PHP Screenshot API: Composer SDK, Laravel and Symfony

Take website screenshots from PHP: the Composer SDK, a Laravel queued job, a Symfony service, PHPUnit with Guzzle's MockHandler, and a curl fallback.

By Simon Chiu · Updated

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/renderscreenshot 1.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 with Retry-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 &amp;, 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

Get a free API key to run the examples against the real API. The free plan includes 150 credits each month.

Frequently asked questions

Which Composer package is the RenderScreenshot PHP SDK?
renderscreenshot/renderscreenshot on Packagist. It needs PHP 8.0 or later and depends on Guzzle 7.
Why does composer require fail in a new Laravel 13 app?
A fresh Laravel 13 app locks Guzzle 8, and the SDK requires Guzzle ^7.0. Laravel 13 accepts either major version, so composer require with -W downgrades Guzzle to 7 and installs the SDK.
Can I call the API from PHP without Guzzle?
Yes. The SDK uses Guzzle, but the API is plain HTTPS. ext-curl can POST the JSON body and write the image straight to a file with CURLOPT_FILE.
Does Http::fake() stop the SDK's requests in Laravel tests?
No. The SDK builds its own Guzzle client, not Laravel's HTTP client. Mock the RenderScreenshot\Client binding with $this->mock() instead.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.