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 Snappy Laravel Package

barryvdh/laravel-snappy

Laravel wrapper for Snappy that generates PDFs and images from HTML using wkhtmltopdf and wkhtmltoimage. Render Blade views or raw markup and configure page size, orientation, headers, footers, and custom binary options through a simple, familiar Laravel API.

View on GitHub
Deep Wiki
Context7

Getting Started

Two installs, not one. Composer gets you the wrapper; the wkhtmltopdf/wkhtmltoimage binaries are the real work and the source of nearly every day-one failure.

composer require barryvdh/laravel-snappy
php artisan vendor:publish --provider="Barryvdh\Snappy\ServiceProvider"

config/snappy.php exposes pdf.binary, pdf.options, pdf.timeout, pdf.env and the mirrored image.* keys. Bind binaries to env vars so staging and production can differ without a deploy:

WKHTML_PDF_BINARY=/usr/bin/wkhtmltopdf
WKHTML_IMAGE_BINARY=/usr/bin/wkhtmltoimage

Install the binaries inside the image, not on the host — apt-get install -y wkhtmltopdf. Pin the distro version: output differs subtly between 0.12.5 and 0.12.6, and "it rendered differently in prod" is a miserable debugging session. Ghostscript is only needed for postscript/image conversion formats.

Verify the binary as PHP's user, not your shell. Working in your terminal and failing in FPM is the classic first bug — different PATH, different container, different user.

php artisan tinker --execute="echo shell_exec('wkhtmltopdf --version 2>&1');"

Empty output means stop; nothing downstream will work. Check proc_open isn't in disable_functions while you're there.

Where to look first. config/snappy.php, then vendor/barryvdh/snappy/src/PdfWrapper.php — ~150 lines that enumerate every chainable method (loadView, loadHTML, loadFile, setOption(s), setPaper, setTimeout, inline, download, save, output, stream). Facades: Barryvdh\Snappy\Facades\SnappyPdf / SnappyImage; container keys snappy.pdf.wrapper / snappy.image.wrapper.

First use case: the invoice download. Highest value, exercises the whole path in one route.

use Barryvdh\Snappy\Facades\SnappyPdf as PDF;

Route::get('/invoices/{invoice}/pdf', function (Invoice $invoice) {
    return PDF::loadView('pdf.invoice', ['invoice' => $invoice->load('items')])
        ->setOption('enable-local-file-access', true)
        ->setOption('margin-top', 15)
        ->setOption('margin-bottom', 20)
        ->setPaper('a4')
        ->download("invoice-{$invoice->number}.pdf");
});

Build pdf.invoice as a thin wrapper over a shared partial (@include('invoices._body')) that the web page also uses, so print and screen never drift. Render one real invoice on day one — real item count, real logo, real long description. Fidelity problems only surface on real content, and you want them now rather than after sign-off.

Implementation Patterns

Never inline rendering in controllers. One class per document type; the controller or job does one thing.

final class InvoicePdfRenderer
{
    public function __construct(private Invoice $invoice) {}

    public function html(): string
    {
        return view('pdf.invoice', [
            'invoice' => $this->invoice,
            'items'   => $this->invoice->items()->with('product')->get(),
            'totals'  => $this->invoice->totals(),
        ])->render();
    }

    public function pdf(): string
    {
        return $this->wrapper()
            ->loadHTML($this->html())
            ->setOptions(config('snappy.profiles.invoice'))
            ->output();
    }

    private function wrapper(): PdfWrapper
    {
        // the container binding is a singleton and carries state — reset it
        app()->forgetInstance('snappy.pdf.wrapper');
        return app('snappy.pdf.wrapper');
    }
}

loadHTML(view(...)->render()) beats loadView() by one line and buys you a unit-testable html() plus a free "preview in browser" route during development.

Option profiles live in config, versioned with the rest of the app.

// config/snappy.php
'profiles' => [
    'invoice' => ['page-size' => 'A4', 'margin-top' => 12, 'margin-bottom' => 18,
                  'enable-local-file-access' => true],
    'report'  => ['page-size' => 'A4', 'orientation' => 'Landscape',
                  'footer-font-size' => 8, 'footer-right' => 'Page [page] of [topage]'],
],

Four terminal methods cover delivery:

$pdf->download('x.pdf');   // attachment
$pdf->inline('x.pdf');     // browser tab
$pdf->save($absolutePath); // local disk only — directory must already exist
$pdf->output();            // raw bytes

save() is local-filesystem only. For S3 or any non-local disk, take output() and push the bytes:

Storage::disk('s3')->put("invoices/{$invoice->id}.pdf", $renderer->pdf(), 'private');

Queue anything that isn't a single user click. Month-end runs, bulk statements, report packs. Two rules: pass IDs (or SerializesModels and reload), and reset the wrapper at the top of handle() because workers reuse the container across jobs.

final class GenerateInvoicePdf implements ShouldQueue
{
    use SerializesModels;
    public int $timeout = 180;

    public function __construct(public Invoice $invoice) {}

    public function handle(InvoicePdfRenderer $renderer): void
    {
        app()->forgetInstance('snappy.pdf.wrapper');
        Storage::disk('s3')->put("invoices/{$this->invoice->id}.pdf", $renderer->pdf(), 'private');
    }
}

Set $timeout on the job and 'timeout' => 120 in config. Two independent kill switches: one for a hung binary, one for a hung job.

Headers/footers are files, not strings. --header-html needs a path or URL, so write a temp file and clean up:

$header = tempnam(sys_get_temp_dir(), 'hdr') . '.html';
file_put_contents($header, view('pdf.partials.header', compact('invoice'))->render());

try {
    return PDF::loadView('pdf.invoice', compact('invoice'))
        ->setOption('header-html', $header)
        ->setOption('enable-local-file-access', true)
        ->inline('invoice.pdf');
} finally {
    @unlink($header);
}

For plain page numbering, skip the file entirely: footer-center 'Page [page] of [topage]' plus footer-font-size / footer-right is far less fragile.

Images are the underrated half. OG cards, thumbnails, and social previews from the same Blade layer:

SnappyImage::loadView('og.card', compact('post'))
    ->setOption('width', 1200)->setOption('height', 630)->setOption('format', 'png')
    ->save(storage_path("app/og/{$post->slug}.png"));

Testing has no SnappyPdf::fake(). Two complementary strategies:

// 1. Assert the HTML — covers most of the risk, runs in milliseconds, no binary
$html = (new InvoicePdfRenderer($invoice))->html();
$this->assertStringContainsString('INV-1042', $html);

// 2. Swap the facade root to assert response wiring
SnappyPdf::swap($mock = Mockery::mock(PdfWrapper::class));
$mock->shouldReceive('loadView')->andReturnSelf();
$mock->shouldReceive('setOption')->andReturnSelf();
$mock->shouldReceive('download')->andReturn(response('pdf-bytes'));

Then keep a separate, non-CI smoke test that hits the real binary against a fixture and asserts the output starts with %PDF- and exceeds ~10KB. That test catches "the binary vanished from the image" — exactly what your mocks never will.

Gotchas and Tips

The wrapper is a stateful singleton — this will bite you. snappy.pdf.wrapper is registered as a singleton, loadView() returns $this, and setOption() accumulates. Two renders in one request silently share the first one's settings; in a queue worker the leak crosses jobs. Fix: app()->forgetInstance('snappy.pdf.wrapper') before each render, or always set a complete profile rather than relying on a partial set.

enable-local-file-access is mandatory on 0.12.6+. Without it, local images, local CSS, and header/footer files are silently dropped — no error, just missing logos. Put it in the profile config, not at call sites.

CSS support is 2010-era WebKit. Flexbox and Grid are unreliable; position: fixed is inconsistent across pages. Lay out with <table>. page-break-before: always works; page-break-inside: avoid works on blocks but is flaky on <tr> — chunk long tables or accept ragged breaks. Inline critical CSS in a <style> block; debugging Tailwind inside wkhtmltopdf costs more than writing 60 lines of print CSS.

Prefer data URIs over local paths for images. base64_encode(file_get_contents(...)) kills the entire local-file-access class of bugs and makes the HTML self-contained — which matters when the same HTML lands on a queue worker on another host.

Fonts must exist on the rendering host. A @font-face pointing at /fonts/Inter.woff2 only works if that file is on the same filesystem as the binary. Missing fonts fall back to DejaVu and shift every page's layout — a bug that looks like a margin problem.

Never feed user-supplied HTML or URLs to the renderer. loadHTML($request->input('body')) or loadFile($request->url) is code-execution plus SSRF: wkhtmltopdf will happily read file:///etc/passwd or hit cloud metadata endpoints. Restrict input to your own templates; sanitize any contributed fragment with HTMLPurifier first.

Debugging a failed render. KnpSnappy throws Knp\Snappy\Exception\RuntimeException with the command and stderr baked into the message — log it verbatim, it's almost always self-explanatory ("Exit with code 1 due to network error", "cannot connect to X server"). Additional levers: debug-javascript to surface JS errors, javascript-delay (e.g. 2000ms) to let charts paint, load-error-handling => ignore for flaky remote assets. Best move: copy the command from the exception and run it by hand.

Turn timeout from false into a number. The default disables it, so a malformed template or slow remote asset hangs until PHP's max_execution_time or the queue timeout kills it — with no useful error. 'timeout' => 120 gives you a catchable exception and a 500 with a real message.

Serverless is a hard no. Vapor and friends can't host arbitrary binaries. Decide this before implementation, not after.

Directory creation is on you. save() does not mkdir -p; Storage::put() does.

Locale is applied at render time. Call App::setLocale($user->locale) before loadView()/render(), or the PDF comes out in the request default while the web page is correct.

Document the upstream risk. wkhtmltopdf is effectively unmaintained, and the package tracks it rather than replacing it. Keep the renderer classes thin and output-format-agnostic (html() vs. pdf()) so you can swap in a headless-Chrome driver later without touching controllers — that one design decision is the cheapest insurance you can buy against the dependency's future.

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.
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route