wnx/sidecar-browsershot
Run Browsershot on AWS Lambda via Sidecar for Laravel. Generate PDFs and screenshots without installing Node, Puppeteer, or Google Chrome on your server—headless Chrome runs on Lambda instead. Install via Composer, register the function in sidecar.php, and deploy with sidecar:deploy.
Prerequisites (do these first, in this order)
composer require hammerstone/sidecar and run php artisan sidecar:configure — this walks you through creating the AWS IAM user/role and writes the SIDECAR_* keys into .env. Without working Sidecar credentials nothing else in this package functions.composer require spatie/browsershot — but skip its Node/Puppeteer/Chrome installation steps entirely. That's the whole point of this package.composer require wnx/sidecar-browsershotphp artisan vendor:publish --tag="sidecar-browsershot-config" — you only need this if you want to change warming, fonts, or the storage/layers settings. Many teams skip it initially.config/sidecar.php:'functions' => [
\Wnx\SidecarBrowsershot\Functions\BrowsershotFunction::class,
],
php artisan sidecar:deploy --activate. The --activate flag matters — deploying without activating leaves the old version serving traffic.Where to look first
Wnx\SidecarBrowsershot\BrowsershotLambda is the only class you'll touch 95% of the time. Read its method list (save, saveToS3, readHtmlFromS3, bodyHtml, base64pdf) before reading anything else.config/sidecar-browsershot.php (after publishing) for warming, fonts, and disk settings.config/sidecar.php for aws_region — the region here is what saveToS3() uses to build its S3 client.First use case: invoice PDFs from a Blade view
The fastest win, and the one that justifies the package, is moving PDF generation off your web/queue servers:
use Wnx\SidecarBrowsershot\BrowsershotLambda;
$html = view('invoices.pdf', ['invoice' => $invoice])->render();
BrowsershotLambda::html($html)
->format('A4')
->showBackground()
->save(storage_path("app/invoices/{$invoice->id}.pdf"));
The chain is identical to spatie/browsershot — format(), margins(), windowSize(), waitUntilNetworkIdle(), setOption() all work because BrowsershotLambda delegates to the real Browsershot remotely. You can prototype locally with Browsershot (if you have Chrome) and swap the class name at the end.
If you already have Sidecar working for another function, the mental model is: your Laravel app serialises a Browsershot call, ships it to Lambda, Lambda runs Puppeteer against bundled Chromium, and the result comes back as base64 or lands directly in S3.
1. Wrap it in a small service class, not in controllers
Because every call is a remote invocation with latency and cost, you want one place that owns the chain, the disk, and the retry behaviour:
namespace App\Support;
use Wnx\SidecarBrowsershot\BrowsershotLambda;
class PdfGenerator
{
public function fromView(string $view, array $data, string $path): string
{
$html = view($view, $data)->render();
BrowsershotLambda::html($html)
->format('A4')
->showBackground()
->setOption('printBackground', true)
->save($full = storage_path("app/{$path}"));
return $full;
}
}
This also gives you a single seam to fake in tests (see gotchas) and a single place to add ->setDelay() or ->waitUntilNetworkIdle() when a template starts rendering before its webfonts/CSS load.
2. Always dispatch to a queue for anything user-facing
A cold Lambda + Chromium boot is measured in seconds, not milliseconds. Never block a request on it. The queue job pattern is the standard one:
class GenerateInvoicePdf implements ShouldQueue
{
public $tries = 3;
public $backoff = [10, 60];
public function __construct(public int $invoiceId) {}
public function handle(PdfGenerator $pdf): void
{
$invoice = Invoice::findOrFail($this->invoiceId);
$pdf->fromView('invoices.pdf', compact('invoice'), "invoices/{$invoice->id}.pdf");
$invoice->update(['pdf_ready_at' => now()]);
}
}
Pair this with the job_batches table or a simple pdf_ready_at column so the UI can poll. This is also where Sidecar's own timeout behaviour bites least — a queue worker can wait, a web request cannot.
3. saveToS3() for anything large or long-lived
Lambda responses have a payload ceiling, and base64-encoded PDFs hit it fast. If the artefact is destined for S3 anyway, cut out the round trip:
BrowsershotLambda::url('https://example.com/report')
->format('A4')
->saveToS3("reports/{$report->id}.pdf");
Then hand the user a temporary URL from your app — don't make the file public:
Storage::disk('s3')->temporaryUrl("reports/{$report->id}.pdf", now()->addMinutes(10));
saveToS3() returns the S3 ETag value, which is handy for cache-busting or dedupe checks.
4. readHtmlFromS3() for oversized HTML
If your rendered HTML is big (long table exports, embedded SVG charts, base64 images inline), the request payload to Lambda will blow up. Pre-stage the HTML in S3 and pass a key:
Storage::disk('s3')->put('html/report-42.html', $html);
BrowsershotLambda::readHtmlFromS3('html/report-42.html')->saveToS3('reports/42.pdf');
// or a named disk
BrowsershotLambda::readHtmlFromS3('html/report-42.html', 's3files')->saveToS3('reports/42.pdf', 's3files');
Note the package reads the S3 object into a string before handing it to Chromium, so the real constraint is HTML size, not request size — still much better than shipping it over the invoke payload.
5. Screenshots and OG images
The same class handles images; the difference is sizing and the optional spatie/image post-processing:
BrowsershotLambda::url($post->url)
->windowSize(1200, 630)
->setOption('deviceScaleFactor', 2)
->saveToS3("og/{$post->slug}.jpg");
// resize after capture
BrowsershotLambda::url('https://example.com')
->windowSize(1920, 1080)
->fit(\Spatie\Image\Enums\Fit::Contain, 200, 200)
->save('thumb.jpg');
fit(), quality(), format() etc. require spatie/image v3+ installed separately — it is not a dependency of this package.
6. Enable warming in production
Warming keeps N Lambda instances booted so the first request of the day doesn't eat a full cold start:
SIDECAR_BROWSERSHOT_WARMING_INSTANCES=5
Alternatively publish the config and set warming there. Size this to your peak concurrency, not your average — and remember warm instances cost money whether or not you invoke them.
7. Register custom fonts by convention, not by code
Drop .ttf/.otf files into resources/sidecar-browsershot/fonts/ and redeploy. They're bundled into the Lambda image and registered with Chromium automatically. You can relocate that folder via the sidecar-browsershot.fonts config key. Noto Color Emoji ships by default, so emoji render without you doing anything.
8. Extract a shared chain for consistency
If you generate invoices, reports, and receipts, centralise the base chain so margins, DPI, and printBackground don't drift:
BrowsershotLambda::html($html)
->format('A4')
->margins(20, 20, 20, 20)
->showBackground()
->setOption('preferCSSPageSize', true);
Redeploy after every spatie/browsershot upgrade — non-negotiable. The Lambda bundle pins the Puppeteer/Chromium layer and the JS shim that talks to Browsershot. Upgrading the PHP package locally without redeploying produces confusing "method not found" or silently different rendering. Add a note to your upgrade checklist.
Architecture is x86_64 only. ARM/Graviton Lambda is not supported. If your Sidecar config or CI forces arm64, deploys will succeed and invocations will fail at runtime.
saveToS3() + fit() round-trips through your app server. The image is generated on Lambda, written to S3, downloaded to your local disk, manipulated with spatie/image, and uploaded back. That partially defeats the "keep the app server light" goal for large images, and it means your app server needs S3 read/write plus enough local temp space. For thumbnails this is fine; for hundreds of megapixels, generate the final size on Lambda via windowSize() instead.
Your sidecar-execution-role needs S3 write permissions. saveToS3() is a silent-looking failure mode: the Lambda completes, the invoke returns, and the object just isn't there because the IAM role lacks s3:PutObject. Check the Lambda logs first when saveToS3() "works" but produces nothing.
Region mismatch. saveToS3() builds its S3 client from sidecar.aws_region, not from your filesystems.php disk config. If those differ (older versions had a bug here, fixed in v1.6.1), you'll get signature or bucket-not-found errors. Align them.
The layer → layers config rename in v2. If you published the config under v1 and upgraded, your customised sidecar-browsershot.layer key is now ignored. Rename it and check the upgrade guide.
Image manipulation changed in v2. spatie/image moved from a transitive dependency to an explicit one. Upgrading without composer require spatie/image gets you fit() failing at runtime, not install time.
Filenames and extensions drive the output format. ->save('example.pdf') produces a PDF, ->save('example.jpg') produces a JPEG. There's no explicit format flag — pass the wrong extension and you get the wrong artefact. Same applies to saveToS3().
HTML must be self-contained. Chromium on Lambda can reach the public internet, but any asset on localhost, an internal Docker hostname, or behind your auth middleware will simply 404 and render blank. Either inline the CSS (@vite doesn't work — use a published manifest or inline the built CSS) or pass absolute public URLs.
url() calls to your own app can deadlock. If a screenshot job targets a route on your own application and your queue workers are saturated, you can starve the request. Point at a public URL, a separate hostname, or pre-render the page and use html().
Debug by reading Lambda logs, not by adding dd(). BrowsershotLambda is a black box locally — no Chrome, no visible browser. When output is blank or truncated, go to CloudWatch for the function's log group, or temporarily reproduce with Browsershot locally (if you install Chrome in a container) to confirm the chain itself is sane.
Blank or partially-rendered PDFs are almost always timing. Add ->setDelay(1000) or ->waitUntilNetworkIdle() before you go hunting for a deeper bug. Charts and webfonts are the usual culprits.
Watch the base64 payload when using save() for large files. The result travels back over the invoke response. Multi-megabyte PDFs will hit Lambda limits; switch to saveToS3() rather than trying to tune.
Testing needs a decision up front. The package's own test suite hits real AWS. For your app, mock at your service-class boundary (PdfGenerator) rather than trying to fake BrowsershotLambda statically — it's a static facade over a remote call and is awkward to stub at the chain level. If you genuinely need to assert on rendered output, keep a small integration test suite with real AWS credentials, gated behind an env flag, and run it on a schedule rather than on every PR.
Cost discipline. Every invocation is a billed Lambda. Cache aggressively — hash the template + data and reuse the existing S3 object if the hash hasn't changed. Warm instances plus per-request Chromium boots get expensive quickly for anything high-volume, and this is the main reason teams that start with this package eventually move to a dedicated rendering service.
Extension point: your own Sidecar function. If you need to do more than Browsershot allows in one hop (e.g. capture multiple URLs in one invocation, or post-process on Lambda with sharper tooling), copy BrowsershotFunction as a starting point for a custom Sidecar function. The package is a thin wrapper over Sidecar's function contract, so it's a good reference implementation.
How can I help you explore Laravel packages today?