Generating PDFs in a Web App: Invoices That Print Right
Every serious app eventually needs document output: invoices, quotes, packing slips, reports. The feature gets scoped as a day of work and then the problems arrive one by one. A Polish customer opens the invoice and the special characters are boxes. A two-page quote puts the totals alone on page three. The bank payment reference reads correctly on screen and wrong on paper. PDF generation is a layout problem more than a library call, and the layout only proves itself when someone prints it.
Pick the renderer by what the document contains
There are two workable approaches and the choice is mostly decided upfront:
- HTML to PDF through a headless browser (Chromium, or a service like Gotenberg): full CSS support, including flexbox and grid. The document is an ordinary Blade or Vue template, so one stylesheet handles screen and print.
- Pure PHP libraries (Dompdf, Snappy): simpler to deploy, no browser process, but CSS support stops around 2010. Fine for a plain invoice; painful the moment you need a two-column layout with a repeating table header.
We default to headless Chromium through Gotenberg in a Docker container. The browser is a dependency we already have, and the HTML path means the developer who writes the invoice is writing markup they already know.
Fonts are where Unicode goes to die
The single most common PDF bug is a font without the glyphs the document needs. The renderer falls back to something that looks fine in Latin, and a customer named Büřováček or an amount with a Polish zł symbol turns into empty squares.
The fix is boring and non-negotiable: ship a font with real Unicode coverage in the project, register it explicitly in the print stylesheet, and put a test string through the pipeline before launch. The test string should include the characters your users actually type, not an idealized sample. If your market speaks Latvian, it includes ā, č, ē and ļ.
Design for page breaks, not against them
A document that fits on one screen rarely fits on one page. Three things make multi-page output survive:
page-break-inside: avoidon each table row so a line item never splits across a page.- A repeating header on every page (the
theadtrick works with browser-based renderers) so the accountants page of a five-page quote still says whose invoice this is. - Totals in a footer that repeats or a block anchored to the last page, never floating wherever the content happened to end.
If the document supports page numbers, test with a document that is forced to three pages, because the one page nobody checks is the second one.
Render from stored data, never from the request
Generate the PDF from the persisted invoice, not from form input that happens to be lying around. The document must look the same tomorrow as today, which means the renderer's inputs are database rows. Anything else produces the famous "the PDF disagrees with the app" support ticket.
And do not render inside the web request: a browser-based renderer takes a second or several, and an invoice button that takes five seconds under load teaches users to click it again. Queue the job, deliver by download link or email attachment, and show immediate feedback that generation started.
Verify the output programmatically
A PDF that opens is not a PDF that is correct. Before calling the feature done, extract the text from the generated file and assert on what matters: the total, the payment reference, the VAT number. Libraries like smalot/pdfparser make this a five-line test, and it catches the regressions that a screenshot never will, since fonts and layouts break without anyone noticing a single pixel changed.
The final check is a human one. Print it. Actual paper, actual printer. The screen forgives small sizes and tight margins; the printer does not, and it always catches the footer that falls off the page.