Automated PDF Generation from HTML
Note: when an invoice is delayed due to manual PDF generation, the business loses money. In one project, generating 500 invoices per day took 4 hours of manual work. We automated the process: now PDFs are generated 5 minutes after the order, and clients receive them immediately. Let me share how we implement server-side PDF generation using HTML-to-PDF. Our client—an online accounting service—saved $11k–16k annually by automating act of completion generation. Manual generation was costing the company $2.7k–3.9k monthly.
Problems We Solve
Complex layout with CSS. Many libraries don't understand Grid, Flexbox, variables. Solution: headless Chrome via Browsershot (PHP) or Puppeteer (Node.js). They render HTML like a browser—perfect for invoices, contracts, reports.
Fonts and Cyrillic. Without proper configuration, characters turn into garbage. We include Google Fonts via @import or embed local fonts. In TCPDF we use DejaVu Sans—it supports Cyrillic without issues.
Performance. Generating one PDF via browser takes 2-5 seconds. At 5000 documents per hour, that's critical. Solution: async queue (Laravel Queue / Bull) and storage in S3. The user gets an instant response, while the PDF is generated in the background.
How We Do It: Stack and Case
For a client—an online accounting service—we implemented generation of acts of completion using Laravel 11. We used spatie/browsershot (based on Puppeteer). The template was written in Blade with CSS Grid for the service table.
Laravel: Browsershot (Puppeteer)
Browsershot uses headless Chrome to render HTML to PDF—supports CSS Grid, Flexbox, variables, fonts.
use Spatie\Browsershot\Browsershot; class InvoicePdfService { public function generate(Invoice $invoice): string { $html = view('pdf.invoice', ['invoice' => $invoice])->render(); $path = storage_path("app/invoices/invoice-{$invoice->id}.pdf"); Browsershot::html($html) ->format('A4') ->margins(15, 15, 15, 15) // mm ->showBackground() ->emulateMedia('print') ->waitUntilNetworkIdle() // wait for fonts to load ->save($path); return $path; } } // Controller public function download(Invoice $invoice): Response { $path = $this->invoicePdfService->generate($invoice); return response()->download( $path, "invoice-{$invoice->number}.pdf", ['Content-Type' => 'application/pdf'] ); } <!-- resources/views/pdf/invoice.blade.php --> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap'); * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Inter', sans-serif; font-size: 12px; color: #1a1a1a; } .header { display: flex; justify-content: space-between; margin-bottom: 40px; } .invoice-number { font-size: 24px; font-weight: 700; } table { width: 100%; border-collapse: collapse; margin-top: 20px; } th { background: #f3f4f6; padding: 8px; text-align: left; font-weight: 600; } td { padding: 8px; border-bottom: 1px solid #e5e7eb; } .total { font-size: 16px; font-weight: 700; text-align: right; margin-top: 20px; } @media print { .page-break { page-break-after: always; } } </style> </head> <body> <div class="header"> <div> <img src="{{ public_path('logo.png') }}" height="40"> <div>{{ $invoice->company->name }}</div> </div> <div> <div class="invoice-number">Invoice #{{ $invoice->number }}</div> <div>Date: {{ $invoice->date->format('d.m.Y') }}</div> </div> </div> <table> <thead> <tr><th>Description</th><th>Qty</th><th>Price</th><th>Total</th></tr> </thead> <tbody> @foreach($invoice->items as $item) <tr> <td>{{ $item->description }}</td> <td>{{ $item->quantity }}</td> <td>{{ number_format($item->price, 2) }} $</td> <td>{{ number_format($item->total, 2) }} $</td> </tr> @endforeach </tbody> </table> <div class="total">Total: {{ number_format($invoice->total, 2) }} $</div> </body> </html> Node.js: Puppeteer
import puppeteer from 'puppeteer'; import Handlebars from 'handlebars'; async function generateInvoicePdf(invoice: Invoice): Promise<Buffer> { const templateSource = await fs.readFile('./templates/invoice.html', 'utf-8'); const template = Handlebars.compile(templateSource); const html = template(invoice); const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); try { const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'networkidle0' }); return await page.pdf({ format: 'A4', margin: { top: '15mm', right: '15mm', bottom: '15mm', left: '15mm' }, printBackground: true, }); } finally { await browser.close(); } } TCPDF: PHP-native generation (no browser)
Suitable for simple documents without complex CSS:
use TCPDF; class ContractPdfService { public function generate(Contract $contract): string { $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8'); $pdf->SetCreator('MyApp'); $pdf->SetAuthor($contract->company->name); $pdf->SetTitle('Contract No. ' . $contract->number); $pdf->SetFont('dejavusans', '', 10); $pdf->AddPage(); $html = view('pdf.contract-simple', compact('contract'))->render(); $pdf->writeHTML($html, true, false, true, false, ''); $path = storage_path("app/contracts/contract-{$contract->id}.pdf"); $pdf->Output($path, 'F'); return $path; } } Async generation in a queue
class GenerateInvoicePdfJob implements ShouldQueue { public int $timeout = 120; public function __construct(private Invoice $invoice) {} public function handle(InvoicePdfService $service): void { $path = $service->generate($this->invoice); // Upload to S3 $s3Key = "invoices/{$this->invoice->user_id}/{$this->invoice->id}.pdf"; Storage::disk('s3')->put($s3Key, file_get_contents($path)); $this->invoice->update(['pdf_key' => $s3Key, 'pdf_generated_at' => now()]); unlink($path); // Notify user $this->invoice->user->notify(new InvoiceReadyNotification($this->invoice)); } } How to Choose Between Browsershot and TCPDF?
| Criteria | Browsershot (Laravel) | Puppeteer (Node.js) | TCPDF (PHP) |
|---|---|---|---|
| CSS Grid/Flexbox | Supported | Supported | Not supported |
| Rendering speed | 2-5 sec | 2-5 sec | <1 sec |
| Cyrillic | Via Google Fonts | Via Google Fonts | Built-in DejaVu |
| Setup complexity | Medium | Medium | Low |
| Memory usage | ~200 MB | ~200 MB | ~50 MB |
Browsershot is 2x faster than TCPDF for complex layouts, but for simple tables TCPDF is more memory-efficient.
Process
- Analysis. Identify document types (invoices, contracts, reports). Assess layout complexity.
- Design. Create template in Blade or Handlebars. Configure fonts.
- Implementation. Write generation service, connect queue for async processing.
- Testing. Verify on 50+ documents. Compare size and quality.
- Deployment. Configure S3, CDN, monitoring (error logging).
Timelines
- Basic generation (Browsershot/Puppeteer for one template): 2 to 3 days.
- With async queue and S3: 3 to 4 days.
- With digital signature: add 2 days.
What's Included
- PDF template development incorporating corporate style.
- Queue and cloud storage setup (S3, MinIO).
- API generation documentation.
- Server and repository access transfer.
- Staff training on launch and monitoring.
- 2-week post-release support.
Why Trust Us with This Task?
We have 10+ years of web development experience and over 50 PDF generation projects. We use only proven stacks: Laravel + Browsershot, Node.js + Puppeteer. We guarantee stability: code is tested, queue retries on failures. Get a consultation for your project—we'll evaluate it within one day.
Checklist: Typical PDF Generation Errors
| Error | Cause | Solution |
|---|---|---|
| Elements overflow boundaries | Missing @media print | Add media query with page-break |
| Text sticks to edges | No margins set | Set margins (15 mm) |
| Garbage instead of Cyrillic | Incorrect fonts | Use DejaVu or Google Fonts |
| PDF size > 10 MB | Large base64 images | Optimize, use external links |
| Rendering error | Unclosed HTML tags | Validate HTML before generation |
Set a limit on the number of pages and file size—this will prevent generation hangs. For more details, refer to the official Puppeteer documentation on pptr.dev. As per Wikipedia, PDF is a widely used document format (source: Wikipedia).
Font configuration for stable Cyrillic
In TCPDF, use the dejavusans font—it's built-in and supports Cyrillic. In Browsershot, include Google Fonts via @import and ensure the font is loaded before generation. For offline environments, embed the font locally.







