knplabs/knp-snappy
Snappy is a lightweight PHP wrapper around wkhtmltopdf and wkhtmltoimage for generating PDFs, thumbnails, and snapshots from URLs or HTML pages. Install via Composer, with ready-made Symfony and Laravel integrations.
KnpSnappy is the low-level driver; in Laravel you almost always use it through barryvdh/laravel-snappy, which binds a configured Pdf/Image instance into the container and exposes the PDF / SnappyImage facades.
1. Install the binary first (Snappy is only a process wrapper — no binary, no PDF):
# Debian/Ubuntu — use the patched-QT build, not the distro package
wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb
dpkg -i wkhtmltox_0.12.6.1-3.jammy_amd64.deb
wkhtmltopdf --version # must report 0.12.6 (with patched qt)
2. Require the package and publish config:
composer require barryvdh/laravel-snappy
php artisan vendor:publish --provider="Barryvdh\Snappy\ServiceProvider"
3. Point config/snappy.php at the binary (absolute path, no arguments — see Gotchas):
'pdf' => [
'enabled' => true,
'binary' => env('WKHTMLTOPDF_BINARY', '/usr/local/bin/wkhtmltopdf'),
'timeout' => 120,
'options' => [
'enable-local-file-access' => false,
'encoding' => 'utf-8',
'margin-top' => 15, 'margin-bottom' => 15,
'margin-left' => 10, 'margin-right' => 10,
],
'env' => [],
],
4. First use case — invoice PDF from a Blade view:
use Barryvdh\Snappy\Facades\SnappyPdf as PDF;
Route::get('/invoices/{invoice}/pdf', function (Invoice $invoice) {
return PDF::loadView('pdf.invoice', ['invoice' => $invoice])
->setOption('footer-center', 'Page [page] of [topage]')
->download("invoice-{$invoice->number}.pdf");
});
Where to look first: config/snappy.php, then wkhtmltopdf -H for the full option list — every setOption() key maps 1:1 to a CLI flag.
Wrap generation in a dedicated service, not controllers. One class per document type keeps options next to the template that depends on them:
final class InvoicePdfBuilder
{
public function __construct(private Invoice $invoice) {}
public function render(): string
{
return SnappyPdf::loadView('pdf.invoice', ['invoice' => $this->invoice])
->setOption('footer-html', view('pdf.partials.footer')->render())
->setPaper('a4')
->setOrientation('portrait')
->setOption('print-media-type', true)
->output();
}
}
Generate into a queue job, store on a disk. PDF generation takes seconds — never block a request for bulk work. Note Storage::path() (absolute local path) is required; Snappy writes with the binary, so it cannot stream to S3 directly:
public function handle(): void
{
$path = "invoices/{$this->invoice->id}.pdf";
Storage::disk('local')->put($path, app(InvoicePdfBuilder::class, ['invoice' => $this->invoice])->render());
$this->invoice->update(['pdf_path' => $path]);
}
Always build absolute asset URLs. The binary runs outside the request lifecycle, so asset() must resolve against APP_URL; relative /css/app.css silently 404s and you get an unstyled PDF with no error. Use url()/asset() with a configured host, or inline CSS in the view.
Headers/footers need real HTML files. --footer-html won't take a URL to your session-authenticated route; render the partial with view()->render(), Storage::put() it to a temp file, and pass the absolute path. Remember headers/footers don't inherit the body's CSS or fonts.
Merging and covers are first-class — pass an array of URLs or reuse cover/toc:
SnappyPdf::getOutput(['https://a.test', 'https://b.test'])
->setOption('cover', view('pdf.cover')->render())
->setOption('toc', true)
->setOption('xsl-style-sheet', public_path('toc.xsl'));
Per-document options vs. the shared singleton. The facade resolves one instance; PDF::setOption() mutates it for the rest of the request. Either call PDF::resetOptions() when finished, or resolve a fresh instance: app('snappy.pdf.wrapper')->... inside a scoped binding.
Testing. Mock the facade so CI needs no binary, and assert the view data, not the bytes:
PDF::shouldReceive('loadView')->once()->with('pdf.invoice', $data)->andReturnSelf();
PDF::shouldReceive('download')->andReturn(response('', 200));
For a smoke test that a real PDF is produced, keep one test behind a WKHTMLTOPDF_BINARY env check — otherwise pipelines on Alpine/minimal images fail confusingly.
Image generation uses the same API (SnappyImage) with setOption('width', 800) / height; good for og:image thumbnails of a Blade-rendered card.
is_executable() is now checked on the whole string, so new Pdf('/usr/bin/wkhtmltopdf --enable-local-file-access') throws. Pass flags via setOption()/the options array instead. If you upgraded and PDFs broke, this is almost always why.--enable-local-file-access is a security boundary, not a convenience flag. With user-supplied HTML it can read local files and, combined with JS, lead to RCE. Sanitise input, disable JavaScript (--disable-javascript) for untrusted content, and consider --disable-local-file-access with everything inlined. Advisory GHSA-c5fp-p67m-gq56 exists; keep the package current.--javascript-delay. Charts rendered client-side will be blank by default; add setOption('javascript-delay', 1000) (or window-status if the page exposes a flag) and --no-stop-slow-scripts when needed.QT_QPA_PLATFORM=offscreen (or run xvfb-run), and mount /dev/shm larger — the default 64 MB crashes on big documents.--temp-dir and --cache-dir to a writable, monitored directory in config/snappy.php; containers with a read-only /tmp produce "Exit with code 1 due to network error" style red herrings.setOption('load-error-handling', 'ignore') and setOption('load-media-error-handling', 'ignore') — the default abort gives you a stack trace instead of the one broken image that caused it.fonts-dejavu / fonts-liberation (and any brand font) in the image; wkhtmltopdf does not fall back gracefully, and it won't error either.setOption() value types matter. Booleans become --flag / omitted, arrays become repeated flags (allow, cookie, post), and image-dpi/image-quality are cast to int. Passing false explicitly is the only way to override a config default — null is ignored.setLogger(app('log')) (Snappy implements LoggerAwareInterface) and log getCommand() output; the CLI string it builds is the fastest way to reproduce outside PHP — copy it into a shell and add --debug-javascript.timeout in config silently kills long documents; set it to false for the job and rely on queue timeouts, which give better visibility.Knp\Snappy\Pdf and swap the container binding if you need pre-processing (e.g. inlining images as base64 or rewriting <img> sources) before the command is built — cleaner than sprinkling str_replace over rendered HTML in every controller.How can I help you explore Laravel packages today?