Laravel HTML to PDF: dompdf, Browsershot or laravel-pdf
Generate PDFs from Blade views in Laravel: dompdf, Browsershot, spatie/laravel-pdf and an API, measured on one invoice, with queued jobs, Storage and tests.
Laravel has no PDF renderer of its own. You render a Blade view to HTML and hand it to an engine. The engine choice decides almost everything else: which CSS works, what you install on the server, and how long each PDF takes. In 2026 there are four paths worth considering:
- dompdf (usually through
barryvdh/laravel-dompdf): pure PHP, no browser. - Browsershot (
spatie/browsershot): drives Chrome through Puppeteer, so it needs Node.js. - spatie/laravel-pdf: a
Pdffacade on top of an engine. Version 2 lets you pick the driver. - An HTTP API: Chrome runs somewhere else, and Laravel sends HTML and gets PDF bytes back.
We printed the same 60-line invoice with dompdf and Browsershot, checked which CSS survived, and wrote the Laravel parts around them: a queued job, the Storage facade and a test. This page belongs to our HTML to PDF API cluster. For a deeper engine test (five pages, ten CSS features, JavaScript), read dompdf vs Browsershot. This page is about wiring the choice into a Laravel app.
Tested on 2026-10-18 on macOS (Apple Silicon) with PHP 8.5.10.
dompdf/dompdf 3.1.6(the enginebarryvdh/laravel-dompdfwraps).spatie/browsershot 5.4.0withpuppeteer 24.43.1(Chrome 148) on Node.js 22.18.0. Both engines got the invoice HTML as a string. Times are the median of five calls in one PHP process; page counts and sizes come frompdfinfo. Not run: the Laravel package wrappers,spatie/laravel-pdf, the job and the test (no Laravel app in this harness).
The measurement
| Engine | Pages | Bytes | Median time | Flex header | Grid block | @media print |
Rounded badge | Repeated <thead> |
Forced page break |
|---|---|---|---|---|---|---|---|---|---|
| dompdf 3.1.6, default options | 3 | 6,920 | 81 ms | Stacked | Stacked | Ignored | Yes | Yes | Yes |
dompdf 3.1.6, defaultMediaType = print |
3 | n/a | n/a | Stacked | Stacked | Applied | Yes | Yes | Yes |
| Browsershot 5.4.0 (Chrome 148) | 3 | 118,393 | 651 ms | Row | Two columns | Applied | Yes | Yes | Yes |
What to take from it:
- dompdf is about eight times faster here and the file is seventeen times smaller. The small size comes from fonts: dompdf used the PDF built-in Helvetica and embedded nothing, while Chrome embedded font subsets.
- dompdf ignores flex and grid. Our header (title left, invoice number right) and the two-column "billed to / issued" block both became single stacked columns. The document stayed readable. If your designer's layout depends on either, dompdf will not match it.
- dompdf prints with screen CSS by default. Our
.screen-onlyline, hidden with@media print, appeared in the dompdf PDF and not in the Chrome one. One option fixes it (below). - Browsershot starts a new Chrome per call. That is most of its 651 ms. The print itself, on a page Chrome already has open, took about 110 ms in our Puppeteer test.
Option 1: dompdf through laravel-dompdf
composer require barryvdh/laravel-dompdf php artisan vendor:publish --provider="Barryvdh\DomPDF\ServiceProvider"
In a controller:
use Barryvdh\DomPDF\Facade\Pdf; public function download(Invoice $invoice) { return Pdf::loadView('pdf.invoice', ['invoice' => $invoice]) ->setPaper('a4') ->download("invoice-{$invoice->number}.pdf"); }
Then fix the two things that surprised us, in config/dompdf.php under options:
'default_media_type' => 'print', // apply @media print rules, like a browser does 'enable_remote' => false, // do not fetch URLs found in user-supplied HTML
Under the wrapper, that is the plain dompdf code we ran. It also adds page numbers, which dompdf has no CSS for:
<?php // dompdf-invoice.php: php dompdf-invoice.php invoice.html invoice.pdf require __DIR__.'/vendor/autoload.php'; use Dompdf\Dompdf; use Dompdf\Options; $options = new Options(); $options->setDefaultMediaType('print'); $options->setDefaultPaperSize('a4'); $options->setIsRemoteEnabled(false); $dompdf = new Dompdf($options); $dompdf->loadHtml(file_get_contents($argv[1])); $dompdf->render(); // Stamp "Page X of Y" on every page after layout. $dompdf->getCanvas()->page_text(500, 810, 'Page {PAGE_NUM} of {PAGE_COUNT}', null, 8, [0.4, 0.45, 0.55]); file_put_contents($argv[2], $dompdf->output());
pdftotext on the output found "Page 1 of 3", "Page 2 of 3" and "Page 3 of 3", and no screen-only line. The coordinates are in points from the top-left corner; 810 is near the bottom of an A4 page (842 pt high).
Blade tips for dompdf:
- Write the template in tables and floats. Use a two-cell table for "title left, number right" instead of flex. It prints the same in dompdf and in every browser.
- Reference images by file path. dompdf reads
public_path('img/logo.png')within itschrootsetting, so you can leave remote fetching off. - Use one PDF layout (
resources/views/pdf/layout.blade.php) with inline<style>. Do not include the app's Vite bundle; dompdf does not need your Tailwind build, and the file is large. - Register fonts for non-Latin text. The built-in fonts cover Latin-1. dompdf ships DejaVu Sans for wider coverage; set
font-family: 'DejaVu Sans'in the PDF layout.
Option 2: Browsershot
composer require spatie/browsershot npm install [email protected]
The call we timed, fed with a rendered Blade view:
use Spatie\Browsershot\Browsershot; $html = view('pdf.invoice', ['invoice' => $invoice])->render(); $pdf = Browsershot::html($html) ->format('A4') ->showBackground() ->pdf(); // returns the PDF bytes
Use ->save($path) instead of ->pdf() to write a file, and ->margins(18, 14, 22, 14) for millimetre margins. Two settings matter in production:
- Node and npm paths. PHP-FPM runs with a minimal
PATH. If Browsershot cannot find Node, set->setNodeBinary('/usr/bin/node')and->setNpmBinary('/usr/bin/npm'), or the matchingsetChromePath(). - Untrusted HTML. Chrome follows
file://links and fetches any URL the HTML points at. If customers write any part of the template, escape their input with{{ }}(never{!! !!}) and keep the renderer off networks it should not reach.
Since Chrome is printing, everything from our Puppeteer page.pdf() guide applies, including the two settings that cost people the most time: format('A4') (the default is Letter) and showBackground() (backgrounds are off by default). The Browsershot alternative page covers the server cost of running Chrome next to PHP.
Option 3: spatie/laravel-pdf
spatie/laravel-pdf is a Laravel-first API on top of an engine. Its basic call:
use Spatie\LaravelPdf\Facades\Pdf; Pdf::view('pdf.invoice', ['invoice' => $invoice]) ->format('a4') ->save(storage_path("app/invoices/{$invoice->number}.pdf"));
Return the same builder from a controller with ->name('invoice.pdf') to send it as a response. Version 2 lists several drivers: Browsershot (the default, needs Node and Chrome), DOMPDF (pure PHP), Gotenberg (a Docker service), Cloudflare (a hosted browser), WeasyPrint and chrome-php. The value is that your calls stay the same when you change the engine, for example dompdf in a shared-hosting staging box and Chrome in production. Check the package's driver docs for how to select one, and remember that a dompdf driver still has dompdf's CSS limits from the table above.
It also has a test fake:
use Spatie\LaravelPdf\Facades\Pdf; use Spatie\LaravelPdf\PdfBuilder; Pdf::fake(); $this->get(route('invoices.download', $invoice))->assertOk(); Pdf::assertRespondedWithPdf(fn (PdfBuilder $pdf) => $pdf->contains($invoice->number));
We did not run spatie/laravel-pdf for this page.
Option 4: an HTTP API
If you want Chrome output without Node or Chrome on the PHP servers, send the rendered Blade view to an API. With our PHP SDK, following the PHP SDK reference and the PDF options (not run for this page):
use RenderScreenshot\Client; use RenderScreenshot\TakeOptions; $client = new Client(config('services.renderscreenshot.key')); $pdf = $client->take( TakeOptions::html(view('pdf.invoice', ['invoice' => $invoice])->render()) ->format('pdf') ->pdfPaperSize('a4') ->pdfPrintBackground() ->pdfMargin('18mm') ->pdfFooter('<div style="font-size:8px;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>') );
Page numbers come from the footer template, the same way as in Chrome. Each successful PDF uses one credit, whatever its page count; the free plan includes 150 credits a month. The trade-off is a network round trip per document and a dependency on our service; in return, nothing on your server needs a browser.
Queue it and store it
Whatever engine you choose, do not render inside the web request. A Browsershot call held a PHP worker for well over half a second in our test, and an API call adds network time. Render in a queued job and store the result with the Storage facade:
<?php namespace App\Jobs; use App\Models\Invoice; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Storage; use Spatie\Browsershot\Browsershot; class GenerateInvoicePdf implements ShouldQueue { use Queueable; public int $tries = 3; public int $timeout = 60; public function __construct(public Invoice $invoice) { $this->onQueue('pdfs'); } public function handle(): void { $html = view('pdf.invoice', ['invoice' => $this->invoice])->render(); $pdf = Browsershot::html($html)->format('A4')->showBackground()->pdf(); $path = "invoices/{$this->invoice->id}/{$this->invoice->number}.pdf"; Storage::disk('s3')->put($path, $pdf); $this->invoice->update(['pdf_path' => $path]); } }
Swap the Browsershot line for the dompdf or SDK call above; the rest does not change. Put PDF jobs on their own queue and run a separate worker for it (php artisan queue:work --queue=pdfs), so a month-end burst of invoices does not delay password-reset mail. The download route then streams what is stored:
public function download(Invoice $invoice) { abort_unless($invoice->pdf_path, 404); return Storage::disk('s3')->download($invoice->pdf_path, "invoice-{$invoice->number}.pdf"); }
Our Laravel screenshot guide shows a shared rate limiter for API jobs, which applies to PDFs the same way.
Which one to pick
| Your situation | Pick | Reason |
|---|---|---|
| Invoices and receipts built with tables, shared hosting | dompdf | Pure PHP, 81 ms, 7 KB files |
| Blade views with flexbox, grid or Tailwind layouts | Browsershot or an API | Chrome prints what the browser shows |
| Charts or content drawn by JavaScript | Browsershot or an API | dompdf does not run scripts |
| You want to switch engines later without code changes | spatie/laravel-pdf | One facade, several drivers |
| No Node.js or Chrome on the servers, Chrome output needed | An API | Rendering happens elsewhere |
| Documents may not leave your servers | dompdf or Browsershot | Everything stays local |
Troubleshooting
| Symptom | Engine | Cause | Fix |
|---|---|---|---|
| Hidden screen-only content appears | dompdf | Default media type is screen | default_media_type = print |
| Layout collapses into one column | dompdf | No flexbox or grid support | Tables or floats in the PDF layout, or a Chrome engine |
| Images missing | dompdf | Remote fetch off, or path outside chroot |
Use public_path() file paths |
Characters show as ? or boxes |
dompdf | Built-in fonts are Latin-1 | font-family: 'DejaVu Sans' or register a font |
| No page numbers | dompdf | No CSS page counters | page_text() with {PAGE_NUM} and {PAGE_COUNT} |
Could not find Chrome or node: not found |
Browsershot | PHP-FPM PATH is minimal |
setNodeBinary(), setNpmBinary(), setChromePath() |
| Letter paper instead of A4 | Browsershot | Default paper size | ->format('A4') |
| Colors and shading missing | Browsershot | Backgrounds off by default | ->showBackground() |
| Request times out on download | Any | Rendering in the web request | Queue the job and serve the stored file |
| Tailwind classes have no effect | Any | Vite assets not in the HTML string | Inline the compiled CSS in the PDF layout |
What we ran and what we did not
Ran on 2026-10-18: dompdf (default options and with print media plus page_text) and Browsershot on our invoice fixture, with the versions in the box at the top. Layout results come from looking at the rendered pages; text checks from pdftotext.
Not run: barryvdh/laravel-dompdf, spatie/laravel-pdf, the queued job, the controller, the test fake, and the RenderScreenshot SDK call. We produced no rendered images for this post.
To compare the API output with your current engine, create a free account and send one of your real Blade views through the SDK call above.
Related in this topic
- HTML to PDF API: Paper, Margins, Page Numbers Turn a URL or your HTML into a PDF with real Chrome: paper sizes, margins, header and footer templates with page numbers, one-page fit and print CSS.
- Best HTML to PDF APIs (2026): 7 Options Compared DocRaptor, PDFShift, Api2Pdf, PDFMonkey, Gotenberg, Browserless and RenderScreenshot compared: engines, prices at three volumes, code and an engine test.
- HTML to PDF in Node.js: Puppeteer, Playwright, PDFKit HTML to PDF in Node.js with Puppeteer, Playwright, PDFKit or an API, tested on an invoice and a chart report: times, file sizes, page numbers and pitfalls.
- HTML to PDF in Python: WeasyPrint vs pdfkit vs Playwright HTML to PDF in Python with WeasyPrint, pdfkit (wkhtmltopdf), Playwright or an API, tested on an invoice and a chart report: CSS support, timings, pitfalls.
- Rails HTML to PDF: wicked_pdf vs Grover vs Prawn (2026) Generate PDFs in Rails 8: wicked_pdf, Grover, Prawn or an API. Measured speed and size on one invoice, Active Storage code, and when each choice fits.
- URL to PDF in Node.js and Python, Tested on Live Pages Convert a URL to PDF in Node.js and Python: print media, cookie banners that repeat on every page, lazy images that never load, with tested scripts.