padocia/laravel-nova-pdf
Laravel Nova PDF adds PDF handling to Nova resources, letting you generate, preview, and download PDF documents from your admin panel using familiar Laravel and Blade workflows.
Verify the runtime before you write a line of code. This package is a thin Nova Action wrapper around Browsershot (headless Chromium via Node/Puppeteer). composer require gives you plumbing, not a renderer. On the same image you deploy, confirm:
node -v && npm -v
which chromium || which google-chrome
# or, if Puppeteer manages the binary:
npx puppeteer browsers list
If Chromium is missing or Node is broken, stop. No config value fixes an absent binary. This check is the difference between a 20-minute spike and a two-day debugging session.
Install and publish both config and views.
composer require padocia/laravel-nova-pdf
php artisan vendor:publish --provider="Padocia\LaravelNovaPdf\LaravelNovaPdfServiceProvider"
Publish both the config and the Blade stubs. Commit the published templates to your app. They are the artifact your team will iterate on daily; the package around them is small glue.
Read the action source first, in this order:
src/Actions/* — how the action pulls the model and what it passes to the view. That is your template contract.config/laravel-nova-pdf.php — options forwarded to Browsershot (nodeBinary, chromePath, format, margins, waitUntil, timeout).handleBrowsershotOptions — the one method patched in v1.0.2, and therefore the least-tested surface in the package. Treat it as suspect until you've verified each option you rely on.First use case: one record, one PDF, from a Nova detail view. Add the action to a resource's actions() method:
public function actions(NovaRequest $request): array
{
return [
\Padocia\LaravelNovaPdf\Actions\PdfAction::make()
->onlyOnDetail(),
];
}
Open a record, run the action, confirm you get bytes. That validates the whole pipeline (model → Blade → Browsershot → HTTP response) in one shot. If it works, you now have a known-good baseline to break as you customize templates.
One Blade template per document type, not per resource. Nova resources map to domain objects; documents map to audiences. An Invoice resource may need customer-invoice.blade.php and warehouse-packing-slip.blade.php. Keep them at resources/views/pdf/<domain>/<variant>.blade.php, select the view through the action, and never branch inside one giant template. A per-document template is a clean review surface when ops asks for a tweak; a monolithic template is a weekly maintenance tax.
Pass a flattened view-model array, not the Eloquent model. The action's job is to project the model into primitives: formatted currency, pre-computed totals, absolute image URLs, string dates. Templates that reach into $model->relation->method() fire lazy loads during rendering, turn Chromium's render into an N+1 machine, and make the template untestable. If you need an audit snapshot, capture it in the action before rendering.
Wrap the render behind your own interface immediately.
interface PdfRenderer {
public function render(string $view, array $data): string; // raw bytes
}
Have the Nova Action call $this->renderer->render(...). Every future need — queueing, swapping to dompdf, adding footers, injecting fixtures into a test — becomes one file. The package is a 2021 wrapper with a one-maintainer footprint; do not let your controllers or actions couple to its class names.
Make inline vs. attachment an explicit choice. Stream inline (Content-Disposition: inline) for preview, attachment for download. Expose it as two buttons or a select field in the action form so operators know what they're pressing. Hiding it in a config default invites support tickets.
Queue the render as soon as it exceeds ~1–2 seconds. A synchronous Chromium launch inside a Nova request will eventually meet a cold worker, a slow CDN font, or max_execution_time. The durable pattern:
GeneratePdfJob with the record ID and template variant.pdfs table.Storage::temporaryUrl(...) or a signed, short-lived route.If you must stay synchronous, set an explicit timeout and produce a timestamped filename — never a raw sequential ID.
Subclass the action for per-resource customization. Override template resolution, filename construction, and authorization in class InvoicePdf extends PdfAction. Never edit files under vendor/ — they vanish on the next composer update and confuse whoever reads the code later.
Authorize inside handle() even when Nova's policy is configured. Resource-level checks are not a reliable guarantee that a view policy runs when an action fires on a specific record:
$this->authorize('view', $this->resource);
An export is an egress path and must be treated like one, especially once the action is exposed on the index table — "select all, export" is one click away.
Read it as a reference implementation before adopting it as a dependency. The whole pattern is ~150 lines of your own code: an Action, a Blade view, Browsershot::html(), response()->streamDownload(). Pinning this package is a legitimate spike; pinning it as long-term infrastructure is a different decision that the maturity profile below should inform.
Browsershot is the entire risk surface. In Docker you will probably need --no-sandbox to start Chromium under an unprivileged user. That is a real security downgrade: an unsandboxed Chromium rendering admin-influenced HTML can reach file:// paths, localhost services, or metadata endpoints. Mitigate deliberately:
SSRF is a design concern here, not a footnote.
handleBrowsershotOptions was broken until v1.0.2. A patch release whose entire changelog is "fix option plumbing" tells you the config path shipped once with a real bug. Verify every option you rely on — format, margins, waitUntil, timeout, nodeBinary, chromePath — by rendering a page with known measurements and inspecting the output. Trust the pixels, not the config file.
Missing logos/CSS means the render finished before assets loaded. Use waitUntilNetworkIdle() (or the equivalent in your pinned Browsershot). The waitUntil semantics have shifted across Browsershot versions — check the version you actually have, not the docs for latest.
Missing or wrong fonts: the container has none. Slim base images ship almost no system fonts, so Chromium silently substitutes and your layout drifts. Install the exact font pack your templates reference and set font-family explicitly in CSS.
Pin everything. Pin this package, spatie/browsershot, and the Chromium image tag. A 2021-era constraint resolved against a modern Browsershot will either fail install or blow up at runtime — Browsershot::html(), path setters, and option names have all moved. If Composer drags in a newer Browsershot, expect to patch.
Nova 4/5 compatibility is unverified. The code is Nova 3 / Laravel 8 vintage (last release July 2021). Action signatures, tool registration, and field APIs have changed across majors. Before planning template work, branch a throwaway install against your Nova version and fire one action. A fatal is cheap to find now and expensive after a sprint. If it fatals, the honest fallback is your own 150-line action — you'll own it anyway.
Storage is a privacy question. If the action persists files, confirm the disk is private and the path is unguessable. storage/app/public with a sequential filename is a URL anyone with the link can enumerate. Use a private disk plus Storage::temporaryUrl() with an expiry matched to your retention policy.
Zero dependents and 23 stars means you are the QA. Run composer audit and a Chrome/Puppeteer CVE scan as part of your install gate, and write a one-paragraph ADR recording the fork-and-replace plan while the code is small. This package saves hours, not weeks — and only if you accept ongoing ownership.
Debugging order when a PDF fails or comes out blank:
nodeBinary and chromePath in config against the container's real paths.Extension points worth knowing: template resolution (override the view name), filename construction, response mode (inline vs attachment), Browsershot options via config or an overridden handleBrowsershotOptions, and the action's fields() for adding a template selector. All reachable from a subclass; none should require touching vendor/.
How can I help you explore Laravel packages today?