urlbox/screenshots
Laravel wrapper for the Urlbox screenshot API, letting you capture full-page or viewport screenshots of any website as PNG or JPEG, with options for thumbnails, device sizes, retina, and custom rendering.
composer require urlbox/screenshots:^3.0
Grab an API key + secret from the Urlbox dashboard (they are separate values — the key goes in the URL path, the secret is used to HMAC-sign the query string). If you plan to consume render-completion webhooks, also copy the webhook secret — as of 3.0.1 it is the optional third argument to fromCredentials().
Keep credentials out of code and register the client once:
// config/services.php
'urlbox' => [
'key' => env('URLBOX_API_KEY'),
'secret' => env('URLBOX_API_SECRET'),
'webhook_secret' => env('URLBOX_WEBHOOK_SECRET'),
],
// app/Providers/AppServiceProvider.php
use Urlbox\Screenshots\Urlbox;
public function register(): void
{
$this->app->singleton(Urlbox::class, fn () => Urlbox::fromCredentials(
config('services.urlbox.key'),
config('services.urlbox.secret'),
config('services.urlbox.webhook_secret'), // nullable — 3.0.1 default
));
}
First use case — a link-preview thumbnail for a saved URL:
use Urlbox\Screenshots\Urlbox;
$urlbox = app(Urlbox::class);
$options = [
'url' => 'https://laravel.com',
'format' => 'jpg',
'thumb_width' => 600,
'block_ads' => true,
];
$urlbox->getImage($options, ['output' => 'download', 'file_name' => 'laravel.jpg']);
Read the package README first, then read src/Urlbox.php end to end — it is one small class, and the only things that matter are fromCredentials(), getApiUrl() (builds the signed URL), and getImage() (a thin Guzzle wrapper around that URL). Once you have seen getApiUrl() output you understand the whole package: it is a signed-URL builder, nothing more.
Prefer getApiUrl() over getImage(). The wrapper's transport is the least valuable part of the package; the signature logic is the valuable part. Building the URL yourself and fetching it with Laravel's HTTP client gives you timeouts, retries, Http::fake(), and storage for free:
$signedUrl = $urlbox->getApiUrl($options);
$response = Http::timeout(30)
->retry(2, 2000, throw: false)
->get($signedUrl);
abort_unless(
$response->successful() && str_starts_with($response->header('Content-Type'), 'image/'),
502, 'Screenshot render failed'
);
Always queue it. A cold render takes 3–15 seconds. Do this in a job, never in the request cycle:
class GenerateThumbnail implements ShouldQueue
{
public int $tries = 2;
public int $timeout = 60;
public array $backoff = [10, 60];
public function __construct(public int $postId) {}
public function handle(Urlbox $urlbox): void
{
$post = Post::findOrFail($this->postId);
$url = $urlbox->getApiUrl([
'url' => $post->canonical_url,
'format' => 'webp',
'width' => 1200,
'height' => 630,
'block_ads' => true,
'hide_selector' => '.cookie-banner, .newsletter-modal',
'wait_for' => 'main',
'retina' => true,
]);
$bytes = Http::timeout(45)->get($url)->throw()->body();
Storage::disk('public')->put("thumbs/{$post->id}.webp", $bytes);
$post->update(['thumb_generated_at' => now()]);
}
}
Cache aggressively. Screenshots are billed per render and the result for a given URL changes slowly. Key on a hash of the normalized options array, not the raw URL string:
$key = 'urlbox:'.md5(json_encode($options));
$url = Cache::remember($key, now()->addDays(7), fn () => $urlbox->getApiUrl($options));
On-demand signed URLs for the frontend. For user-generated previews, generate the signed URL server-side and emit it as an <img src> so bytes never touch your app server. Gate it behind your own route or short-lived cache so you can invalidate/rate-limit — see Gotchas.
Use the webhook secret (new in 3.0.1) for callback verification. If you pass render_complete/callback options or use Urlbox's async render endpoint, the callback arrives as an unsigned HTTP POST — anyone can forge it. Register the client with the webhook secret and verify the payload's HMAC against it in a dedicated route before trusting the body:
Route::post('/webhooks/urlbox', function (Request $request) {
$expected = hash_hmac('sha256', $request->getContent(), config('services.urlbox.webhook_secret'));
abort_unless(hash_equals($expected, (string) $request->header('X-Urlbox-Signature')), 403);
RenderCompleted::dispatch($request->json('url'));
return response()->noContent();
});
Check the installed src/Urlbox.php for the exact header name and hashing scheme your version emits — 3.0.1 only guarantees the secret is nullable in fromCredentials(), not the verification helper's shape.
Wrap it in a one-method interface. App\Contracts\Screenshotter with screenshot(array $options): string. Bind the Urlbox implementation in a service provider. This is a 300-line SDK with 11 stars; you want a one-file swap if it stalls.
Batch/bulk: loop the job dispatch with Bus::batch([...]), cap concurrency low (5–10). Urlbox renders in parallel on their side but your plan has concurrency limits and you will get 429s on a 200-item burst.
guzzlehttp/psr7 to ^2.6.1, and adjusts composer.json. But if your installed version is still ^1.0, treat the jump to ^3.0 as a major upgrade: re-read vendor/urlbox/screenshots/src/Urlbox.php and re-check every method signature before bumping.fromCredentials($key, $secret) still works. The whole point of PR #10 is that the third argument defaults to null — you will not get a compile error. You will silently lose the ability to verify webhooks, so if you consume callbacks, set it explicitly.true vs 1, key order, or different URL-encoding silently yields a 403. Always go through getApiUrl() / getImage().'full_page' => $request->boolean('full_page')) rather than passing "0"/"1" strings.Content-Type, not just status. Render failures sometimes come back with a 200 and an error body or placeholder image. Guard for image/ before storing bytes, otherwise you will happily persist a JSON error as a .webp.delay vs wait_for: prefer wait_for on a selector you know exists post-render; delay is a blunt sleep that costs latency on every request. Combine with hide_selector for cookie banners and block_ads for deterministic output.thumb_width is not width. width/height control the viewport and therefore the render; thumb_width only resizes the returned file (saves bytes, not render cost).full_page is a trap on infinite-scroll sites — you get a 40,000px-tall image and a slow render. Gate it behind a per-domain allowlist.Http::fake(['api.urlbox.io/*' => Http::response($png, 200, ['Content-Type' => 'image/png'])]), or bind a fake Screenshotter in the container. Assert on the generated URL's query params rather than the network. Test the webhook route with a hand-computed hash_hmac so a broken signature check fails loudly.dd($urlbox->getApiUrl($options)), paste into a browser or curl -I. If it 403s, it is almost always signature/encoding; if it times out, it is a page that never reaches idle — add disable_js or wait_for.fromCredentials() arity, and render-output options (['output' => 'download'|'display']) have shifted across releases; the local vendor/urlbox/screenshots/src is the source of truth, not the README."urlbox/screenshots": "^3.0") and schedule a quarterly look at the repo — low star count, single maintainer. Note that 3.0.1's composer.json change can narrow the PHP/Guzzle constraints your app must satisfy; run composer why urlbox/screenshots before bumping in a legacy app. Your interface wrapper is the insurance policy.How can I help you explore Laravel packages today?