Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Pdf Laravel Package

spatie/laravel-pdf

Create PDFs in Laravel apps from Blade views using Chromium (Browsershot, chrome-php), Gotenberg, Cloudflare Browser Run, WeasyPrint, or DOMPDF. Save to disk or return as a download, with full control over page format and modern CSS.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and pick a driver before writing a single Blade view. The driver choice is the single biggest decision you'll make with this package, because it determines what CSS you can use, what your server needs installed, and how you'll debug rendering.

composer require spatie/laravel-pdf
php artisan vendor:publish --tag=laravel-pdf-config   # config/laravel-pdf.php

Then set the driver in .env:

LARAVEL_PDF_DRIVER=browsershot
Driver Runtime requirement Reach for it when
browsershot (default) Node + Puppeteer + Chromium on the host You want modern CSS (grid, flexbox, gap), web fonts, JS-rendered charts — the normal Laravel app case
gotenberg A Gotenberg container reachable over HTTP Your app runs in a container/host where you can't install Node; you want PDF rendering as a stateless sidecar
cloudflare (Browser Run) Cloudflare account + API token Serverless/Lambda-style deploys where you can't ship Chromium
weasyprint Python + WeasyPrint binary You need real CSS Paged Media (@page margin boxes, running headers, cross-references)
dompdf Nothing (pure PHP) Zero-dependency environments, or simple documents; expect limited CSS
chrome-php/chrome Chromium You're already running Chromium for other purposes

Look here first: README.md for the five-line example, then the docs site (spatie.be/docs/laravel-pdf) for formatting-pdfs.md and testing-pdfs.md. Skip the source entirely until something misbehaves.

Your first use case is almost certainly an invoice or a report download. The minimum viable flow is a Blade view plus a controller action:

use Spatie\LaravelPdf\Facades\Pdf;

class DownloadInvoiceController
{
    public function __invoke(Invoice $invoice)
    {
        return Pdf::view('pdfs.invoice', ['invoice' => $invoice])
            ->format('a4')
            ->name("invoice-{$invoice->number}.pdf");
    }
}

Returning the builder from a controller is what makes this package pleasant day-to-day — Laravel turns it into a streamed download without you touching response()->streamDownload(). If you're on Laravel 12 with Boost installed, run php artisan boost:install to register the package skill; it keeps AI-assisted codegen from hallucinating API that doesn't exist.

Implementation Patterns

Wrap generation in an invokable action, not inline controller code. PDF generation has enough moving parts (view, data shaping, margins, header/footer, storage, naming) that you want it in one testable place:

class GenerateInvoicePdf
{
    public function __invoke(Invoice $invoice): PdfBuilder
    {
        return Pdf::view('pdfs.invoice', [
                'invoice' => $invoice->load('lines'),
                'company' => config('company'),
            ])
            ->format('a4')
            ->margins(20, 15, 25, 15)
            ->footerView('pdfs.partials.footer');
    }
}

Then return app(GenerateInvoicePdf::class)($invoice); from the controller, and reuse the same action from a queued job, a console command, or a mail attachment — the builder is driver-agnostic and the view is the only thing that changes.

Persist instead of streaming when the document is expensive. A Chromium render is typically hundreds of milliseconds to a couple of seconds. If the same PDF is downloaded more than once, or you're generating hundreds in a batch, queue it and store it:

Pdf::view('pdfs.report', compact('report'))
    ->format('a4')
    ->save("reports/{$report->id}.pdf", disk: 's3');

The save($path, $disk) signature lets you target any filesystem disk directly — don't hand-roll Storage::put() around ->toString() unless you need to transform the bytes. Pair this with a cached/predictable filename and a signed URL, and the download endpoint becomes trivial.

Use headers and footers as Blade partials, not CSS @page. With Chromium you get Browsershot's header/footer templates:

->headerView('pdfs.partials.header')   // or ->headerHtml('<div>…</div>')
->footerView('pdfs.partials.footer')

Inside those templates, Chromium injects the pageNumber, totalPages, title, url, and date classes, so a footer that shows "Page 3 of 12" is just:

<div style="font-size:9px; width:100%; text-align:center;">
  Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</div>

This is the single most common formatting requirement and the most common source of wasted hours — full CSS Paged Media (@page { @bottom-center { … } }) is a WeasyPrint feature, not a Chromium one.

Inline every asset the renderer can't fetch. Browsershot renders an HTML string, so relative paths break. Use asset()/url() so URLs are absolute, or — better for anything private — inline the bytes:

<img src="@InlinedImage(public_path('img/logo.png'))" alt="Logo">

@InlinedImage base64-encodes and mime-detects the file, which is the reliable way to get logos and signatures into a PDF when the document must render on a worker that can't reach your app over HTTP. Same logic applies to fonts: a webfont the renderer can't fetch silently falls back to Times New Roman, and you won't notice until a customer does.

Escape to Browsershot when you need the last 5%. The builder deliberately doesn't wrap every Puppeteer option:

->withBrowsershot(function (Browsershot $browsershot) {
    $browsershot->setOption('args', ['--no-sandbox'])
               ->setOption('printBackground', true)
               ->timeout(120);
})

getBrowsershot() is public (since 1.5.2) if you need to inspect the configured instance in a test or a custom driver integration. Keep these callbacks short and centralised in your action class — a withBrowsershot closure scattered across ten controllers is a maintenance tax.

Make a test-time default of Pdf::fake(). Add it to TestCase::setUp() or a Pest beforeEach, and assert on the builder instead of the bytes:

Pdf::fake();

$this->get(route('download-invoice', $invoice))->assertOk();

Pdf::assertRespondedWithPdf(fn (PdfBuilder $pdf) =>
    $pdf->contains('Invoice #'.$invoice->number)
);

contains() / assertDontSee() search the generated text — in fake mode that means the rendered HTML, in a real render it means pdftotext output. Use Pdf::assertViewIs('pdfs.invoice') and view-data assertions for the structural checks, and reserve one or two real (unfaked) render tests for the full pipeline. Those real tests need pdftotext on the machine (brew install poppler-utils / poppler-utils via apt) and a working Chromium — which is exactly why you don't want a hundred of them in your suite.

Gotchas and Tips

Browsershot is a system dependency, not a Composer dependency. composer require succeeds and the package looks installed, but every render fails until Node, Puppeteer, and Chromium exist on the machine and the running user can launch Chrome. This is the number one "it works locally, not in CI/prod" failure. If you can't install it (Docker images without Node, read-only filesystems, shared hosting), switch to gotenberg — it moves the whole problem into a container you can version alongside your app — or to dompdf for simple documents.

Set the paper format explicitly, every time. Defaults differ from what people assume, and format('a4') versus a paperSize(210, 297) call are not interchangeable when margins are involved. Explicit is cheap; a reprinted invoice run is not.

Chromium concurrency will eat your queue workers' memory. Each render spawns a Chrome process. Ten workers × a Chromium each is a fast route to OOM kills. Cap the queue's concurrency for PDF jobs specifically (--max-jobs, a dedicated queue name), and prefer gotenberg at volume because it pools browsers rather than starting one per job.

Header/footer templates do not inherit your document's CSS or fonts. They're rendered in a separate context. Inline the styles in the partial, keep them tiny, and don't reference your compiled Tailwind bundle — it won't be there.

Page breaks need explicit rules, and break-inside: avoid is your friend. Tables and invoice line items silently split mid-row otherwise. Add page-break-inside: avoid to row/card components and break-before: page where a section must start fresh. Verify with a deliberately long test fixture, not a two-line one.

Backgrounds are on by default since 1.0.1, but colour management isn't always. If your brand colours look washed out, check print-color-adjust: exact (the package ships a @printColor-style directive) — printers and Chromium both like to "optimise" backgrounds away.

save() with and without a disk historically behaved inconsistently. It was tightened in 1.5.3, alongside the fix that made name()/download() honour the name you set. If your Storage paths look wrong, check you're not on an old pin — anything < 1.5.3 is worth upgrading rather than working around.

Text assertions fail on non-text content, and that's not a bug. contains() can't find a string rendered inside an image, a canvas chart, or text converted to SVG paths. It also fails on ligature/kerning oddities in some fonts. If an assertion mysteriously fails while the PDF looks right, open it and check the string is real text in the expected case.

Treat user-supplied content as hostile. Chromium can fetch file:// URLs and reach internal network addresses from the rendering host — an HTML injection into a PDF view is an SSRF and potentially a local-file-read. Never pass raw user HTML into a PDF view without sanitising it, prefer Blade escaping everywhere, and restrict the renderer's network access if it shares a VPC with your database.

Make output deterministic if you plan to cache or snapshot-test it. now() in a footer, a random document ID, or a "generated at" timestamp guarantees a cache miss and a flaky assertion. Pass the timestamp in as data you control.

Timeouts deserve a number, not a default. The default render timeout is generous enough that a hung Chrome can stall a request for a long time before failing visibly. Set an explicit timeout in withBrowsershot() (or the Gotenberg client config) and let the failure surface in your logs rather than in a spinning browser tab.

Extension point to remember: everything is funnelled through the builder, so when you need behaviour the package doesn't expose, the withBrowsershot callback and a custom driver in config/laravel-pdf.php get you out of almost any corner without forking. Reach for those before you reach for vendor/.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style