This page shows how to take website screenshots from PHP with RenderScreenshot. It covers the Composer SDK, a Laravel queued job that stores the image, a Symfony service, PHPUnit tests built on Guzzle's MockHandler, and a plain ext-curl call for projects that cannot add Guzzle. For og:image cards on a WordPress site, see the WordPress plugin guide. For other languages, see the screenshot API code examples hub.
Last tested: 2026-10-18 with PHP 8.5.10,
renderscreenshot/renderscreenshot1.1.1, Guzzle 7.16.0, PHPUnit 13.4.3, Laravel 13.37.0 (PHPUnit 12.5.40) and Symfony 8.1.9. Every listing ran against a local stub of our API that returns the documented image, batch and error responses, including a 429 withRetry-After. We did not call the live API for this page.
Install
composer require renderscreenshot/renderscreenshot
The SDK needs PHP 8.0 or later and depends on guzzlehttp/guzzle 7.0.
In a new Laravel 13 app, that command fails. Laravel 13.37 locks Guzzle 8.3, and Composer refuses to change a locked package during a partial update. Laravel itself accepts Guzzle ^7.8.2 || ^8.0, so allow the downgrade:
composer require renderscreenshot/renderscreenshot -W
On our run, -W moved Guzzle from 8.3.0 to 7.16.0 (and guzzlehttp/psr7 and promises with it), and the app's test suite still passed. A Symfony 8.1 skeleton has no Guzzle, so the plain command works there.
Quick start
<?php require __DIR__ . '/vendor/autoload.php'; use RenderScreenshot\Client; use RenderScreenshot\TakeOptions; $client = new Client(getenv('RS_API_KEY'), timeout: 60.0); $png = $client->take( TakeOptions::url('https://example.com')->preset('og_card')->blockCookieBanners() ); file_put_contents('example-og.png', $png); echo 'saved ' . strlen($png) . " bytes\n";
take() returns the image as a binary string. The PHP SDK reference lists every builder method, and the presets reference explains og_card.
Errors and the retry rule
RenderScreenshotException exposes httpStatus, errorCode, retryable and retryAfter as public properties, plus getMessage(). For three failing stub responses it held these values:
| Stub response | httpStatus |
errorCode |
retryable |
retryAfter |
|---|---|---|---|---|
429 with Retry-After: 1 |
429 | too_many_requests |
false |
1 |
| 422 target timeout | 422 | timeout |
true |
null |
| 400 bad URL | 400 | invalid_url |
false |
null |
errorCode is the API's code, but retryable is computed from the SDK's own list (rate_limited, timeout, render_failed, internal_error). The API names a rate limit too_many_requests, so a 429 arrives with retryable set to false. Branch on httpStatus instead. The property is errorCode, not code, because Exception already has a $code. Every code has its own page in the error codes index.
A framework-free service
Both framework examples below use this class. It has no framework imports, so you can test it with plain PHPUnit:
<?php namespace App\Service; use RenderScreenshot\Client; use RenderScreenshot\RenderScreenshotException; use RenderScreenshot\TakeOptions; final class OgImages { public function __construct(private readonly Client $client) { } /** Render a 1200x630 card, retrying 429 and 5xx up to three times. */ public function render(string $url, int $attempts = 3): string { for ($attempt = 1; ; $attempt++) { try { return $this->client->take(TakeOptions::url($url)->preset('og_card')); } catch (RenderScreenshotException $e) { $transient = $e->httpStatus === 429 || $e->httpStatus >= 500; if (!$transient || $attempt >= $attempts) { throw $e; } sleep($e->retryAfter ?? 2 ** $attempt); } } } /** Signed GET URL for an <img> or og:image. Stable for a whole UTC day. */ public function signedUrl(string $url): string { $expires = new \DateTimeImmutable('today +8 days', new \DateTimeZone('UTC')); return $this->client->generateUrl(TakeOptions::url($url)->preset('og_card'), $expires); } }
sleep() inside a web request holds a PHP-FPM worker. That is acceptable for an occasional retry, but in a queue worker it is better to hand the wait back to the queue, as the Laravel job below does.
Laravel: a queued job
Bind one client in AppServiceProvider::register(), reading keys from config/services.php:
$this->app->singleton(Client::class, fn () => new Client( config('services.renderscreenshot.key'), timeout: 60.0, signingKey: config('services.renderscreenshot.signing_key'), publicKeyId: config('services.renderscreenshot.public_key_id'), ));
The job renders a card and stores it on the public disk. The three outcomes are handled differently: a rate limit goes back on the queue without using up a try, a client or target error fails at once, and a 5xx is rethrown so the worker's backoff applies.
<?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 CaptureOgImage implements ShouldQueue { use Queueable; public int $tries = 5; public int $timeout = 120; public function __construct(public string $slug) { } public function handle(Client $client): void { try { $png = $client->take( TakeOptions::url(url("/blog/{$this->slug}"))->preset('og_card') ); } catch (RenderScreenshotException $e) { if ($e->httpStatus === 429) { $this->release($e->retryAfter ?? 30); // not a failure: try again later return; } if ($e->httpStatus < 500) { $this->fail($e); // bad input or a target error: retrying will not help return; } throw $e; // 5xx: let the worker retry with backoff } Storage::disk('public')->put("og/{$this->slug}.png", $png); } public function backoff(): array { return [10, 30, 90]; } }
Dispatch it when a post is published: CaptureOgImage::dispatch($post->slug). Note that release() still counts as an attempt against $tries, so keep $tries above the number of rate-limit waits you expect. The background jobs guide covers idempotent job keys and how many workers to run.
Symfony: wiring the service
Symfony autowires OgImages once it knows how to build the SDK client. Add this to config/services.yaml:
services: RenderScreenshot\Client: arguments: $apiKey: '%env(RS_API_KEY)%' $timeout: 60.0 $signingKey: '%env(RS_SIGNING_KEY)%' $publicKeyId: '%env(RS_PUBLIC_KEY_ID)%'
Named arguments let you skip $baseUrl and $httpClient. A controller then asks for the service:
<?php namespace App\Controller; use App\Service\OgImages; use RenderScreenshot\RenderScreenshotException; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; final class OgImageController { #[Route('/og/{slug}.png', requirements: ['slug' => '[a-z0-9-]+'], methods: ['GET'])] public function __invoke(string $slug, OgImages $og): Response { try { $png = $og->render("https://example.com/blog/{$slug}"); } catch (RenderScreenshotException $e) { return new Response('', $e->httpStatus === 429 ? 503 : 502); } return new Response($png, 200, [ 'Content-Type' => 'image/png', 'Cache-Control' => 'public, max-age=86400', ]); } }
The route requirement does the slug check, so /og/Bad_Slug.png is a 404 and never reaches the API.
A signed og:image in a template
A signed URL lets the crawler fetch the image straight from the API, with no request to your app and no API key in the HTML. In plain PHP:
<meta property="og:image" content="<?= htmlspecialchars($og->signedUrl($pageUrl), ENT_QUOTES) ?>">
In Blade, {{ }} escapes for you; in Twig, autoescaping does. Each & becomes &, which is correct inside an attribute. signedUrl() sets the expiry to midnight UTC eight days ahead. A +7 days from now would sign a new URL on every request, and crawlers would treat each one as a new image. The maximum lifetime is 30 days.
Batch
For up to 10 URLs, POST /v1/batch returns all results in one response:
$batch = $client->batch( ['https://example.com/', 'https://example.com/pricing', 'https://example.com/docs'], TakeOptions::url('')->preset('og_card') ); printf("%d/%d rendered\n", $batch['completed'], $batch['total']); foreach ($batch['results'] as $item) { echo $item['status'] === 'completed' ? "{$item['url']} -> {$item['image']['image_url']}\n" : "{$item['url']} failed: {$item['error']}\n"; }
Test with PHPUnit and Guzzle's MockHandler
The SDK's constructor accepts a Guzzle client, and that is the cleanest seam for tests: queue fake responses, and record what was sent with the history middleware. An injected client replaces the SDK's default one, so give it the base_uri:
<?php use App\Service\OgImages; use GuzzleHttp\Client as Guzzle; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; use RenderScreenshot\Client; use RenderScreenshot\RenderScreenshotException; final class OgImagesTest extends TestCase { private array $sent = []; private function service(Response ...$responses): OgImages { $stack = HandlerStack::create(new MockHandler($responses)); $stack->push(Middleware::history($this->sent)); $guzzle = new Guzzle([ 'handler' => $stack, 'base_uri' => 'https://api.renderscreenshot.com/v1/', ]); return new OgImages(new Client('rs_live_test', httpClient: $guzzle)); } public function testRetriesAfterRateLimit(): void { $limited = new Response(429, ['Retry-After' => '0', 'Content-Type' => 'application/json'], '{"error":{"code":"too_many_requests","message":"slow down"}}'); $png = $this->service($limited, new Response(200, ['Content-Type' => 'image/png'], 'png-bytes')) ->render('https://example.com'); $this->assertSame('png-bytes', $png); $this->assertCount(2, $this->sent); $body = json_decode((string) $this->sent[0]['request']->getBody(), true); $this->assertSame('og_card', $body['preset']); $this->assertSame('Bearer rs_live_test', $this->sent[0]['request']->getHeaderLine('Authorization')); } public function testBadUrlIsNotRetried(): void { $this->expectException(RenderScreenshotException::class); $this->service(new Response(400, [], '{"error":{"code":"invalid_url","message":"bad"}}')) ->render('not-a-url'); } }
In Laravel, mock the binding instead. Http::fake() does not help, because the SDK sends through its own Guzzle client, not Laravel's HTTP client:
public function test_a_rate_limit_releases_the_job(): void { $client = $this->mock(Client::class, fn (MockInterface $m) => $m->shouldReceive('take') ->andThrow(new RenderScreenshotException(429, 'too_many_requests', 'slow down', 7))); $job = (new CaptureOgImage('hello-world'))->withFakeQueueInteractions(); $job->handle($client); $job->assertReleased(delay: 7); }
More patterns are in the SDK testing guide.
Without the SDK: ext-curl
If you cannot add Guzzle, ext-curl is enough. CURLOPT_FILE streams the response to disk, so a long full-page capture never sits in memory:
<?php // No SDK, no Guzzle: ext-curl writes the image straight to a file. $out = fopen('example-full.png', 'wb'); $ch = curl_init('https://api.renderscreenshot.com/v1/screenshot'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('RS_API_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'url' => 'https://example.com', 'capture' => ['mode' => 'full_page'], ]), CURLOPT_FILE => $out, // stream the body to disk CURLOPT_TIMEOUT => 90, CURLOPT_FAILONERROR => false, ]); $ok = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); fclose($out); if ($ok === false || $status !== 200) { $body = json_decode((string) file_get_contents('example-full.png'), true); unlink('example-full.png'); fwrite(STDERR, sprintf("%d %s\n", $status, $body['error']['code'] ?? curl_error($ch))); exit(1); } echo 'saved ' . filesize('example-full.png') . " bytes\n";
An error response is JSON, and it lands in the same file, so the script reads it back and deletes the file. With a missing key, it printed 401 missing_credentials and exited with status 1. There is no curl_close(): it has done nothing since PHP 8.0 and is deprecated in 8.5. The body format is the POST /v1/screenshot JSON.
Source, changelog and next steps
- SDK source: github.com/Render-Screenshot/rs-php, with release notes in its CHANGELOG and the package on Packagist.
- Retry rules in depth: error handling and retries.
- Rendering in-process instead: Dompdf vs Browsershot.
Get a free API key to run the examples against the real API. The free plan includes 150 credits each month.