· Tutorials · By Simon Chiu

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 Pdf facade 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 engine barryvdh/laravel-dompdf wraps). spatie/browsershot 5.4.0 with puppeteer 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 from pdfinfo. 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-only line, 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 its chroot setting, 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 matching setChromePath().
  • 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