This page shows how to take website screenshots in a Laravel app with the RenderScreenshot PHP SDK. It covers the parts that are specific to Laravel: one client in the service container, a queued job that respects the per-account rate limit across all workers, files on the Storage facade, a Blade component that writes a signed og:image tag, and feature tests that fake both the API and the queue. For other stacks, see the screenshot API code examples hub.
Last tested: 2026-10-18 with PHP 8.5.10, Laravel 13.37.0,
renderscreenshot/renderscreenshot1.1.1, Guzzle 7.16.0 and PHPUnit 12.5.40. The code ran in a freshlaravel/laravelproject.php artisan testpassed four tests. A dispatched job ran on thesyncqueue against a local stub of our API that returns the documented bodies, including a 429 withRetry-After. We did not call the production API.
Why not Browsershot?
Browsershot is a good library, but it is a PHP wrapper around Puppeteer. Every host that runs it needs Node.js, an npm install puppeteer, a Chrome build and the system libraries Chrome needs. When we installed Puppeteer 25.14.0 for this page, its browser cache alone took 563 MB on macOS (Chrome plus Chrome Headless Shell). Each capture also starts Node and Chrome inside your PHP worker. Our Browsershot alternative page measures that cost and maps every Browsershot option. For PDFs, see Laravel HTML to PDF and Dompdf vs Browsershot. The rest of this page assumes you want the HTTP route.
Install on Laravel 13
composer require renderscreenshot/renderscreenshot -W
The -W is not optional on a new Laravel 13 app. The skeleton we installed on 2026-10-18 locked Guzzle 8.3.0, and SDK 1.1.1 requires Guzzle ^7.0. Without -W, Composer stops with "the package is fixed to 8.3.0 (lock file version)". Laravel 13 allows ^7.8.2 || ^8.0, so -W moves Guzzle to 7.16 and nothing else breaks. On Laravel 11 or 12 apps that already use Guzzle 7, a plain composer require works. The SDK needs PHP 8.0 or later.
Add your keys to .env and to config/services.php:
// config/services.php 'renderscreenshot' => [ 'key' => env('RS_API_KEY'), // rs_live_... 'secret' => env('RS_SECRET_KEY'), // rs_secret_..., signs URLs 'public_key_id' => env('RS_PUBLIC_KEY_ID'), // rs_pub_... 'base_url' => env('RS_BASE_URL'), 'per_minute' => (int) env('RS_PER_MINUTE', 10), // your plan's per-account limit ],
Set RS_PER_MINUTE to your plan's limit: 10 on Free, 60 on Starter, 120 on Growth and 200 on Scale. The X-RateLimit-Limit response header reports the value for your account.
One client and one rate limiter
// app/Providers/AppServiceProvider.php use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; use RenderScreenshot\Client; public function register(): void { $this->app->singleton(Client::class, fn () => new Client( config('services.renderscreenshot.key'), baseUrl: config('services.renderscreenshot.base_url'), timeout: 90.0, // longer than the 60 s maximum page wait signingKey: config('services.renderscreenshot.secret'), publicKeyId: config('services.renderscreenshot.public_key_id'), )); } public function boot(): void { // One bucket for every worker: the API counts requests per account. RateLimiter::for('renderscreenshot', fn () => Limit::perMinute( config('services.renderscreenshot.per_minute') )); }
The SDK's default timeout is 30 seconds, but the API can wait up to 60 seconds for a slow page. Give the client more time than that, or it gives up on renders that would have worked.
The limiter is the Laravel-specific piece. The API limit is per account: every key and every worker shares one counter. If each Horizon process paces itself to the full limit, five processes send five times too many requests and most of them come back as 429. A named limiter lives in your cache store (Redis in most deployments), so all workers draw from one bucket.
The queued job
<?php // app/Jobs/CapturePageScreenshot.php namespace App\Jobs; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Queue\Middleware\RateLimited; use Illuminate\Support\Facades\Storage; use RenderScreenshot\Client; use RenderScreenshot\RenderScreenshotException; use RenderScreenshot\TakeOptions; class CapturePageScreenshot implements ShouldQueue { use Queueable; public int $tries = 5; public int $timeout = 120; public function __construct(public string $url, public string $path) {} public function middleware(): array { return [new RateLimited('renderscreenshot')]; } public function handle(Client $client): void { try { $image = $client->take( TakeOptions::url($this->url)->preset('og_card')->format('webp') ); } catch (RenderScreenshotException $e) { match (true) { $e->httpStatus === 429 => $this->release($e->retryAfter ?? 60), in_array($e->httpStatus, [408, 422], true), $e->httpStatus >= 500 => $this->release(30 * $this->attempts()), default => $this->fail($e), // 400, 401, 403: fix the request first }; return; } Storage::disk('public')->put($this->path, $image); } }
Dispatch it from a controller, an observer or a scheduled command:
CapturePageScreenshot::dispatch('https://example.com/pricing', "previews/{$page->id}.webp");
Three decisions in this job came from running it:
- Branch on
httpStatus, notretryable. SDK 1.1.1 setsretryablefrom a fixed list of codes (rate_limited,timeout,render_failed,internal_error). The API reports the per-minute limit astoo_many_requests, so a real 429 arrives withretryablefalse. A job that trusts the flag fails on the first rate limit. The status code is reliable. release()withretryAfter. On a 429 the SDK reads theRetry-Afterheader intoretryAfter. Releasing the job for exactly that long puts it back when the window resets. Each release counts as an attempt, so keep$triesabove the number of rate limits you expect in a burst.$timeoutabove the client timeout. Laravel kills a job that runs past$timeout. At 120 seconds it outlives the 90-second HTTP timeout, so the job always sees the SDK's exception and decides what to do.
The capture uses the og_card preset (1200x630) and WebP output. Any TakeOptions method from the PHP SDK reference works here, for example ->fullPage() or ->blockCookieBanners().
A Blade component for signed og:image URLs
A crawler fetches og:image without your API key, so the tag needs a signed URL. A view component keeps the signing in one place:
<?php // app/View/Components/OgImage.php namespace App\View\Components; use DateTime; use Illuminate\Support\Facades\Cache; use Illuminate\View\Component; use RenderScreenshot\Client; use RenderScreenshot\TakeOptions; class OgImage extends Component { public string $src; public function __construct(Client $client, public string $url) { // Same URL for 6 days, so crawlers and the API cache see one address. $this->src = Cache::remember('og-image:'.sha1($url), now()->addDays(6), fn () => $client->generateUrl( TakeOptions::url($url)->preset('og_card')->cacheAvailable(), new DateTime('+7 days') // the API accepts at most 30 days ) ); } public function render() { return view('components.og-image'); } }
{{-- resources/views/components/og-image.blade.php --}} <meta property="og:image" content="{{ $src }}"> <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630"> <meta name="twitter:card" content="summary_large_image">
Use it in a layout as <x-og-image :url="url()->current()" />. Laravel resolves Client from the container because the constructor type-hints it. Two details:
- Cache the signed URL.
generateUrl()puts a newexpiresin every URL, so without the cache each page view produces a new address and each crawler fetch is a new render. WithcacheAvailable()signed into the URL, a repeat render inside the cache TTL costs 0 credits. - Escaping is correct.
{{ }}turns&into&inside the attribute. Browsers and crawlers decode it before they request the image, so the signature still matches.
Tests with fake HTTP and a fake queue
The SDK constructor takes a Guzzle client, so a MockHandler replaces the network. Laravel's withFakeQueueInteractions() lets you assert release() and fail() without a worker:
<?php // tests/Feature/CapturePageScreenshotTest.php namespace Tests\Feature; use App\Jobs\CapturePageScreenshot; use GuzzleHttp\Client as Guzzle; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use Illuminate\Support\Facades\Storage; use RenderScreenshot\Client; use Tests\TestCase; class CapturePageScreenshotTest extends TestCase { private function fakeApi(Response ...$responses): void { $guzzle = new Guzzle(['handler' => HandlerStack::create(new MockHandler($responses))]); $this->app->instance(Client::class, new Client('rs_live_test', null, null, $guzzle)); } public function test_it_stores_the_image(): void { Storage::fake('public'); $this->fakeApi(new Response(200, ['Content-Type' => 'image/webp'], 'RIFF....WEBP')); $job = (new CapturePageScreenshot('https://example.com', 'previews/1.webp'))->withFakeQueueInteractions(); $job->handle(app(Client::class)); Storage::disk('public')->assertExists('previews/1.webp'); $job->assertNotReleased(); } public function test_a_429_is_released_for_retry_after_seconds(): void { $this->fakeApi(new Response(429, ['Content-Type' => 'application/json', 'Retry-After' => '20'], json_encode([ 'error' => ['type' => 'rate_limit_error', 'code' => 'too_many_requests', 'message' => 'Rate limit exceeded'], ]))); $job = (new CapturePageScreenshot('https://example.com', 'previews/1.webp'))->withFakeQueueInteractions(); $job->handle(app(Client::class)); $job->assertReleased(delay: 20); } public function test_a_400_fails_without_retry(): void { $this->fakeApi(new Response(400, ['Content-Type' => 'application/json'], json_encode([ 'error' => ['type' => 'validation_error', 'code' => 'invalid_url', 'message' => 'bad url'], ]))); $job = (new CapturePageScreenshot('not-a-url', 'previews/1.webp'))->withFakeQueueInteractions(); $job->handle(app(Client::class)); $job->assertFailed(); } }
The 429 test is the one that matters: it uses the API's real error code, so it fails if someone switches the job back to the retryable flag. For the component, bind a Client with test signing keys, request a page that renders <x-og-image>, and assert that the HTML contains key_id=rs_pub_test, a 64-character hex signature and no rs_secret_ string. More patterns are in testing code that calls the API.
Source and next steps
- SDK source: github.com/Render-Screenshot/rs-php, with release notes in its CHANGELOG.
- Queue patterns with idempotent keys: screenshots in background jobs.
- Every error code and when to retry it: error codes.
Get a free API key and dispatch the job against the real API. The free plan includes 150 credits every month.