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.
Architecture fit. barryvdh/laravel-snappy is a thin adapter, not an engine. It registers a service provider, exposes SnappyPdf/SnappyImage facades, and binds snappy.pdf.wrapper / snappy.image.wrapper as stateful singletons into the container. The actual rendering is delegated to the wkhtmltopdf / wkhtmltoimage binaries via knplabs/knp-snappy, which shells out through proc_open. For a TPM this reframes the decision: we are not adopting a PHP library with a bounded blast radius, we are adopting an OS-level binary dependency with a PHP convenience layer on top. The Laravel integration itself is idiomatic and low-friction; the architecture risk lives entirely one layer down.
The singleton design is the one architectural detail that changes how we write code. loadView() returns $this, and setOption() accumulates onto the shared instance, so option state leaks between renders within a request and — more dangerously — across jobs inside a queue worker. That is not a bug we can wait for; it is a code convention we must mandate (app()->forgetInstance('snappy.pdf.wrapper') before every render, or a per-render factory wrapper). Budget for that convention in the definition of done, and put it in the PR checklist.
Integration feasibility. Composer install is trivial. Binary provisioning is the project. Concretely: install wkhtmltopdf into the container image (pinned to a distro build — Debian's patched-Qt variant — because 0.12.5 and 0.12.6 produce subtly different output), verify the binary is reachable as the PHP-FPM user, not as our shell user, and confirm proc_open is not in disable_functions. Two findings gate the whole initiative and should be checked in the first 30 minutes, before any estimation:
proc_open permitted in the target runtime?Both are pre-commitment questions. Discovering either after sprint planning wastes a sprint.
Technical risk. Ranked, with the TPM-relevant framing:
| Risk | Severity | Likelihood | Mitigation cost |
|---|---|---|---|
wkhtmltopdf upstream is effectively unmaintained |
High | Certain | Structural — see below |
CSS fidelity: 2010-era WebKit, no reliable Flexbox/Grid, flaky page-break-inside on <tr> |
High | High | Design constraint: table layouts, inline print CSS |
| Stateful singleton leaking options across renders/jobs | Medium | High | Convention + wrapper class |
| SSRF / local file read if user HTML or URLs reach the renderer | High | Medium | Sanitization policy, input allowlist |
Silent asset loss without enable-local-file-access on 0.12.6+ |
Medium | High | Set in option profile, not per call site |
Hung binary when timeout is left false (the shipped default) |
Medium | Medium | Set numeric timeout in config and on the job |
| Fonts absent on render host → DejaVu fallback, silent layout shift | Medium | Medium | Bake fonts into image |
| Container drift: binary removed/upgraded, works locally, fails in prod | Medium | Medium | Non-CI smoke test asserting %PDF- prefix and size |
The dominant risk is the unmaintained upstream engine. The Laravel package is actively maintained — v1.0.3 through v1.0.5 show Laravel 11, 12, and 13 compatibility landed promptly, plus a real security fix (GHSA-92rv-4j2h-8mjj in the snappy dependency) and a PHP 8.2 deprecation fix. Credit where due: the wrapper is in better health than the thing it wraps. But a maintained adapter over a decaying engine is a shrinking asset. The 48.3 composite score and 53.1 opportunity score read to me as the scoring model correctly discounting exactly this: healthy package, unhealthy foundation. I would not treat that score as a reason to reject — I would treat it as a prompt to write down the exit plan before we start.
Key questions to resolve before commitment:
wkhtmltopdf will not clear that bar.spatie/laravel-pdf)? Chrome is heavier at runtime but has a maintained engine, modern CSS, and no proc_open footgun. The honest comparison is not "snappy vs nothing" — it is "snappy vs a Chrome-backed renderer." Chrome is the safer default for anything new or brand-visible; Snappy wins on memory footprint, cold-start time, and simplicity for high-volume, low-fidelity internal documents.Verdict. Green-light a timeboxed two-day spike, not a commitment. The spike renders one real invoice with real content, real logo, and real item count, in the target container, under the target user, exercising the full download path. Decision gate at the end of day two: acceptable fidelity + working binary + confirmed non-serverless runtime. If any of the three fails, escalate to the Chrome-backed alternative rather than spending a sprint fighting print CSS.
Stack fit. Good fit for a conventional Laravel monolith on owned VMs or containers with a mutable image and a normal PHP-FPM/queue topology. The integration surface is small and idiomatic — one config file, two facades, two container keys — so there is no meaningful coupling into our domain layer as long as we hold the boundary described below.
The boundary matters more than the library choice: no controller renders a document. Each document type becomes a small renderer class with a testable html() method and a pdf() method. This gives us three things a TPM should care about: the HTML is unit-testable without invoking a binary (milliseconds, no infrastructure), the same template can back a browser preview route during development, and the renderer becomes the single seam where we could later swap the engine without touching call sites. That seam is the real deliverable — it converts a contentious vendor decision into a reversible one.
Migration path. Where we are replacing an existing PDF path (Dompdf, TCPDF, or hand-rolled), migrate one document type at a time, never a big-bang cutover. The reason is fidelity: print CSS is empirical, and each document type surfaces its own layout failures. Sequence by value and risk:
SnappyImage from the same Blade partials; genuinely underrated and low-risk, since social-card fidelity tolerance is high.Keep the previous renderer behind a feature flag per document type for one release cycle. Costs almost nothing and removes the rollback drama.
Compatibility. Strong on the Laravel axis: v1.0.3 → 11.x, v1.0.4 → 12.x, v1.0.5 → 13.x, all landed promptly, and the package is release-based rather than branch-tracking. PHP 8.2 compatibility is patched. The compatibility risks sit elsewhere:
config/snappy.php, grouped per document type and versioned with the rest of the config. Scattering setOption() calls across controllers is how you get two invoices that look different for reasons nobody can reconstruct six months later.save() is local-filesystem only and does not create directories. For S3 or any non-local disk, take output() and Storage::put() ourselves. Write this down once; it is the kind of detail that gets rediscovered per-feature otherwise.App::setLocale() must be set before the view renders, or a queued multilingual invoice comes out in the worker's default language while the web page is correct — a bug that reports as "the PDF is in English" and looks like a caching problem.--header-html takes a path or URL, not a string, so any real header needs a temp file with finally { unlink() }. For simple page numbering, the built-in footer-center 'Page [page] of [topage]' is far less fragile — steer the team to it first.Sequencing. Rough shape, assuming the spike passes:
config/snappy.php with numeric timeouts and option profiles. Renderer class pattern + the forgetInstance convention written into the PR checklist.Knp\Snappy\Exception\RuntimeException verbatim — the message contains the exact command and stderr and is almost always self-explanatory.$timeout and config-level timeout set independently — two separate kill switches for two separate hangs. Per-job wrapper reset.Maintenance. This is the section that should drive the decision, and the honest answer is that maintenance cost is low but not zero, and it is concentrated in infrastructure, not code. The Composer side is a non-event — pinned version, occasional bump, no transitive dependency sprawl. The recurring work is:
SnappyPdf::fake() exists. We get 90% of coverage from asserting the rendered HTML in fast unit tests, plus a facade swap (SnappyPdf::swap($mock)) when we need to assert response wiring. But the failure mode that mocks cannot catch is "the binary vanished from the image." That requires a separate, non-CI-or-nightly smoke test that invokes the real binary against a fixture and asserts the output starts with %PDF- and exceeds ~10KB. That single test is the highest-ROI operational investment in the whole integration; scope it in week one, not later.Support. Support burden is moderate and predictable, and it clusters into a small number of recurring tickets that we should pre-empt with documentation rather than absorb one at a time:
PATH or a missing binary. php artisan tinker --execute="echo shell_exec('wkhtmltopdf --version 2>&1');" is the first diagnostic step and belongs in the runbook.enable-local-file-access on 0.12.6+, or a path that isn't on the renderer's filesystem. Data URIs (base64_encode(file_get_contents(...))) sidestep the entire class and should be the default recommendation.position: fixed inconsistent across pages, page-break-inside: avoid flaky on <tr>. The team should default to <table> layout and inline critical CSS in a <style> block rather than pulling a utility framework.One support item is not routine and needs a policy decision, not a ticket: never pass user-supplied HTML or URLs to the renderer. loadHTML($request->input(...)) or loadFile($request->url) is a code-execution and SSRF vector — the binary will read file:///etc/passwd or reach cloud metadata endpoints without complaint. Restrict input to our own templates; sanitize any contributed fragment through HTMLPurifier first. Put this in the security review checklist, because it is the kind of thing a well-meaning feature developer adds in month three.
Scaling. Scaling characteristics are favorable for our likely volume and unfavorable only under load we probably don't have. Each render spawns a child process consuming ~200–500ms of CPU; process spawn overhead dominates. That means:
$timeout on the job and timeout in config set independently. Pass scalars/IDs (or SerializesModels + reload), and reset the wrapper instance at the top of handle() — queue workers reuse the container across jobs, so leaked options from job N silently affect job N+1.Failure modes. Enumerated with the operational response each implies:
| Failure | Signature | Response |
|---|---|---|
| Binary missing from image | Immediate RuntimeException from proc_open; smoke test catches it |
Image rollout gate; smoke test in deploy pipeline |
| Hung render | Worker stalls until PHP max_execution_time or queue timeout, no useful error |
Set numeric timeouts. Shipped default is false — change it in week one. Numeric timeout produces a catchable exception we can surface as a real 500 |
| Silent asset drop | Invoice renders, logos and CSS missing, no error | enable-local-file-access in the option profile |
| Option leakage between renders/jobs | Two documents share settings; reproduces intermittently in workers | forgetInstance('snappy.pdf.wrapper') convention, enforced |
| Wrong-language PDF | Queued invoice in worker default locale | App::setLocale() before render; cover with a test |
| Font fallback | Everything shifts; reads as a margin bug | Fonts in image; visual regression on font changes |
| SSRF / local file read | None until exploited | Input allowlist + HTMLPurifier; security review item |
| Broken/ragged page breaks | Long tables split badly | Chunk long tables; accept imperfect breaks for internal documents |
The recovery story is good: failures are loud (RuntimeException carries the exact command and stderr, frequently self-explanatory) and reproducible outside PHP by running that command by hand. That is a materially better debugging experience than a misbehaving PDF library, and it should count in the package's favor.
Ramp-up. Low-to-moderate, with a clear split between what's fast and what isn't.
loadView, loadHTML, loadFile, setOption(s), setPaper, setTimeout, inline, download, save, output, stream — and PdfWrapper.php is ~150 lines and readable in ten minutes. A mid-level Laravel developer is productive on the golden path in a day.Bottom line for the portfolio view. Adopt narrowly and deliberately: internal, high-volume, moderate-fidelity documents — invoices, reports, OG images — where the renderer seam and the exit ADR are in place from day one. Do not adopt for customer-facing brand-critical output, accessibility-regulated PDFs, modern-CSS-heavy layouts, or any roadmap pointing at serverless; use a Chrome-backed renderer for those. The 48/100 score is not an argument against the Laravel package, which is well-maintained and promptly tracks new Laravel releases — it is an accurate read on the aging engine underneath, and the right response is to capture that risk explicitly in an ADR with a named alternative and a revisit trigger, rather than to pretend the score reflects the wrapper.
How can I help you explore Laravel packages today?