tklaversma / flexpdf
HTML to PDF for Laravel with real CSS3 support: flexbox, grid, transforms and font embedding, with no binary dependencies.
Package info
pkg:composer/tklaversma/flexpdf
Requires
- php: ^8.4
- ext-dom: *
- ext-gd: *
- ext-zlib: *
- illuminate/contracts: ^11.0||^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0||^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
HTML to PDF for Laravel, in pure PHP, with a modern CSS layout engine.
Write your PDF templates as Blade views with flexbox, grid, custom fonts and
real typography. Render them in the same PHP process as the rest of your
request. No headless browser, no wkhtmltopdf, no Gotenberg container, no
Node.js, no binaries to ship.
use FlexPDF\Facades\Pdf; Pdf::view('invoices.show', ['invoice' => $invoice])->download('invoice.pdf');
Note
FlexPDF is in beta. The engine is heavily tested and renders real production templates today, but the API may still shift before 1.0. Anything marked 🚧 below is not a dead end: it is on the list and being worked on. See ROADMAP.md.
Why FlexPDF
Every HTML-to-PDF approach in PHP sits somewhere on one trade-off: layout quality against deployment weight.
- Browser-based tools (Browsershot, Snappy, Gotenberg) produce excellent output, at the cost of a Chrome or wkhtmltopdf process next to your app: something to install, patch, monitor and scale, with an out-of-process failure mode.
- Classic pure-PHP libraries (dompdf, mpdf, TCPDF) deploy with
composer requireand nothing else, but their CSS support predates flexbox. Layouts get built out of tables and absolute positioning, and the template you wrote is not the page you get.
FlexPDF keeps the pure-PHP deployment model and closes the layout gap with an engine written from scratch: it parses your HTML and CSS, runs the real flexbox, grid and table algorithms to give every box its geometry, cuts the result into pages, and writes the PDF itself, fonts subset and embedded. Not a browser behind an API, and not HTML mapped onto PDF tables.
So flexbox, grid, container queries, @page, custom properties and modern
color functions all work, and the CSS you write for the screen is close to
the CSS you write for paper. And because everything happens in your PHP
process, a render is one function call: no temp files, no shelling out,
nothing to keep running.
Two things about the output are worth knowing up front:
- Same input, same file. Always. Render the same HTML twice and you get the exact same PDF, byte for byte: today, next week, on your laptop, on the server. Most tools cannot do this, because they put timestamps and random IDs in the file. Here you can write a test that says "the output must equal this saved file", cache by content hash, and re-render last year's invoice into a provably identical document. (The one exception is an encrypted document, because encryption keys must be random.)
- Bad input cannot hang your server. A broken or hostile document can send a layout engine into an endless loop, and this one runs inside your PHP process. So every render has hard limits: pages, nesting depth, and a wall-clock timeout. Cross one and you get a normal exception to catch, not a request that eats CPU until something kills it.
What CSS is supported
The rule of thumb: the CSS you write for a modern browser mostly works here. The tables give the honest picture, gaps included: ✅ works today, ⚠️ partial, 🚧 not yet, in development.
Layouts
| Feature | Notes | |
|---|---|---|
| ✅ | Flexbox | gap, order, reverse directions, auto margins, baseline alignment |
| ✅ | Grid | fr, minmax(), repeat(), auto-fill / auto-fit, template areas, spans |
| 🚧 | Subgrid | |
| ✅ | Tables | colspan / rowspan, border-collapse; <thead> and <tfoot> repeat on every page a long table crosses |
| ✅ | Floats | with real text wrap around them |
| ✅ | Positioning | relative, absolute, fixed |
| ✅ | Multi-column | balanced |
| ✅ | Box model | margin collapsing, box-sizing, percentage padding and margins, aspect-ratio, overflow: hidden |
Values, colors and conditional rules
| Feature | Notes | |
|---|---|---|
| ✅ | CSS variables | var(), also inside calc() |
| ✅ | Math functions | calc(), min(), max(), clamp() |
| ✅ | Viewport units | vw / vh resolve against the page size |
| ✅ | Modern colors | hsl(), oklch(), color-mix(), hex with alpha |
| ✅ | Conditional rules | @media print, @supports, @container queries, @scope, @import |
| ⚠️ | @layer |
rules inside apply; layer ordering is ignored |
| ✅ | Generated content | ::before / ::after, counters |
Pages and breaks
| Feature | Notes | |
|---|---|---|
| ✅ | @page |
paper size and margins from CSS, :first, named pages |
| ✅ | Forced breaks | break-before / break-after: page |
| ✅ | Keeping content together | break-inside: avoid, orphans, widows |
| ⚠️ | Named page with another width | the sheet size is right, line wrapping still uses the document's width |
Graphics and decoration
| Feature | Notes | |
|---|---|---|
| ✅ | Images | PNG, JPEG, GIF, WebP, data: URIs, object-fit |
| ✅ | SVG | inline and as <img>, full path grammar |
| ✅ | Transforms | translate, rotate, scale, matrix |
| ✅ | Effects | opacity, mix-blend-mode |
| 🚧 | filter |
grayscale(), blur() and the rest are dropped for now |
| ✅ | linear-gradient() |
|
| 🚧 | radial-gradient() |
renders as no background for now |
| ✅ | Borders and shadows | border-radius, box-shadow |
| ⚠️ | text-shadow |
offset and color drawn, blur radius ignored |
Anything the engine does not know is skipped the way a browser skips unknown CSS: that declaration is dropped and the rest of your stylesheet still applies. What is missing beyond CSS is under Current limitations.
Requirements
- PHP 8.4+
- Laravel 11+
- The
dom,gdandzlibextensions (all commonly enabled)
Installation
composer require tklaversma/flexpdf
Optionally publish the config file:
php artisan vendor:publish --tag="flexpdf-config"
This gives you config/flexpdf.php with defaults for page size, margins,
fonts, metadata, safety limits, remote images, tagging, PDF/A and encryption.
Everything in it can also be set per document on the builder.
Quick start
The most common case: a controller that turns a Blade view into a PDF the browser downloads.
use FlexPDF\Facades\Pdf; class InvoiceController extends Controller { public function download(Invoice $invoice) { return Pdf::view('invoices.show', ['invoice' => $invoice]) ->download("invoice-{$invoice->number}.pdf"); } }
Every call follows the same shape: where the HTML comes from, then what to do with the PDF. Anything in between (page size, fonts, metadata, and so on) is optional and covered in the sections below.
Where the HTML comes from. Pick one:
Pdf::view('invoices.show', ['invoice' => $invoice]) // a Blade view, like view() Pdf::html('<h1>Hello</h1>') // an HTML string you already have Pdf::loadFile(resource_path('templates/report.html')) // an HTML file on disk
What to do with the PDF. Pick one:
->download('invoice.pdf') // browser shows a save dialog ->inline('invoice.pdf') // opens right in the browser tab ->save(storage_path('app/invoices/invoice.pdf')) // write to disk ->output() // the raw bytes, when you need them yourself
The download() and inline() functions return responses, so you return them
straight from a controller. The function stream() also exists and behaves like
inline(), as a streamed response. The function save() writes the file and
returns the page count.
The function output() is for everything else Laravel does with bytes, like
storing on a disk or attaching to a mail:
// Store on disk Storage::disk('s3')->put( 'invoices/invoice.pdf', Pdf::view('invoices.show', $data)->output() ); // Use in a Mailable $this->attachData( Pdf::view('invoices.show', $data)->output(), 'invoice.pdf', ['mime' => 'application/pdf'] );
Not using Laravel? The engine has no Laravel dependency:
FlexPDF\Engine\Html::make($html) exposes the same capabilities directly.
Examples
examples/ holds nine complete documents, each a Blade template
plus the exact builder chain that renders it: an invoice, a bank statement, an
annual report, a slide deck, a product catalog, a CSS showcase, a fillable form,
a PDF/A-3 e-invoice with an attached XML, and an encrypted document. They run
from a clone with composer install && php examples/render.php, and each one is
meant to be copied into your app as a starting point. The
examples README says what every document shows.
Page setup, headers and footers
Pdf::view('report') ->page('a4') // a3, a4, a5, letter, legal, tabloid, or [w, h] in points ->landscape() ->margins(40, 30) // top/bottom, left/right (CSS shorthand order) ->header('<div>Quarterly report</div>') ->footer('<div>Page {page} of {pages}</div>') ->inline('report.pdf');
@page in your CSS works too, including named pages, so a document can carry
its own geometry:
@page { size: A4; margin: 20mm } @page cover { size: A4 landscape; margin: 0 } @page :first { margin-top: 60pt } .cover { page: cover }
Headers and footers can differ per page. Pass a callable: it receives the
1-based page number and the total, and returns that page's markup or null
for none. The count is real, because pagination finishes before the first
header is drawn, so "last page only" is as easy as "first":
Pdf::view('report') ->header(fn (int $page): ?string => $page === 1 ? null : '<div>Quarterly report</div>') ->footer(fn (int $page, int $total): ?string => $page === $total ? '<div>End of report</div>' : '<div>{page} / {pages}</div>') ->inline('report.pdf');
Fonts and writing systems
Built in, zero setup. The classic PDF fonts Helvetica, Times and Courier,
plus DejaVu Sans, which ships with the package. Common names map onto them,
so font-family: Arial, sans-serif, serif or monospace just work.
If your text contains a character the classic fonts cannot draw (ą, ć, ß,
Greek, Cyrillic, Hebrew, Arabic), it is drawn from DejaVu Sans automatically
instead of coming out as ?. Nothing to configure.
Your own fonts. Point the config at your TTF files:
'fonts' => [ 'Inter' => [ 'regular' => resource_path('fonts/Inter-Regular.ttf'), 'bold' => resource_path('fonts/Inter-Bold.ttf'), 'italic' => resource_path('fonts/Inter-Italic.ttf'), 'bold-italic' => resource_path('fonts/Inter-BoldItalic.ttf'), ], ],
then use font-family: Inter in your CSS. Only regular is required: a
missing bold or italic is generated from it, like a browser does. Fonts can
also be registered for a single document with ->font('Inter', $paths), or
declared in the CSS itself with @font-face.
Embedding stays small: only the characters you actually use go into the PDF, so a 742 KB font typically adds around 54 KB to the document.
Writing systems. Supported out of the box:
- Latin, so every language written in it: English, Dutch, German, French, Polish, Czech, Turkish, Vietnamese, ...
- Greek
- Cyrillic: Russian, Ukrainian, Bulgarian, Serbian, ...
- Hebrew and Arabic: right-to-left just works, Arabic letters connect correctly, and the text stays selectable and searchable in the PDF.
Not supported: CJK (Chinese, Japanese, Korean), Indic scripts such as Devanagari (Hindi), Thai, and Khmer.
The typography details are handled too: hyphenation, justification, kerning, ligatures and small caps.
If a document asks for a font that is not there, it still renders (in a
fallback font) and ->fontReport() tells you afterwards what was missing.
Prefer failing loudly? ->strictFonts() throws instead.
What the PDF itself can do
The output is not just pictures of pages:
- Selectable, searchable text, in every script the engine sets
- Links: external URLs and internal
#anchorjumps become real annotations - Bookmarks: the heading structure becomes the reader's outline panel
- Fillable forms:
<input>,<textarea>and<select>become AcroForm fields (text, password, checkbox, radio, combo and list boxes) - Metadata: title, author, subject, keywords, creator, producer
Pdf::view('report') ->metadata(['title' => 'Q3 Report', 'author' => 'Acme B.V.']) ->initialView('FitH', page: 1) // how the reader opens the document ->pageMode('UseOutlines') // with the bookmarks panel showing ->save($path);
Accessible and archival PDF
Pdf::view('invoices.show', $data) ->tagged(lang: 'en') // tagged PDF: a real structure tree and /Lang ->pdfa() // PDF/A-3 (ISO 19005-3), level B, or 'A' ->pdfua() // claim PDF/UA-1 (ISO 14289-1) as well ->save($path);
Tagging gives every piece of content a role and a reading order, which is what screen readers consume. PDF/A adds the color profile, XMP metadata and font rules an archival file needs; the output validates against veraPDF. Claims are honest: asking for level A or PDF/UA without a structure tree and language is refused with an exception rather than written as an empty promise.
Encryption
Pdf::view('payslip', $data) ->encrypt('user-password', allow: ['print']) ->download('payslip.pdf');
AES-256 (PDF 2.0, revision 6), with the standard permission set: print,
copy, modify, annotate, fill_forms, assemble,
print_high_quality. Older, broken revisions are deliberately not offered.
Output opens in Acrobat 9+, macOS Preview, pdf.js, pdfium and Ghostscript.
File attachments and e-invoicing
A file can travel inside the document, which is what PDF/A-3 exists for and what Factur-X and ZUGFeRD e-invoicing are built on:
Pdf::view('invoices.show', $data) ->pdfa() ->attach('factur-x.xml', $xml, 'text/xml', 'Invoice data', 'Data') ->save($path);
The attachment is written as an associated file: the catalog's /AF array
names it and its /AFRelationship (Source, Data, Alternative,
Supplement, Unspecified) says what it is to the document, which is the
pair an e-invoice consumer looks for. Attachments are encrypted along with
everything else when the document is.
Rendering untrusted HTML
The supported input is a template you control. If you render HTML that users influence, read this section.
Layout is a fixpoint computation, and some inputs never converge. Without ceilings, a few KB of hostile HTML consumes unbounded CPU. The limits are therefore security controls, not tuning knobs:
'limits' => [ 'max_pages' => 2000, 'max_depth' => 64, 'max_length' => 200000.0, 'max_font_size' => 2000.0, 'timeout_seconds' => 30.0, 'max_gradient_stops' => 500000, ],
A render that exceeds them throws a named exception
(LayoutTimeoutException, PageLimitExceededException,
GradientLimitExceededException) rather than returning a document quietly
missing its tail. Override the wall clock per render with
->timeout($seconds).
File access is scoped. base_path is the only directory a document can
read: <img src>, @font-face src and stylesheet hrefs resolve against it
and are refused outside it, symlinks followed. With no base path, no file is
reachable at all.
The network is not touched unless you turn it on. Remote images are off by default, and enabling them requires naming the hosts:
'remote_images' => [ 'enabled' => true, 'allowed_hosts' => ['cdn.example.com'], 'max_bytes' => 2_000_000, 'timeout' => 5.0, ],
Fetches are https only, exact host match, private and loopback addresses
refused, no redirects, body size capped while reading, bytes sniffed rather
than the content type believed. A remote stylesheet or font is never
fetched: a stylesheet is a second document, and a font is glyph data copied
into your output.
Still, rendering is CPU work in your process. If the input is not yours, put it behind a queue.
Performance
Real templates, rendered on a laptop, single process:
| Document | Pages | Time | Output |
|---|---|---|---|
| Invoice (flex layout, custom font, SVG logo) | 1 | ~180 ms | 49 KB |
| Bank statement (tables across pages) | 3 | ~330 ms | 43 KB |
| Report (charts, grid, images) | 5 | ~320 ms | 245 KB |
| Presentation (full-bleed pages) | 8 | ~190 ms | 466 KB |
Your numbers will vary with content; the point is the order of magnitude. A typical business document is a few hundred milliseconds, in-process, with no container or browser to keep warm.
Current limitations
The CSS gaps are marked 🚧 and ⚠️ in the tables under What CSS is supported. Beyond those, not here yet:
- Writing systems: no Chinese, Japanese, Korean, Indic scripts, Thai or Khmer, and no vertical writing or ruby. See Fonts and writing systems.
And two things that are by design and will stay this way:
- An encrypted document is not byte-reproducible. Key material is random per render, as it should be. Everything else in the writer is deterministic.
- No JavaScript in the output. A PDF that executes code is an attack surface this package chooses not to have.
Roadmap
FlexPDF is in active development. Everything marked 🚧 or ⚠️ on this page is collected in ROADMAP.md, with what happens today until each item lands. No dates and no fixed order: what real documents run into first gets built first.
Testing
composer test # Pest, the Laravel integration composer test-engine # the 9 engine suites, 596 tests composer test-all # both
The engine suites verify output by parsing the generated PDFs back, and the rasterizing checks shell out to Python. To run them locally:
python3 -m venv .venv .venv/bin/pip install pypdf pypdfium2 numpy pillow fonttools # macOS brew install --cask font-dejavu # Debian/Ubuntu sudo apt-get install fonts-dejavu-core
None of this is needed to use the package, only to run the engine suites.
There is also a fuzzer, which has found more bugs than every hand-written test combined:
php tests/Engine/fuzz.php <seed> <iterations>
It generates random documents and asserts invariants: finite geometry, no negative sizes, nothing painting past a page edge, no content lost, pagination proportional to content, and PDFs that parse back.
Credits
License
The MIT License (MIT). Please see License File for more information.