Spatie's Browsershot is a good PHP package with a hidden dependency: every call starts Node.js, which starts Chrome, on the same machine as your PHP code. That is fine on a VPS you control. It is a problem on shared hosting, on serverless PHP, in slim Docker images and anywhere a Chrome update can break a deploy. RenderScreenshot keeps a Browsershot-style fluent API in PHP and moves the browser off your server, so composer require is the whole install.
Disclosure: I build RenderScreenshot. Browsershot is free and open source, and for some teams it is the better choice; the last section says when.
This page is part of our Laravel screenshot API guide. For PDFs from Blade views, see Laravel HTML to PDF; for social cards, see Laravel OG images.
Verdict
| Keep Browsershot if | Switch to RenderScreenshot if |
|---|---|
| You run your own servers and already maintain Node.js and Chrome | You deploy to shared hosting, Vapor, or slim containers without Chrome |
| Captures are rare and a 2-second blocking call is fine | Captures run in web requests or high-volume queues |
You need evaluate() to read values back from the page |
You only need the image or PDF |
| You render pages on a private network your server can reach | Pages are public, or reachable with cookies or headers you can send |
| Cost must be zero and server time is free to you | Your time on Chrome updates and zombie processes costs more than a plan |
What Browsershot needs on your server, measured
We installed Browsershot 5.4.0 with Puppeteer 25.14.0 on Node.js 22.18.0 (Apple M1 Pro, macOS 27.0.1) and captured https://example.com at 1280x800 three times, on 2026-10-15:
| Measure | Result |
|---|---|
| Time per capture (cold Node.js and Chrome each call) | 1,947 ms, 1,641 ms, 1,851 ms |
| Output | 79,353 bytes, 1280x800 PNG |
| Chrome downloaded by Puppeteer (macOS arm64, Chrome 155) | 376 MB on disk |
| Chrome Headless Shell, if you switch to it | 202 MB on disk |
RenderScreenshot PHP SDK plus Guzzle in vendor/ |
about 1.5 MB |
Each Browsershot call pays the start-up of a Node.js process and a Chrome browser, which is most of that 1.6 to 1.9 seconds on a page as simple as example.com. It also blocks a PHP worker for the whole time. On Linux you add the Chrome system libraries (fonts, libnss3, libatk, libgbm and similar), and every Puppeteer upgrade downloads a new Chrome.
The test script:
<?php // browsershot-before.php: a typical Browsershot capture, timed. require __DIR__ . '/vendor/autoload.php'; use Spatie\Browsershot\Browsershot; $t0 = microtime(true); Browsershot::url('https://example.com') ->setNodeModulePath(getenv('NODE_MODULES')) // where puppeteer is installed ->windowSize(1280, 800) ->deviceScaleFactor(1) ->waitUntilNetworkIdle() ->timeout(30) ->save(__DIR__ . '/browsershot.png'); printf("browsershot: %d ms, %d bytes\n", (microtime(true) - $t0) * 1000, filesize(__DIR__ . '/browsershot.png'));
We have not yet timed RenderScreenshot from the same machine on the same URL list; that comparison needs cold and warm runs across a shared set of pages and will be added here when it is run. You can run it now with the vendor test script.
Browsershot to RenderScreenshot: option mapping
Every RenderScreenshot method below is in the PHP SDK reference. Options marked "JSON" are API parameters you send in a POST body, documented in the parameter reference.
| Browsershot | RenderScreenshot (PHP SDK or JSON) | Notes |
|---|---|---|
Browsershot::url($url) |
TakeOptions::url($url) |
|
Browsershot::html($html) |
TakeOptions::html($html) |
Render a Blade view with view(...)->render() first |
->windowSize(1280, 800) |
->width(1280)->height(800) |
|
->deviceScaleFactor(2) |
->scale(2) |
1 to 3 on both |
->fullPage() |
->fullPage() |
Add JSON capture.scroll.simulate for lazy images |
->select('.card') |
->element('.card') |
|
->clip($x, $y, $w, $h) |
JSON capture.clip |
|
->setDelay(500) |
->delay(500) |
Milliseconds on both |
->waitUntilNetworkIdle() |
->waitFor('networkidle') |
|
->waitForSelector('#ready') |
->waitForSelector('#ready') |
|
->waitForFunction($js) |
No direct equivalent | Wait for an element your function would check |
->timeout(60) (seconds) |
->waitForTimeout(60000) (ms) |
API maximum is 60 seconds |
->userAgent($ua) |
->userAgent($ua) |
|
->setExtraHttpHeaders([...]) |
->headers([...]) |
|
->authenticate($user, $pass) |
->authBasic($user, $pass) |
|
->useCookies([...], $domain) |
->cookies([[ 'name' => ..., 'value' => ..., 'domain' => ... ]]) |
|
->emulateMedia('print') |
->mediaType('print') |
|
->emulateMediaFeatures([...dark]) |
->darkMode() |
|
->device('iPhone X') |
->device('iphone_14_pro') |
See device list |
->mobile(), ->touch() |
->mobile(), JSON viewport.touch |
|
->setScreenshotType('jpeg', 80) |
->format('jpeg')->quality(80) |
Also webp and avif |
->transparentBackground() |
JSON output.transparent |
PNG and WebP |
->blockDomains([...]), ->blockUrls([...]) |
->blockUrls(['*ads.example*']) |
Glob patterns |
->click('.accept') |
JSON page.click |
Or ->blockCookieBanners() for consent pop-ups |
->format('A4') (PDF) |
->format('pdf')->pdfPaperSize('a4') |
|
->margins(10, 10, 10, 10) |
->pdfMargin('10mm') |
Per-side margins via JSON pdf.margin |
->landscape() (PDF) |
->pdfLandscape() |
|
->showBackground() (PDF) |
->pdfPrintBackground() |
|
->headerHtml(), ->footerHtml() |
->pdfHeader(), ->pdfFooter() |
|
->evaluate($js) |
No equivalent | The API returns images and PDFs, not script results |
->setNodeBinary(), ->setChromePath(), ->noSandbox() |
Not needed | No local browser |
Two things have no equivalent, and both are reasons to keep Browsershot for that job: reading values back with evaluate(), and rendering pages that only your server can reach (an internal admin panel on a private network, for example).
Migrate: install and first capture
composer require renderscreenshot/renderscreenshot composer remove spatie/browsershot # after the migration is done npm uninstall puppeteer # and remove Chrome from your deploy scripts
<?php // capture.php: the Browsershot capture, without Node.js or Chrome on the server. require __DIR__ . '/vendor/autoload.php'; use RenderScreenshot\Client; use RenderScreenshot\RenderScreenshotException; use RenderScreenshot\TakeOptions; $client = new Client(getenv('RS_API_KEY')); try { $image = $client->take( TakeOptions::url('https://example.com') ->width(1280) ->height(800) ->scale(1) ->waitFor('networkidle') ->format('png') ); file_put_contents(__DIR__ . '/capture.png', $image); printf("saved capture.png (%d bytes)\n", strlen($image)); } catch (RenderScreenshotException $e) { fwrite(STDERR, sprintf("%s (retryable: %s): %s\n", $e->errorCode, $e->retryable ? 'yes' : 'no', $e->getMessage())); exit(1); }
Tested on 2026-10-15 with PHP 8.5.10 and renderscreenshot/renderscreenshot 1.1.0 against a local mock of the API, including the error path (an unreachable host raised a RenderScreenshotException with retryable true).
Migrate: the Laravel version
Bind one client in the container so jobs and controllers share it:
// app/Providers/AppServiceProvider.php (register method) use RenderScreenshot\Client; $this->app->singleton(Client::class, fn () => new Client(config('services.renderscreenshot.key')));
// config/services.php 'renderscreenshot' => [ 'key' => env('RS_API_KEY'), ],
Then move captures out of web requests and into a queued job. A Browsershot call that blocked a PHP-FPM worker for two seconds becomes a short HTTP call in the background:
<?php // app/Jobs/CapturePage.php namespace App\Jobs; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Storage; use RenderScreenshot\Client; use RenderScreenshot\RenderScreenshotException; use RenderScreenshot\TakeOptions; class CapturePage implements ShouldQueue { use Queueable; public int $tries = 3; public function __construct(public string $url, public string $path) {} public function handle(Client $client): void { try { $png = $client->take( TakeOptions::url($this->url)->width(1280)->height(800)->blockCookieBanners() ); } catch (RenderScreenshotException $e) { if ($e->retryable) { $this->release($e->retryAfter ?? 30); // rate limit or transient render error return; } $this->fail($e); // bad URL or bad options: do not retry return; } Storage::disk('public')->put($this->path, $png); } }
CapturePage::dispatch('https://example.com/pricing', 'captures/pricing.png');
The retryable flag decides between release() and fail(), so a typo in a URL does not burn three attempts.
Social cards from Blade, without storing files
A common Browsershot job is rendering a Blade template into an og:image. With a signed URL, the image is rendered on the first crawler request and cached, and nothing is written to your disk:
// In a controller or view composer use RenderScreenshot\Client; use RenderScreenshot\TakeOptions; $ogImage = app(Client::class)->generateUrl( TakeOptions::url(route('posts.card', $post))->preset('og_card'), // 1200x630 now()->addDays(7) );
<meta property="og:image" content="{{ $ogImage }}"> <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630">
Signed URLs expire (30 days at most), so generate them when you render the page, not once at publish time. Our Laravel OG image guide covers the card template itself.
The Laravel snippets are fragments for an existing Laravel 11 or later app and were not run in a full Laravel project for this page; the SDK calls inside them are the same calls tested above.
Pricing at 1,000, 10,000 and 100,000 captures a month
Browsershot has no licence fee. Its cost is the server time to run Chrome and the engineering time to keep it working. RenderScreenshot plans, checked 2026-10-15 (see pricing):
| Monthly captures | RenderScreenshot plan | Price | Effective cost per capture |
|---|---|---|---|
| Up to 150 | Free | $0, no credit card | $0 |
| 1,000 | Starter (2,500 credits) | $10 | $0.010 |
| 10,000 | Growth (12,000 credits) | $40 | $0.004 |
| 100,000 | Above Scale (60,000 credits, $100) | Custom plan | Ask us |
One standard capture uses one credit, and repeated requests for the same capture can come from cache. Compare that with what a Chrome-capable server and its upkeep cost you; for a team already running a VPS with spare memory, Browsershot can be the cheaper choice.
Response and auth differences you need to know
- Network location. Browsershot loads pages from your server. The API loads them from ours, so
localhost, private IPs and VPN-only hosts do not work. UseTakeOptions::html()to send the rendered HTML instead, withhtml_base_urlin JSON when it references relative assets. - Blocking vs async.
take()returns the image bytes likescreenshot()did. For many URLs, use$client->batch([...], $options)instead of a loop; see the batch endpoint. - Caching. Responses include
X-Cache-URL, a public CDN link you can store instead of the file. Default cache TTL is 24 hours. - Keys. Keep the
rs_live_key in.env. Never put it in Blade; use signed URLs for anything a browser loads.
When to stay with Browsershot
- You need
evaluate()results, console messages or failed-request lists from the page. - You capture internal pages that only your server can reach.
- You already run Chrome reliably on servers you control, and volumes are low enough that a 2-second synchronous call is fine.
- You need zero external services for compliance reasons.
Try it on one job first
Create a free key, install the SDK next to Browsershot, and move one job, such as your OG images, to the API. Keep Browsershot for the rest until the outputs match what you expect.