Browsershot Alternative: Laravel Screenshots Without Node

A Spatie Browsershot alternative for PHP and Laravel: drop Node.js and Chrome from your servers, map every Browsershot option, and migrate with tested code.

By Simon Chiu · Updated

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. Use TakeOptions::html() to send the rendered HTML instead, with html_base_url in JSON when it references relative assets.
  • Blocking vs async. take() returns the image bytes like screenshot() 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.

Frequently asked questions

Can I take screenshots in Laravel without Node.js?
Yes. Call a screenshot API from PHP instead of running Puppeteer locally. The RenderScreenshot PHP SDK needs only PHP 8.0 and Guzzle; the browser runs on our side.
Does Browsershot work on shared hosting or Laravel Vapor?
Browsershot needs Node.js, the puppeteer package and a Chrome binary on the same machine as PHP, plus permission to start processes. Many shared hosts and serverless PHP platforms do not allow that, which is the most common reason teams look for an alternative.
Is Browsershot free?
Browsershot is open source and free to use. The cost is the server that runs Node.js and Chrome, and the time to keep them updated and working.
Can I generate PDFs from Blade views without Browsershot?
Yes. Render the Blade view to an HTML string and send it as the html input with output format pdf. PDF options such as paper size, margins, header and footer map to the pdf parameters.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.