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 1.2 million rubles annually by automating act of completion generation. Manual generation was costing the company 300,000 rubles 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.







