How to Take Screenshots with Laravel Using a Screenshot API

2026-05-22 | Tags: [laravel, php, api, screenshot, backend]

Laravel's expressive syntax and rich ecosystem make integrating external APIs straightforward. Whether you're building a monitoring dashboard, an archiving tool, or a social preview generator, this guide covers the full range of Laravel patterns for screenshot API integration.

Setup

composer require guzzlehttp/guzzle

Laravel's HTTP client wraps Guzzle and is available out of the box in Laravel 7+. Add your API key to .env:

SCREENSHOT_API_KEY=your_api_key_here

Register it in config/services.php:

'screenshot' => [
    'key' => env('SCREENSHOT_API_KEY'),
    'base_url' => 'https://hermesforge.dev/api/screenshot',
],

Get a free API key at hermesforge.dev/screenshot.

Basic Controller

A minimal Laravel controller that proxies screenshot requests:

// app/Http/Controllers/ScreenshotController.php
<?php

namespace App\Http\Controllers;

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

class ScreenshotController extends Controller
{
    public function capture(Request $request): Response
    {
        $request->validate([
            'url'       => 'required|url',
            'width'     => 'integer|min:1|max:3840',
            'height'    => 'integer|min:1|max:2160',
            'format'    => 'in:webp,png,jpeg',
            'full_page' => 'boolean',
            'delay'     => 'integer|min:0|max:10000',
        ]);

        $response = Http::withHeaders([
            'X-API-Key' => config('services.screenshot.key'),
        ])->get(config('services.screenshot.base_url'), $request->only([
            'url', 'width', 'height', 'format', 'full_page', 'delay',
        ]));

        if ($response->failed()) {
            abort($response->status(), 'Screenshot failed');
        }

        return response($response->body(), 200, [
            'Content-Type'  => $response->header('Content-Type') ?? 'image/webp',
            'Cache-Control' => 'public, max-age=300',
        ]);
    }
}

Register the route in routes/api.php:

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

Test it:

curl "http://localhost:8000/api/screenshot?url=https://example.com" --output example.webp

Service Class

Extract the API interaction into an injectable service:

// app/Services/ScreenshotService.php
<?php

namespace App\Services;

use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class ScreenshotService
{
    private string $apiKey;
    private string $baseUrl;

    public function __construct()
    {
        $this->apiKey  = config('services.screenshot.key');
        $this->baseUrl = config('services.screenshot.base_url');
    }

    public function capture(
        string $url,
        int $width = 1280,
        int $height = 800,
        string $format = 'webp',
        bool $fullPage = false,
        ?int $delay = null,
    ): string {
        $params = array_filter([
            'url'       => $url,
            'width'     => $width,
            'height'    => $height,
            'format'    => $format,
            'full_page' => $fullPage ? 'true' : 'false',
            'delay'     => $delay,
        ], fn($v) => $v !== null);

        $response = Http::timeout(30)
            ->withHeaders(['X-API-Key' => $this->apiKey])
            ->get($this->baseUrl, $params);

        if ($response->failed()) {
            throw new RuntimeException(
                "Screenshot API error: HTTP {$response->status()}",
                $response->status()
            );
        }

        return $response->body();
    }

    public function captureToBase64(string $url, array $options = []): string
    {
        $body = $this->capture($url, ...$options);
        return base64_encode($body);
    }
}

Bind it in AppServiceProvider or use auto-resolution:

// The service can be auto-resolved via Laravel's container
// Inject it directly in controllers:

public function capture(Request $request, ScreenshotService $screenshots): Response
{
    $body = $screenshots->capture(
        url: $request->input('url'),
        width: $request->integer('width', 1280),
        height: $request->integer('height', 800),
    );

    return response($body, 200, ['Content-Type' => 'image/webp']);
}

Response Caching

Cache screenshot results with Laravel's Cache facade:

use Illuminate\Support\Facades\Cache;

public function capture(Request $request, ScreenshotService $screenshots): Response
{
    $request->validate(['url' => 'required|url']);

    $cacheKey = 'screenshot:' . md5(http_build_query($request->only([
        'url', 'width', 'height', 'format', 'full_page',
    ])));

    $body = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($request, $screenshots) {
        return $screenshots->capture(
            url:      $request->input('url'),
            width:    $request->integer('width', 1280),
            height:   $request->integer('height', 800),
            format:   $request->input('format', 'webp'),
            fullPage: $request->boolean('full_page'),
        );
    });

    return response($body, 200, [
        'Content-Type'  => 'image/webp',
        'Cache-Control' => 'public, max-age=300',
    ]);
}

Switch cache drivers in .envCACHE_DRIVER=redis for production.

Rate Limiting

Use Laravel's built-in rate limiting in RouteServiceProvider or routes/api.php:

// routes/api.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('screenshots', function (Request $request) {
    return Limit::perMinute(10)->by($request->ip());
});

Route::middleware(['throttle:screenshots'])
    ->get('/screenshot', [ScreenshotController::class, 'capture']);

Laravel returns 429 Too Many Requests with a Retry-After header automatically.

Queued Job

For long-running captures or background processing, dispatch a Job:

// app/Jobs/CaptureScreenshot.php
<?php

namespace App\Jobs;

use App\Models\Screenshot;
use App\Services\ScreenshotService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;

class CaptureScreenshot implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 30;  // Seconds between retries

    public function __construct(
        private readonly string $url,
        private readonly int $screenshotId,
        private readonly array $options = [],
    ) {}

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

        $path = "screenshots/{$this->screenshotId}.webp";
        Storage::put($path, $body);

        Screenshot::findOrFail($this->screenshotId)->update([
            'status' => 'ready',
            'path'   => $path,
        ]);
    }

    public function failed(\Throwable $exception): void
    {
        Screenshot::findOrFail($this->screenshotId)->update([
            'status' => 'failed',
            'error'  => $exception->getMessage(),
        ]);
    }
}

Dispatch from a controller:

public function captureAsync(Request $request): \Illuminate\Http\JsonResponse
{
    $request->validate(['url' => 'required|url']);

    $screenshot = Screenshot::create([
        'url'    => $request->input('url'),
        'status' => 'pending',
    ]);

    CaptureScreenshot::dispatch($request->input('url'), $screenshot->id);

    return response()->json(['id' => $screenshot->id, 'status' => 'pending']);
}

public function status(int $id): \Illuminate\Http\JsonResponse
{
    $screenshot = Screenshot::findOrFail($id);
    return response()->json($screenshot->only(['id', 'url', 'status', 'error']));
}

Batch Screenshots

Capture multiple URLs in a single request using concurrent HTTP:

use Illuminate\Support\Facades\Http;

public function batch(Request $request): \Illuminate\Http\JsonResponse
{
    $request->validate([
        'urls'          => 'required|array|max:10',
        'urls.*'        => 'url',
        'width'         => 'integer|min:1|max:3840',
        'height'        => 'integer|min:1|max:2160',
    ]);

    $apiKey = config('services.screenshot.key');
    $baseUrl = config('services.screenshot.base_url');
    $params = $request->only(['width', 'height']) + ['format' => 'webp'];

    // Build concurrent requests
    $promises = collect($request->input('urls'))->mapWithKeys(function ($url) use ($apiKey, $baseUrl, $params) {
        return [$url => Http::withHeaders(['X-API-Key' => $apiKey])
            ->async()
            ->get($baseUrl, ['url' => $url] + $params)];
    });

    $results = Http::pool(fn ($pool) =>
        $promises->map(fn ($_, $url) =>
            $pool->as($url)->withHeaders(['X-API-Key' => $apiKey])
                ->get($baseUrl, ['url' => $url] + $params)
        )->toArray()
    );

    $formatted = collect($results)->map(function ($response, $url) {
        if ($response instanceof \Throwable || $response->failed()) {
            return ['url' => $url, 'status' => 'error', 'error' => 'Capture failed'];
        }
        return [
            'url'    => $url,
            'status' => 'ok',
            'data'   => base64_encode($response->body()),
        ];
    })->values();

    return response()->json(['results' => $formatted]);
}

Artisan Command

Add a CLI command for scheduled or manual captures:

// app/Console/Commands/CaptureScreenshotCommand.php
<?php

namespace App\Console\Commands;

use App\Services\ScreenshotService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;

class CaptureScreenshotCommand extends Command
{
    protected $signature = 'screenshot:capture
        {url : URL to capture}
        {--output= : Output file path}
        {--width=1280 : Viewport width}
        {--height=800 : Viewport height}
        {--format=webp : Image format (webp, png, jpeg)}
        {--full-page : Capture full page}';

    protected $description = 'Capture a screenshot of a URL';

    public function handle(ScreenshotService $service): int
    {
        $url = $this->argument('url');
        $this->info("Capturing: {$url}");

        try {
            $body = $service->capture(
                url:      $url,
                width:    (int) $this->option('width'),
                height:   (int) $this->option('height'),
                format:   $this->option('format'),
                fullPage: $this->option('full-page'),
            );

            $output = $this->option('output') ?? 'screenshot.' . $this->option('format');
            file_put_contents($output, $body);
            $this->info("Saved to: {$output} (" . strlen($body) . " bytes)");

            return Command::SUCCESS;
        } catch (\RuntimeException $e) {
            $this->error("Failed: {$e->getMessage()}");
            return Command::FAILURE;
        }
    }
}

Run it:

php artisan screenshot:capture https://example.com --output=example.webp --full-page

Register in app/Console/Kernel.php to run on a schedule:

$schedule->command('screenshot:capture https://yoursite.com --output=/var/screenshots/daily.webp')
    ->dailyAt('06:00');

Error Handling

Use Laravel's exception handler for consistent error responses:

// app/Exceptions/Handler.php
use App\Exceptions\ScreenshotException;

$this->renderable(function (ScreenshotException $e, Request $request) {
    if ($request->expectsJson()) {
        return response()->json(['error' => $e->getMessage()], $e->getCode() ?: 500);
    }
    return response($e->getMessage(), $e->getCode() ?: 500);
});

Get Your API Key

Free API key at hermesforge.dev/screenshot. Full parameter reference at hermesforge.dev/api/docs.