Tutorials

Laravel: Weekly Page Snapshots for Business Owners with Screenshot API

Laravel: Weekly Page Snapshots for Business Owners with Screenshot API

A website can change without anyone noticing: a promotion disappears, a booking button moves below the fold, or a deployment quietly breaks the mobile layout. For a small business owner, a weekly screenshot archive provides a simple visual record that is easier to review than release notes or monitoring graphs.

This tutorial builds that archive in Laravel. A scheduled command dispatches one queued job per important page, a dedicated client downloads each PNG, and Laravel stores the image beside a metadata file containing its checksum and relevant cache or quota headers. The application never has to install, patch, or operate Chromium.

Get access to the Screenshot API

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Screenshot API service page. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Visit the official documentation. Find the Service token panel and copy the service-scoped token.
  4. Keep that token in environment-backed configuration. Regenerating it revokes the previously active token, so a rotation must update every deployed application that uses it.

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. A Bearer token is the safest default because query parameters commonly appear in proxy and access logs.

Confirm the exact HTTP contract

The capture operation is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Its required query parameter is url, and a successful response contains an image/png body plus cache and quota response headers.

Make one minimal request before writing integration code:

curl --get \
  --header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  --header "Accept: image/png" \
  --data-urlencode "url=https://example.com/" \
  --dump-header screenshot-headers.txt \
  --output screenshot.png \
  https://ai.mihajlo.mk/api/screenshot-api/v1/capture

Inspect the HTTP status and headers as well as opening the PNG. A file named screenshot.png is not proof that the response was an image; an unsuccessful endpoint can return an error body that an incautious client saves under the same extension.

Now place the credential and the business-owned page URLs in the deployment environment:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SNAPSHOT_DISK=local
SNAPSHOT_HOME_URL=https://www.example.com/
SNAPSHOT_BOOKING_URL=https://www.example.com/book

Do not commit the real values. If Laravel configuration has already been cached, changing .env alone will not update a running deployment; rebuild the configuration cache during release.

Architecture and project structure

The design deliberately has four boundaries:

  • ScreenshotClient owns authentication, timeouts, retries, status mapping, PNG validation, and response-header normalization.
  • ScreenshotCapture is the domain result. The rest of the application does not depend on Laravel’s HTTP response object.
  • CapturePageSnapshot performs slow network and storage work in the queue.
  • snapshots:capture dispatches configured pages, while Laravel’s scheduler invokes the command weekly.

Create app/Services/Screenshots, app/Jobs, and app/Console/Commands. The relevant files are config/services.php, config/snapshots.php, ScreenshotCapture.php, ScreenshotApiException.php, ScreenshotClient.php, CapturePageSnapshot.php, CaptureSnapshots.php, and routes/console.php.

Queues are worthwhile here because a screenshot can take seconds and several pages should not hold the scheduler open. Each job remains independently retryable. The trade-off is operational: production now needs a queue worker and a lock-capable shared cache if several application nodes run the scheduler.

Configure pages and credentials

Add this entry to the array returned by config/services.php:

'screenshot_api' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
    'token' => env('SCREENSHOT_API_TOKEN'),
],

Create config/snapshots.php:

<?php

return [
    'disk' => env('SNAPSHOT_DISK', 'local'),

    'pages' => [
        ['key' => 'home', 'url' => env('SNAPSHOT_HOME_URL')],
        ['key' => 'booking', 'url' => env('SNAPSHOT_BOOKING_URL')],
    ],
];

Keys become storage path components, so keep them lowercase and stable. Treat this configuration as an allowlist. Do not turn the feature into a public “screenshot any URL” endpoint; accepting arbitrary URLs would create an abuse and server-side request-forgery surface.

Map the API response at the boundary

Create the result and exception classes:

<?php
// app/Services/Screenshots/ScreenshotCapture.php

namespace App\Services\Screenshots;

final readonly class ScreenshotCapture
{
    public function __construct(
        public string $png,
        public array $operationalHeaders,
    ) {}
}

// app/Services/Screenshots/ScreenshotApiException.php

namespace App\Services\Screenshots;

use RuntimeException;
use Throwable;

final class ScreenshotApiException extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        public readonly ?int $retryAfterSeconds = null,
        ?Throwable $previous = null,
    ) {
        parent::__construct("Screenshot capture failed: {$kind}", 0, $previous);
    }
}

The exception exposes a stable application-level failure kind without storing an upstream body, URL, or token. The job can therefore distinguish a permanent authentication problem from a temporary outage.

Build a defensive Laravel HTTP client

The client below uses bounded connection and response timeouts. It retries only connection failures, HTTP 429, and server errors. Authentication and validation failures return immediately because repeating the same invalid request wastes quota and delays diagnosis.

<?php

namespace App\Services\Screenshots;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use LogicException;

final class ScreenshotClient
{
    public function capture(string $url): ScreenshotCapture
    {
        $token = config('services.screenshot_api.token');
        $endpoint = config('services.screenshot_api.endpoint');

        if (! is_string($token) || $token === '') {
            throw new LogicException('SCREENSHOT_API_TOKEN is not configured.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($token)
                    ->accept('image/png')
                    ->connectTimeout(5)
                    ->timeout(30)
                    ->get($endpoint, ['url' => $url]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotApiException(
                        'transport', previous: $exception
                    );
                }

                usleep([250, 1000][$attempt - 1] * 1000);
                continue;
            }

            if ($response->successful()) {
                return $this->mapSuccessfulResponse($response);
            }

            $status = $response->status();
            $retryable = $status === 429 || $status >= 500;

            if ($retryable && $attempt < 3) {
                usleep($this->retryDelayMilliseconds($response, $attempt) * 1000);
                continue;
            }

            $kind = match (true) {
                in_array($status, [401, 403], true) => 'authentication',
                in_array($status, [400, 422], true) => 'invalid_request',
                $status === 429 => 'quota',
                $status >= 500 => 'upstream_unavailable',
                default => 'upstream_error',
            };

            throw new ScreenshotApiException(
                $kind,
                $status,
                $this->retryAfterSeconds($response),
            );
        }

        throw new ScreenshotApiException('unexpected_state');
    }

    private function mapSuccessfulResponse(Response $response): ScreenshotCapture
    {
        $body = $response->body();
        $type = strtolower((string) $response->header('Content-Type'));

        if (! str_starts_with($type, 'image/png')
            || ! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
            throw new ScreenshotApiException(
                'invalid_response',
                $response->status(),
            );
        }

        return new ScreenshotCapture($body, $this->operationalHeaders($response));
    }

    private function retryDelayMilliseconds(Response $response, int $attempt): int
    {
        $seconds = $this->retryAfterSeconds($response);

        if ($seconds !== null) {
            return min($seconds * 1000, 10000);
        }

        return [250, 1000][$attempt - 1];
    }

    private function retryAfterSeconds(Response $response): ?int
    {
        $value = $response->header('Retry-After');

        return is_string($value) && ctype_digit($value)
            ? min((int) $value, 3600)
            : null;
    }

    private function operationalHeaders(Response $response): array
    {
        $kept = [];
        $standardCacheHeaders = [
            'age', 'cache-control', 'etag', 'expires', 'last-modified', 'vary',
        ];

        foreach ($response->headers() as $name => $values) {
            $lower = strtolower($name);

            $relevant = in_array($lower, $standardCacheHeaders, true)
                || $lower === 'retry-after'
                || str_contains($lower, 'cache')
                || str_contains($lower, 'quota')
                || str_contains($lower, 'ratelimit')
                || str_contains($lower, 'rate-limit');

            if ($relevant) {
                $kept[$lower] = implode(', ', (array) $values);
            }
        }

        return $kept;
    }
}

The header mapper intentionally preserves names and values rather than assuming an undocumented quota schema. That lets metadata retain the service’s cache and quota signals without falsely treating a particular header as guaranteed. The PNG signature check also prevents an HTML or JSON error document from entering the visual archive.

Store one idempotent snapshot per week

The queued job uses an ISO week such as 2026-W41 as its archive key. Re-running the job for that week repairs or replaces the same object instead of creating duplicates.

<?php

namespace App\Jobs;

use App\Services\Screenshots\ScreenshotApiException;
use App\Services\Screenshots\ScreenshotClient;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;

final class CapturePageSnapshot implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public int $tries = 3;
    public int $timeout = 120;
    public array $backoff = [300, 1800];
    public int $uniqueFor = 7200;

    public function __construct(
        public readonly string $key,
        public readonly string $url,
        public readonly string $period,
    ) {}

    public function uniqueId(): string
    {
        return "{$this->key}:{$this->period}";
    }

    public function handle(ScreenshotClient $client): void
    {
        try {
            $capture = $client->capture($this->url);
        } catch (ScreenshotApiException $exception) {
            Log::warning('Weekly screenshot capture failed.', [
                'page' => $this->key,
                'period' => $this->period,
                'kind' => $exception->kind,
                'status' => $exception->status,
            ]);

            if (in_array($exception->kind, [
                'authentication', 'invalid_request',
            ], true)) {
                $this->fail($exception);
                return;
            }

            if ($exception->kind === 'quota') {
                $delay = $exception->retryAfterSeconds ?? 900;
                $this->release(min(max($delay, 60), 3600));
                return;
            }

            throw $exception;
        }

        $base = "snapshots/{$this->key}/{$this->period}";
        $disk = Storage::disk(config('snapshots.disk'));

        if (! $disk->put("{$base}.png", $capture->png)) {
            throw new RuntimeException('Could not store screenshot.');
        }

        $metadata = json_encode([
            'page' => $this->key,
            'period' => $this->period,
            'captured_at' => now('UTC')->toIso8601String(),
            'sha256' => hash('sha256', $capture->png),
            'response_headers' => $capture->operationalHeaders,
        ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

        if (! $disk->put("{$base}.json", $metadata)) {
            throw new RuntimeException('Could not store snapshot metadata.');
        }

        Log::info('Weekly screenshot stored.', [
            'page' => $this->key,
            'period' => $this->period,
            'bytes' => strlen($capture->png),
        ]);
    }
}

The log contains identifiers and operational state, never the token, response body, or complete target URL. The sidecar checksum makes later integrity checks straightforward.

Dispatch and schedule captures

Create app/Console/Commands/CaptureSnapshots.php:

<?php

namespace App\Console\Commands;

use App\Jobs\CapturePageSnapshot;
use Illuminate\Console\Command;

final class CaptureSnapshots extends Command
{
    protected $signature = 'snapshots:capture';
    protected $description = 'Queue weekly screenshots of configured pages';

    public function handle(): int
    {
        $period = now('UTC')->format('o-\WW');
        $dispatched = 0;

        foreach (config('snapshots.pages', []) as $page) {
            $key = $page['key'] ?? null;
            $url = $page['url'] ?? null;
            $scheme = is_string($url) ? parse_url($url, PHP_URL_SCHEME) : null;

            if (! is_string($key)
                || preg_match('/^[a-z0-9-]+$/', $key) !== 1
                || ! filter_var($url, FILTER_VALIDATE_URL)
                || $scheme !== 'https') {
                $this->error('Snapshot configuration contains an invalid page.');
                return self::FAILURE;
            }

            CapturePageSnapshot::dispatch($key, $url, $period);
            $dispatched++;
        }

        $this->info("Queued {$dispatched} weekly snapshots.");

        return self::SUCCESS;
    }
}

Then add the weekly schedule to routes/console.php:

<?php

use Illuminate\Support\Facades\Schedule;

Schedule::command('snapshots:capture')
    ->weeklyOn(1, '06:00')
    ->timezone('UTC')
    ->onOneServer()
    ->withoutOverlapping();

This queues snapshots every Monday at 06:00 UTC. onOneServer() and withoutOverlapping() require functioning cache locks; use a shared lock-capable cache when multiple nodes run the scheduler.

Test success and permanent failure

Laravel’s Http::fake() keeps tests deterministic and prevents accidental use of quota. The following feature tests verify storage, metadata, and the rule that authentication failures are not retried:

<?php

namespace Tests\Feature;

use App\Jobs\CapturePageSnapshot;
use App\Services\Screenshots\ScreenshotApiException;
use App\Services\Screenshots\ScreenshotClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

final class CapturePageSnapshotTest extends TestCase
{
    public function test_it_stores_a_png_and_metadata(): void
    {
        Storage::fake('local');
        config([
            'snapshots.disk' => 'local',
            'services.screenshot_api.token' => 'test-token',
        ]);

        $png = "\x89PNG\r\n\x1a\n" . 'deterministic-test-content';

        Http::fake([
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
                Http::response($png, 200, [
                    'Content-Type' => 'image/png',
                    'Cache-Control' => 'public, max-age=60',
                ]),
        ]);

        $job = new CapturePageSnapshot(
            'home',
            'https://example.com/',
            '2026-W41',
        );

        $job->handle(app(ScreenshotClient::class));

        Storage::disk('local')->assertExists(
            'snapshots/home/2026-W41.png'
        );
        Storage::disk('local')->assertExists(
            'snapshots/home/2026-W41.json'
        );

        Http::assertSentCount(1);
    }

    public function test_authentication_failure_is_not_retried(): void
    {
        config(['services.screenshot_api.token' => 'invalid-test-token']);

        Http::fake([
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
                Http::response('Unauthorized', 401),
        ]);

        try {
            app(ScreenshotClient::class)->capture('https://example.com/');
            $this->fail('Expected ScreenshotApiException.');
        } catch (ScreenshotApiException $exception) {
            $this->assertSame('authentication', $exception->kind);
            $this->assertSame(401, $exception->status);
        }

        Http::assertSentCount(1);
    }
}

Deploy and operate the archive

Production needs the scheduler, a persistent queue worker, durable storage, and configuration refreshes:

php artisan config:cache
php artisan test
php artisan snapshots:capture
php artisan queue:work --queue=default --tries=3 --timeout=120

* * * * * cd /path/to/application && php artisan schedule:run >> /dev/null 2>&1

Run the worker under the operating system’s process supervisor so it restarts after deployments and crashes. If releases use ephemeral containers or several application nodes, do not leave the archive on a node-local disk. Set SNAPSHOT_DISK to a configured durable Laravel filesystem disk.

Alert on failed jobs and repeated authentication, quota, or invalid_response events. Record queue depth and capture duration in the application’s existing observability system. Decide on a retention policy based on the owner’s needs; weekly images are small individually but form an unbounded collection.

Common failures

  • Every request returns 401 or 403: verify the service-scoped token, rebuild cached configuration, and remember that regenerating the token revoked its predecessor.
  • The command queues nothing useful: check that the page environment variables exist in the runtime environment, not only in a local shell.
  • Jobs remain pending: confirm that a queue worker is running against the same queue connection as the web application.
  • Only one server has images: move snapshots to shared durable storage and ensure every node uses the same disk configuration.
  • The schedule runs more than once: verify the shared cache and its lock support, then inspect the scheduler on each node.
  • A PNG is rejected: inspect status and content type without logging the token or body. The service may have returned an error document rather than an image.

Final verification checklist

  • The plan is active and the current service token is present only in environment-backed secrets.
  • The minimal request returns HTTP success, image/png, and a viewable PNG.
  • php artisan snapshots:capture dispatches every configured HTTPS page.
  • The queue writes matching .png and .json objects under the expected ISO week.
  • The metadata checksum matches the stored image and retains relevant cache and quota headers verbatim.
  • Authentication and invalid-request failures stop immediately; connection, quota, and server failures receive bounded retries.
  • The scheduler has a single-server lock, the worker is supervised, and failed jobs produce an alert.

The finished system is intentionally modest: a schedule, a queue, two files per page, and a strict API boundary. Yet every Monday it creates something unusually useful—a visual timeline a business owner can understand at a glance. Good production integrations often look like this: small in surface area, explicit about failure, and quietly dependable long after the first successful request.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.