Screenshot API with PHP: Capture Website Screenshots in Laravel & Vanilla PHP

Published February 22, 2026 · 10 min read

Need to capture website screenshots from your PHP application? Whether you're building link previews, generating social cards, archiving pages, or monitoring competitor sites, a screenshot API is the simplest path. No Puppeteer servers to manage, no Chrome binaries to install on your hosting.

This guide covers three approaches: vanilla PHP with cURL, a clean Laravel integration, and async batch processing for high-volume use cases. All examples use GrabShot's screenshot API, which returns images in under 3 seconds with a generous free tier.

Why Use an API Instead of Running Headless Chrome?

PHP developers often try to run Puppeteer or Chrome via shell commands. This works locally but creates real problems in production:

A screenshot API handles all of this. You send a URL, you get an image back. Your PHP app stays lightweight.

Quick Start: Vanilla PHP with cURL

The simplest approach uses PHP's built-in cURL functions. No packages, no dependencies:

<?php
// Capture a screenshot with GrabShot API
function captureScreenshot(string $url, string $apiKey, array $options = []): string|false
{
    $params = array_merge([
        'url'    => $url,
        'width'  => 1280,
        'height' => 800,
        'format' => 'png',
    ], $options);

    $endpoint = 'https://grabshot.dev/api/screenshot?' . http_build_query($params);

    $ch = curl_init($endpoint);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => ["X-API-Key: $apiKey"],
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_FOLLOWLOCATION => true,
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode !== 200) {
        error_log("Screenshot failed for $url: HTTP $httpCode");
        return false;
    }

    return $response; // Raw image bytes
}

// Usage
$apiKey = getenv('GRABSHOT_API_KEY');
$image  = captureScreenshot('https://example.com', $apiKey);

if ($image) {
    file_put_contents('screenshot.png', $image);
    echo "Screenshot saved!\n";
}

That's it. About 20 lines of actual code. The function returns raw image bytes that you can save to disk, store in S3, or serve directly to the browser.

Adding a File Cache

If you're capturing screenshots of the same URLs repeatedly (like for link previews), add a simple file-based cache to avoid burning API credits:

<?php
function cachedScreenshot(string $url, string $apiKey, int $ttl = 3600): string|false
{
    $cacheDir  = __DIR__ . '/cache/screenshots';
    $cacheFile = $cacheDir . '/' . md5($url) . '.png';

    // Return cached version if fresh
    if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $ttl) {
        return file_get_contents($cacheFile);
    }

    // Capture fresh screenshot
    $image = captureScreenshot($url, $apiKey);

    if ($image) {
        if (!is_dir($cacheDir)) {
            mkdir($cacheDir, 0755, true);
        }
        file_put_contents($cacheFile, $image);
    }

    return $image;
}

// Screenshots cached for 1 hour by default
$image = cachedScreenshot('https://example.com', $apiKey);

For production apps, consider using Redis or your framework's cache instead of the filesystem. But for small projects, this works perfectly.

Laravel Integration

In Laravel, you can wrap the API in a clean service class with dependency injection, config management, and queue support.

Step 1: Add Your API Key

Add to your .env:

GRABSHOT_API_KEY=your_api_key_here
GRABSHOT_BASE_URL=https://grabshot.dev/api

Create config/grabshot.php:

<?php
return [
    'api_key'  => env('GRABSHOT_API_KEY'),
    'base_url' => env('GRABSHOT_BASE_URL', 'https://grabshot.dev/api'),
    'defaults' => [
        'width'  => 1280,
        'height' => 800,
        'format' => 'png',
    ],
];

Step 2: Create the Service

<?php
namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;

class ScreenshotService
{
    public function capture(string $url, array $options = []): ?string
    {
        $params = array_merge(config('grabshot.defaults'), $options, [
            'url' => $url,
        ]);

        $response = Http::withHeaders([
            'X-API-Key' => config('grabshot.api_key'),
        ])
        ->timeout(30)
        ->get(config('grabshot.base_url') . '/screenshot', $params);

        if ($response->failed()) {
            logger()->error("Screenshot failed: {$url}", [
                'status' => $response->status(),
            ]);
            return null;
        }

        return $response->body();
    }

    public function captureAndStore(
        string $url,
        string $path = null,
        string $disk = 'public'
    ): ?string {
        $image = $this->capture($url);
        if (!$image) return null;

        $path = $path ?? 'screenshots/' . md5($url) . '.png';
        Storage::disk($disk)->put($path, $image);

        return $path;
    }

    public function cached(string $url, int $ttl = 3600): ?string
    {
        $key = 'screenshot:' . md5($url);

        return Cache::remember($key, $ttl, function () use ($url) {
            return $this->captureAndStore($url);
        });
    }
}

Step 3: Use It Anywhere

<?php
// In a controller
class LinkPreviewController extends Controller
{
    public function __construct(
        private ScreenshotService $screenshots
    ) {}

    public function show(Request $request)
    {
        $url  = $request->validate(['url' => 'required|url'])['url'];
        $path = $this->screenshots->cached($url);

        return response()->json([
            'preview_url' => Storage::url($path),
        ]);
    }
}

// In an Artisan command
$service = app(ScreenshotService::class);
$service->captureAndStore('https://laravel.com', 'previews/laravel.png');

Async Screenshots with Laravel Queues

For bulk screenshot jobs (sitemap audits, batch social cards, competitor monitoring), dispatch the work to a queue so your web requests stay fast:

<?php
namespace App\Jobs;

use App\Services\ScreenshotService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class CaptureScreenshotJob implements ShouldQueue
{
    use Dispatchable, Queueable;

    public int $tries = 3;
    public int $backoff = 10;

    public function __construct(
        public string $url,
        public string $storagePath,
        public array $options = [],
    ) {}

    public function handle(ScreenshotService $service): void
    {
        $image = $service->capture($this->url, $this->options);

        if (!$image) {
            $this->fail(new \Exception("Screenshot capture failed: {$this->url}"));
            return;
        }

        Storage::disk('public')->put($this->storagePath, $image);
    }
}

// Dispatch from anywhere:
$urls = ['https://example.com', 'https://laravel.com', 'https://php.net'];

foreach ($urls as $i => $url) {
    CaptureScreenshotJob::dispatch(
        url: $url,
        storagePath: "batch/screenshot-{$i}.png",
    );
}

With Laravel Horizon or a simple queue worker, this processes screenshots in parallel without blocking your app. The built-in retry logic handles transient failures automatically.

Get Your API Key in 30 Seconds

25 free screenshots/month. No credit card required. Works with any PHP version 7.4+.

Try GrabShot Free →

Advanced Options

GrabShot supports parameters that handle common screenshot challenges:

ParameterDescriptionExample
widthViewport width in pixels1440
heightViewport height900
full_pageCapture entire scrollable pagetrue
formatOutput formatpng, jpeg, webp
delayWait before capture (ms)2000
dark_modeForce dark color schemetrue
block_adsBlock ads and trackerstrue

Use these in any of the examples above by passing them in the options array:

$image = captureScreenshot('https://example.com', $apiKey, [
    'full_page' => 'true',
    'format'    => 'webp',
    'delay'     => 2000,
    'dark_mode' => 'true',
]);

Handling Errors Properly

In production, you want to handle failures gracefully. Here's a robust version with retries:

<?php
function captureWithRetry(
    string $url,
    string $apiKey,
    int $maxRetries = 3,
    array $options = []
): string|false {
    $lastError = '';

    for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
        $image = captureScreenshot($url, $apiKey, $options);

        if ($image !== false) {
            return $image;
        }

        $lastError = "Attempt $attempt failed";
        error_log("$lastError for $url");

        if ($attempt < $maxRetries) {
            // Exponential backoff: 1s, 2s, 4s
            sleep(pow(2, $attempt - 1));
        }
    }

    error_log("All $maxRetries attempts failed for $url: $lastError");
    return false;
}

Real-World Use Case: Link Preview Cards

One of the most common needs: generating preview cards for URLs that users paste into your app (like Slack or Twitter do). Here's a minimal endpoint:

<?php
// GET /api/preview?url=https://example.com
header('Content-Type: application/json');

$url    = filter_input(INPUT_GET, 'url', FILTER_VALIDATE_URL);
$apiKey = getenv('GRABSHOT_API_KEY');

if (!$url) {
    http_response_code(400);
    echo json_encode(['error' => 'Invalid URL']);
    exit;
}

$image = cachedScreenshot($url, $apiKey, ttl: 7200);

if (!$image) {
    http_response_code(502);
    echo json_encode(['error' => 'Screenshot capture failed']);
    exit;
}

// Return as base64 for easy embedding
echo json_encode([
    'url'       => $url,
    'image'     => 'data:image/png;base64,' . base64_encode($image),
    'timestamp' => time(),
]);

On the frontend, render it in an <img> tag directly from the base64 data. No file storage needed for ephemeral previews.

Performance Tips

  1. Use WebP format - 30-50% smaller files than PNG with similar quality. Pass format=webp.
  2. Set explicit dimensions - Don't capture at 1920px if you're displaying at 400px. Smaller viewports = faster captures.
  3. Cache aggressively - Most websites don't change minute-to-minute. A 1-hour cache TTL is reasonable for previews.
  4. Use queues for batch work - Never capture 50 screenshots synchronously in a web request.
  5. Consider jpeg for thumbnails - When you don't need transparency, JPEG at 80% quality is much smaller.

Wrapping Up

Capturing website screenshots in PHP doesn't require running headless browsers on your server. A simple API call with cURL or Laravel's HTTP client gives you reliable screenshots with minimal code and zero infrastructure overhead.

The examples in this guide work with GrabShot's API, which includes 25 free screenshots per month on the free tier. For higher volumes, paid plans start at $9/month with no rate limits on individual requests.

Check out the full API documentation for additional parameters like custom CSS injection, cookie handling, and webhook callbacks.