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:
| Parameter | Type | Default | Description |
|---|---|---|---|
url | string | — | The webpage to capture |
format | string | png | Output: png, jpeg, webp, avif, pdf |
width | integer | 1280 | Viewport width in pixels |
height | integer | 720 | Viewport height in pixels |
full_page | boolean | false | Capture the full scrollable page |
device | string | desktop | Device type: desktop, mobile, tablet |
retina | boolean | false | 2x pixel density |
dark_mode | boolean | false | Force dark color scheme |
selector | string | — | CSS selector to capture a specific element |
delay | integer | 0 | Wait N milliseconds before capturing |
block_cookies | boolean | false | Remove cookie consent banners |
block_ads | boolean | false | Remove advertisements |
block_chats | boolean | false | Remove live chat widgets |
css | string | — | Inject custom CSS before capture |
js | string | — | Execute 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 + Puppeteer | ScreenshotRun API | |
|---|---|---|
| Runtime | PHP + Node.js + Chromium (two runtimes) | PHP only |
| Setup | Install Node, npm install puppeteer, configure system libraries | One HTTP GET call |
| Dependencies | Puppeteer (170-400 MB Chromium download) | file_get_contents() or cURL (built-in) |
| RAM per capture | 200-400 MB per Chromium tab | None on your server |
| Scaling | Process limits, memory monitoring, zombie cleanup | Managed by the API |
| Cookie banners | Per-site CSS selectors, ongoing maintenance | One parameter: block_cookies=true |
| Docker | Multi-stage build, font packages, Chromium flags | Standard PHP image, nothing extra |
| Cost | Server resources + engineering time | Free 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 KeyFrequently asked questions
file_get_contents(), cURL, or Guzzle can call it directly. No browser binaries, no Node.js runtime, no system-level dependencies.
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.
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.
delay parameter for pages that load data after the initial render, or wait_for_selector to pause until a specific DOM element appears.
Retry-After header. The retry function shown in the error handling section handles this automatically.