spatie/browsershot
Browsershot renders web pages to images or PDFs using Puppeteer and headless Chrome. Accept HTML, local files, or URLs, run JavaScript, capture body HTML and triggered requests, and customize headless behavior from PHP.
Browsershot is a thin, fluent PHP wrapper that shells out to a Node script driving Puppeteer/Chrome. Day-to-day that means three things must exist on the machine that runs your PHP: Node, Puppeteer (v23+ for Browsershot v5), and a Chrome/chrome-headless-shell binary. The package itself is composer require spatie/browsershot; the runtime is the part people forget.
Minimal setup in a Laravel app:
composer require spatie/browsershot
npm install puppeteer # or: npm -g i puppeteer
Then publish/bind the binary paths once rather than per call — the package ships a browsershot config with keys for node_binary, npm_binary, chrome_path, node_modules_path, and include_path. On Forge/Docker these are rarely on the default PATH, so set them explicitly:
Browsershot::url('https://example.com')
->setNodeBinary('/usr/local/bin/node')
->setChromePath('/usr/bin/chromium')
->setNodeModulePath(base_path('node_modules'));
Where to look first: the requirements page on the docs site (it is the #1 source of support tickets), then the Browsershot class itself — every feature is a public method, so -> completion is the real API reference.
First real use case: an invoice or report PDF from a Blade view. It is the classic Laravel integration and it exercises every part of the stack (Blade → HTML → Chrome → file → storage):
$html = view('invoices.pdf', ['invoice' => $invoice])->render();
Browsershot::html($html)
->format('A4')
->showBackground()
->emulateMedia('print')
->save(storage_path("app/invoices/{$invoice->id}.pdf"));
The extension of the path decides the output type: .pdf produces a PDF, anything else (.png, .jpg, .webp) produces an image.
Blade → HTML, not URLs, for authenticated pages. Rendering to a string and using html() avoids auth, session, and CSRF problems entirely. The trade-off is relative asset paths break — add a <base href="{{ config('app.url') }}"> in the layout or inline your CSS. Use url() when you specifically want Chrome to resolve real HTTP assets, cookies, or a JS-heavy page.
Internal route + url() when you need real assets and JS. A signed, short-lived route is the cleanest way to render a page that depends on asset(), Vite builds, or JS charts:
Browsershot::url(URL::temporarySignedRoute('invoices.render', now()->addMinutes(5), $invoice))
->waitUntilNetworkIdle()
->setDelay(500) // let fonts/charts settle
->windowSize(1240, 1754)
->save($path);
Always queue it. Chrome is 100–400 MB per process and takes 1–5 seconds. Do it in a job, store to a disk, and hand back a URL:
class GenerateInvoicePdf implements ShouldQueue
{
public function handle(): void
{
$tmp = tempnam(sys_get_temp_dir(), 'pdf');
Browsershot::html(view('invoices.pdf', ['invoice' => $this->invoice])->render())
->format('A4')->showBackground()->save($tmp);
Storage::disk('s3')->put($key, file_get_contents($tmp));
unlink($tmp);
}
}
Pair this with WithoutOverlapping / a rate limiter so a batch of 500 invoices does not fork 500 Chromes.
Cache aggressively. PDFs for invoices, receipts, and statements are deterministic — generate once, store, serve. For OG images and social cards, key the cache on the model's updated_at so regeneration is automatic.
Screenshots as a product feature. fullPage(), windowSize(), device(), setScreenshotType('webp', 90), and clip() cover most thumbnail/preview needs. Save through Storage and store the path on the model.
Authenticated or gated pages. Pass cookies (->setCookies([...])) or use Basic auth in the URL; combine with ->userAgent() where the app sniffs. For SPAs, waitForFunction() / waitForSelector() are more reliable than a fixed delay — note that as of 5.0.9 the selector is applied after the wait, which fixed a long-standing footgun.
Injecting JS before page scripts (new in 5.4): evaluateOnNewDocument() is the right tool for stubbing window.analytics, seeding a token, or polyfilling. It runs earlier than evaluate() and survives navigation.
Remote browsers. On Lambda or a dedicated render box, ->setRemoteInstance($host, $port) plus ->throwOnRemoteConnectionError() (5.1) turns silent blank pages into actual exceptions. Pair with ->noSandbox() only when you control the container.
Extraction, not just rendering. bodyHtml() returns post-JS DOM; triggeredRequests() lists every request the page made. Both are useful for scraping-adjacent tasks and for asserting that a page actually loaded its assets.
Version coupling is the biggest trap. Browsershot v5 requires Puppeteer 23+; mismatched versions fail with cryptic Node errors, not PHP exceptions. Pin Puppeteer in package.json and rebuild it in your deploy pipeline — otherwise a npm install on the server silently upgrades you. chrome-headless-shell is supported (5.0.8); newHeadless() opts into Chrome's modern headless mode, which behaves differently for fonts and printing.
"It works in tinker but not in the queue." php-fpm, CLI, and the queue worker often have different PATH and different HOME. Resolve binaries to absolute paths in config and verify with the same user that runs the worker. Binary paths containing spaces were only fixed in 5.1.0 — upgrade if your Node lives under /Program Files/.
Blank or partial output is almost always a timing or asset problem, in this order: fonts not installed on the server (tofu boxes — install fonts-liberation, emoji fonts), waitUntilNetworkIdle() timing out on long-polling analytics, or showBackground() omitted so everything is white. In containers, add --no-sandbox and bump /dev/shm (--disable-dev-shm-usage) or Chrome dies under load.
Print CSS ≠ screen CSS. For PDFs use ->emulateMedia('print') and author real @page rules; background colours need print-color-adjust: exact. Page breaks are controlled with CSS, not PHP.
Treat user-supplied URLs as SSRF. The package has hardened over time (5.2.1 closed local-file reads via UNC and protocol-relative URLs; 5.2.3 relaxed a false positive that broke Vite dev servers on localhost), but you should still restrict schemes, resolve and reject private IPs, use disableRedirects() when the target is user-controlled, and block known-bad hosts with block(). Never render arbitrary URLs from unauthenticated input without a queue-level rate limit.
Save paths are filesystem paths. save() does not understand Storage disks — write to an absolute local path or stream, then move the bytes. htmlFromFilePath() likewise wants a real path.
Debugging: wrap calls and catch CouldNotTakeBrowsershot — the message embeds Node's stderr, which is usually the actual answer. Reproduce outside PHP by running the same Node command manually; if that fails, it was never a Laravel problem. Also check storage/logs for timeout exceptions, and remember timeout() governs Node, while your queue/job timeout must be higher.
Testing: assert on content rather than bytes — spatie/pdf-to-text (needs poppler-utils) lets you extract text and assert the invoice number appears. Failing that, wrap Browsershot behind your own PdfRenderer interface and fake it in feature tests; hit the real one in a single integration test.
How can I help you explore Laravel packages today?