Features Full Page Screenshot Wait for Selector & Delay Block Cookie Banners Custom Viewport & Device Website to PDF HTML to Image Dark Mode Image Format & Quality MCP Server Pricing Docs Blog Log In Sign Up

PHP Screenshot API — Capture Website Screenshots with One HTTP Call

PHP has no built-in browser engine. When a project needs screenshots, the usual workaround is calling a Node.js script through shell_exec(), which launches Puppeteer, which downloads Chromium, which eats a few hundred megabytes of RAM per capture. You end up maintaining two runtimes, debugging inter-process communication, and hoping the OOM killer doesn't visit your VPS at 3 AM.

The ScreenshotRun PHP screenshot API replaces that entire stack with one HTTP call. Pass a URL, get back a PNG, JPEG, WebP, AVIF, or PDF. PHP's built-in file_get_contents() is enough to get started, and most production apps already have cURL or Guzzle available.

Your first screenshot in 30 seconds

Create an account at the dashboard to get an API key (free plan, 200 captures per month, no credit card). Then run this from any PHP file:

<?php

$context = stream_context_create([
    'http' => [
        'header' => "Authorization: Bearer YOUR_API_KEY\r\n",
        'timeout' => 60,
    ],
]);

$query = http_build_query([
    'url' => 'https://github.com',
    'response_type' => 'image',
]);

$image = file_get_contents(
    "https://api.screenshotrun.com/v1/screenshots/capture?{$query}",
    false,
    $context
);

file_put_contents('github.png', $image);
echo "Saved: github.png\n";

No Node.js, no Chromium binary, no shell_exec(). The API opens a real Chrome browser on our infrastructure, renders the page completely (JavaScript, lazy images, web fonts), and returns the raw image bytes.

Full example with cURL

Most PHP installations ship with the cURL extension enabled. It gives you more control over timeouts, HTTP status codes, and error handling than file_get_contents():

<?php

$apiKey = getenv('SCREENSHOTRUN_KEY');

function captureScreenshot(string $targetUrl, array $options = []): string
{
    global $apiKey;

    $params = http_build_query(array_merge([
        'url' => $targetUrl,
        'format' => $options['format'] ?? 'png',
        'width' => $options['width'] ?? 1280,
        'height' => $options['height'] ?? 720,
        'full_page' => $options['full_page'] ?? false,
        'response_type' => 'image',
    ], $options));

    $ch = curl_init("https://api.screenshotrun.com/v1/screenshots/capture?{$params}");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["Authorization: Bearer {$apiKey}"],
        CURLOPT_TIMEOUT => 60,
        CURLOPT_FOLLOWLOCATION => true,
    ]);

    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($status !== 200) {
        throw new RuntimeException("Screenshot API returned HTTP {$status}");
    }

    return $body;
}

$screenshot = captureScreenshot('https://github.com', [
    'format' => 'webp',
    'width' => 1920,
    'full_page' => true,
]);

file_put_contents('github-full.webp', $screenshot);
echo "Captured full-page WebP screenshot\n";

Store the API key in an environment variable (SCREENSHOTRUN_KEY). In Laravel that means a line in .env. In plain PHP, set it through your web server config or a putenv() call during bootstrap. Either way, the key stays out of version control.

Using Guzzle

If your project already pulls in Guzzle for HTTP requests (Laravel includes it by default), the integration looks cleaner:

<?php

use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://api.screenshotrun.com/v1/',
    'headers' => [
        'Authorization' => 'Bearer ' . env('SCREENSHOTRUN_KEY'),
    ],
    'timeout' => 60,
]);

function captureScreenshot(Client $client, string $url, array $options = []): string
{
    $response = $client->get('screenshots/capture', [
        'query' => array_merge([
            'url' => $url,
            'format' => $options['format'] ?? 'png',
            'width' => $options['width'] ?? 1280,
            'height' => $options['height'] ?? 720,
            'full_page' => $options['full_page'] ?? false,
            'response_type' => 'image',
        ], $options),
    ]);

    return $response->getBody()->getContents();
}

$image = captureScreenshot($client, 'https://stripe.com', [
    'format' => 'jpeg',
    'width' => 1440,
]);

file_put_contents('stripe.jpeg', $image);

Guzzle wraps cURL internally but handles redirects, exceptions, and connection pooling for you. If your project already depends on it, there is no reason to drop down to raw cURL calls.

Available parameters

All parameters go into the query string of the GET request. The table below lists the ones you will reach for most often:

ParameterTypeDefaultDescription
urlstringThe webpage to capture
formatstringpngOutput: png, jpeg, webp, avif, pdf
widthinteger1280Viewport width in pixels
heightinteger720Viewport height in pixels
full_pagebooleanfalseCapture the full scrollable page
devicestringdesktopDevice type: desktop, mobile, tablet
retinabooleanfalse2x pixel density
dark_modebooleanfalseForce dark color scheme
selectorstringCSS selector to capture a specific element
delayinteger0Wait N milliseconds before capturing
block_cookiesbooleanfalseRemove cookie consent banners
block_adsbooleanfalseRemove advertisements
block_chatsbooleanfalseRemove live chat widgets
cssstringInject custom CSS before capture
jsstringExecute JavaScript before capture

Full reference in the API documentation.

Dark mode, element capture, and CSS injection

Three options that show up in most real-world PHP integrations.

Dark mode. Set dark_mode to true and the browser applies prefers-color-scheme: dark before rendering. Sites that support this media query switch to their dark theme on their own:

$params = [
    'url' => 'https://github.com',
    'dark_mode' => true,
    'response_type' => 'image',
];

Element capture. Pass a CSS selector through the selector parameter and the API crops the result to that element's bounding box:

$params = [
    'url' => 'https://github.com/anthropics',
    'selector' => '.js-repo-list',
    'response_type' => 'image',
];

CSS injection. Remove a sticky header, hide a promo bar, or swap fonts before the screenshot fires:

$params = [
    'url' => 'https://example.com',
    'css' => '.promo-banner { display: none !important; } body { font-family: Inter, sans-serif; }',
    'response_type' => 'image',
];

All three combine in one request. You can capture a single element, in dark mode, with custom CSS applied, from a single GET call.

Error handling and retries

Production code needs to handle network failures, rate limits, and the occasional server hiccup. A retry wrapper keeps things stable:

<?php

function captureWithRetry(string $url, array $params = [], int $maxRetries = 2): string
{
    $apiKey = getenv('SCREENSHOTRUN_KEY');

    $query = http_build_query(array_merge([
        'url' => $url,
        'format' => 'png',
        'response_type' => 'image',
    ], $params));

    for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
        $ch = curl_init("https://api.screenshotrun.com/v1/screenshots/capture?{$query}");
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ["Authorization: Bearer {$apiKey}"],
            CURLOPT_TIMEOUT => 60,
            CURLOPT_FOLLOWLOCATION => true,
        ]);

        $body = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $headers = curl_getinfo($ch);
        curl_close($ch);

        if ($status === 200) {
            return $body;
        }

        if ($status === 429) {
            sleep(5);
            continue;
        }

        if ($status >= 500 && $attempt < $maxRetries) {
            sleep(($attempt + 1) * 2);
            continue;
        }

        throw new RuntimeException("Screenshot API error: HTTP {$status}");
    }

    throw new RuntimeException("Max retries exceeded for {$url}");
}

Rate-limit responses (429) trigger a 5-second pause before the next try. Server errors (5xx) retry with increasing delays. Client errors like bad credentials or invalid parameters fail immediately because repeating the same request will produce the same result.

Laravel controller for screenshot proxy

A recurring pattern in Laravel apps: the frontend needs screenshots for link previews or thumbnail galleries, but the API key has to stay server-side. A thin controller handles the proxy:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class ScreenshotController extends Controller
{
    public function capture(Request $request)
    {
        $request->validate([
            'url' => 'required|url',
            'format' => 'in:png,jpeg,webp,avif,pdf',
            'width' => 'integer|min:320|max:3840',
        ]);

        $response = Http::withToken(config('services.screenshotrun.key'))
            ->timeout(60)
            ->get('https://api.screenshotrun.com/v1/screenshots/capture', [
                'url' => $request->input('url'),
                'format' => $request->input('format', 'png'),
                'width' => $request->input('width', 1280),
                'response_type' => 'image',
            ]);

        if ($response->failed()) {
            return response()->json(
                ['error' => 'Capture failed'],
                $response->status()
            );
        }

        return response($response->body())
            ->header('Content-Type', $response->header('Content-Type'))
            ->header('Cache-Control', 'public, max-age=3600');
    }
}

Register the route in routes/web.php or routes/api.php:

Route::get('/api/screenshot', [ScreenshotController::class, 'capture']);

Add the key to config/services.php:

'screenshotrun' => [
    'key' => env('SCREENSHOTRUN_KEY'),
],

Your frontend calls /api/screenshot?url=https://example.com and gets the image back. The API key never leaves the server. The Cache-Control header avoids repeated captures of the same URL within the cache window.

API vs. PHP with shell_exec and Puppeteer

The Puppeteer-from-PHP approach works for small-scale captures. You write a Node.js script, call it through shell_exec(), and parse the output. The trouble starts when you deploy it to a server that also runs your main application.

PHP + shell_exec + PuppeteerScreenshotRun API
RuntimePHP + Node.js + Chromium (two runtimes)PHP only
SetupInstall Node, npm install puppeteer, configure system librariesOne HTTP GET call
DependenciesPuppeteer (170-400 MB Chromium download)file_get_contents() or cURL (built-in)
RAM per capture200-400 MB per Chromium tabNone on your server
ScalingProcess limits, memory monitoring, zombie cleanupManaged by the API
Cookie bannersPer-site CSS selectors, ongoing maintenanceOne parameter: block_cookies=true
DockerMulti-stage build, font packages, Chromium flagsStandard PHP image, nothing extra
CostServer resources + engineering timeFree tier (200/month), then usage-based

If your PHP project needs interactive browser automation (filling forms, navigating login flows, running end-to-end tests), the Puppeteer bridge is the right tool. If the job is turning URLs into images without adding Node.js to your stack, a PHP screenshot API call keeps the architecture simple and the deployment clean.

Create a free account, grab your API key, and paste it into any example above. 200 screenshots per month on the free plan.

Get Your Free API Key

Frequently asked questions

No. The API is a standard HTTP endpoint. PHP's built-in file_get_contents(), cURL, or Guzzle can call it directly. No browser binaries, no Node.js runtime, no system-level dependencies.
Yes. Laravel's Http::get() facade works out of the box. The page includes a complete controller example with request validation, caching headers, and error handling that you can drop into any Laravel project.
The capture itself takes 2-5 seconds in both cases, depending on the target page. The difference is in overhead: shell_exec() spawns a Node.js process and launches Chromium on every call, while the API removes that startup cost entirely. With 10 or more concurrent captures, the gap becomes significant because you avoid the RAM and CPU spike from multiple Chromium instances.
Yes. The API runs a full Chromium browser, so JavaScript-heavy pages (React, Vue, Angular, single-page applications) render completely. Use the delay parameter for pages that load data after the initial render, or wait_for_selector to pause until a specific DOM element appears.
The free plan includes 200 screenshots per month with a per-second rate limit to prevent abuse. If you exceed the rate limit, the API returns HTTP 429 with a Retry-After header. The retry function shown in the error handling section handles this automatically.