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.







