Tutorials

Symfony: Weekly Page Snapshots for Small Business Websites via Screenshot API

Symfony: Weekly Page Snapshots for Small Business Websites via Screenshot API

A website can be technically healthy while quietly becoming visually wrong. A theme update shifts the booking button below the fold. A missing asset leaves the services page half empty. A content edit looks fine on a laptop but breaks the production layout.

For a small business owner, a weekly screenshot archive provides a simple answer to an important question: what did customers actually see? This tutorial builds that archive as a production Symfony command. It captures configured pages through the Screenshot API, validates the returned PNG, preserves cache and quota metadata, and writes one idempotent snapshot per ISO week.

Get access before writing integration code

Start by registering an account. If you already have one, use the sign-in page.

  1. Open the Screenshot API service page.
  2. Choose an available Free, Plus, or Pro plan and complete its activation.
  3. Open the official Screenshot API documentation.
  4. Find the Service token panel and copy the service-scoped token.
  5. Store it in environment-backed configuration, never in PHP source, fixtures, logs, or a committed configuration file.

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because credentials in query strings are more likely to appear in access logs and monitoring systems.

Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: update the application secret, restart long-running processes if applicable, verify a capture, and only then consider the rotation complete.

Confirm the HTTP contract

The exact request 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-related response headers.

Before building the feature, make one minimal request. The command saves headers separately so you can inspect service metadata without printing binary PNG data into the terminal.

export SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN

curl --get --silent --show-error --fail \
  --header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
  --header "Accept: image/png" \
  --data-urlencode "url=https://www.example.com/" \
  --dump-header smoke.headers \
  --output smoke.png \
  https://ai.mihajlo.mk/api/screenshot-api/v1/capture

file smoke.png

The service exists to capture cached desktop or mobile PNG screenshots without requiring your application to install, patch, and supervise Chromium. This implementation deliberately sends only the guaranteed url parameter. If you need a particular capture mode, use only options documented on the official documentation page rather than guessing parameter names.

Install the Symfony dependencies

composer require symfony/http-client symfony/filesystem symfony/monolog-bundle
composer require --dev symfony/test-pack

The project will contain a dedicated API boundary, a domain response object, an atomic filesystem store, and a console command:

src/
  Command/CaptureWeeklySnapshotsCommand.php
  Screenshot/CapturedScreenshot.php
  Screenshot/CaptureFailed.php
  Screenshot/ScreenshotApiClient.php
  Screenshot/SnapshotStore.php
tests/
  Screenshot/ScreenshotApiClientTest.php
var/
  snapshots/              generated; never committed

Configure credentials and important pages

Put local secrets in .env.local, which should remain outside version control. In production, prefer your hosting platform’s secret or environment-variable facility. The three targets below suit a typical appointment-based small business.

# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
BUSINESS_HOME_URL=https://www.example.com/
BUSINESS_BOOKING_URL=https://www.example.com/book
BUSINESS_CONTACT_URL=https://www.example.com/contact

Map those variables into constructor arguments with Symfony dependency injection:

# config/services.yaml
parameters:
  app.snapshot_targets:
    homepage: '%env(BUSINESS_HOME_URL)%'
    booking: '%env(BUSINESS_BOOKING_URL)%'
    contact: '%env(BUSINESS_CONTACT_URL)%'

services:
  _defaults:
    autowire: true
    autoconfigure: true
    bind:
      string $screenshotToken: '%env(SCREENSHOT_API_TOKEN)%'
      array $snapshotTargets: '%app.snapshot_targets%'
      string $snapshotDirectory: '%kernel.project_dir%/var/snapshots'

  App\:
    resource: '../src/'

The URLs are deployment-controlled rather than accepted through a public controller. That is an intentional security boundary: an unrestricted screenshot endpoint can become an expensive proxy for arbitrary URLs.

Build a defensive Screenshot API boundary

The client below validates configured URLs, applies bounded connection inactivity and total-duration limits, and retries only transient transport errors, HTTP 429, and server errors. Authentication and request-validation failures are returned immediately because retries cannot repair them.

Response-header names may evolve, so the application does not invent a fixed quota schema. It preserves standard cache headers, headers containing cache, and headers whose names identify quota or rate-limit metadata. It also checks both the declared media type and the PNG signature before trusting the body.

<?php
// src/Screenshot/CapturedScreenshot.php
namespace App\Screenshot;

final readonly class CapturedScreenshot
{
    public function __construct(
        public string $png,
        public array $cacheHeaders,
        public array $quotaHeaders,
        public int $attempts,
    ) {}
}

// src/Screenshot/CaptureFailed.php
namespace App\Screenshot;

final class CaptureFailed extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        public readonly ?int $status = null,
    ) {
        parent::__construct($message);
    }
}

// src/Screenshot/ScreenshotApiClient.php
namespace App\Screenshot;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ScreenshotApiClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly string $screenshotToken,
        private readonly LoggerInterface $logger,
    ) {}

    public function capture(string $url): CapturedScreenshot
    {
        $parts = parse_url($url);
        if (
            filter_var($url, FILTER_VALIDATE_URL) === false ||
            ($parts['scheme'] ?? null) !== 'https' ||
            empty($parts['host'])
        ) {
            throw new CaptureFailed('configuration', 'Target must be an HTTPS URL.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('GET', self::ENDPOINT, [
                    'query' => ['url' => $url],
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->screenshotToken,
                        'Accept' => 'image/png',
                    ],
                    'timeout' => 5.0,
                    'max_duration' => 35.0,
                ]);

                $status = $response->getStatusCode();
                $headers = $response->getHeaders(false);

                if ($status >= 200 && $status < 300) {
                    $body = $response->getContent(false);
                    $type = strtolower(trim(explode(
                        ';',
                        $headers['content-type'][0] ?? ''
                    )[0]));

                    if (
                        $type !== 'image/png' ||
                        strncmp($body, "\x89PNG\r\n\x1a\n", 8) !== 0
                    ) {
                        throw new CaptureFailed(
                            'invalid_response',
                            'The service returned a non-PNG response.',
                            $status
                        );
                    }

                    [$cache, $quota] = $this->classifyHeaders($headers);

                    return new CapturedScreenshot(
                        $body,
                        $cache,
                        $quota,
                        $attempt
                    );
                }

                if (in_array($status, [401, 403], true)) {
                    throw new CaptureFailed(
                        'authentication',
                        'The service rejected its token.',
                        $status
                    );
                }

                if ($status >= 400 && $status < 500 && $status !== 429) {
                    throw new CaptureFailed(
                        'request',
                        'The capture request was rejected.',
                        $status
                    );
                }

                if ($attempt === 3) {
                    throw new CaptureFailed(
                        $status === 429 ? 'quota' : 'upstream',
                        'The screenshot service remained unavailable.',
                        $status
                    );
                }

                $this->logger->warning('screenshot.retry', [
                    'attempt' => $attempt,
                    'status' => $status,
                    'target_host' => $parts['host'],
                ]);

                sleep($this->retryDelay($headers, $attempt));
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new CaptureFailed(
                        'transport',
                        'The screenshot service could not be reached.'
                    );
                }

                $this->logger->warning('screenshot.transport_retry', [
                    'attempt' => $attempt,
                    'target_host' => $parts['host'],
                    'exception' => $exception::class,
                ]);
                sleep($attempt === 1 ? 1 : 3);
            }
        }

        throw new CaptureFailed('internal', 'Capture attempts were exhausted.');
    }

    private function classifyHeaders(array $headers): array
    {
        $cache = [];
        $quota = [];

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

            if (
                str_contains($name, 'cache') ||
                in_array($name, ['age', 'etag', 'expires', 'vary'], true)
            ) {
                $cache[$name] = $values;
            }

            $compact = str_replace('-', '', $name);
            if (str_contains($name, 'quota') || str_contains($compact, 'ratelimit')) {
                $quota[$name] = $values;
            }
        }

        return [$cache, $quota];
    }

    private function retryDelay(array $headers, int $attempt): int
    {
        $value = $headers['retry-after'][0] ?? null;

        if (is_string($value) && ctype_digit($value)) {
            return max(1, min(30, (int) $value));
        }

        if (is_string($value) && ($time = strtotime($value)) !== false) {
            return max(1, min(30, $time - time()));
        }

        return $attempt === 1 ? 1 : 3;
    }
}

The thirty-second cap prevents a malicious or mistaken Retry-After value from tying up the weekly process indefinitely. A 429 still becomes a structured quota failure if all attempts are exhausted, allowing operations tooling to distinguish it from invalid credentials or malformed configuration.

Store one atomic snapshot per week

The store uses an ISO week identifier such as 2026-W34. Re-running the command during the same week replaces that week’s files instead of creating duplicates. Symfony’s filesystem component writes each file through a temporary file and rename, while restrictive permissions keep the archive private by default.

<?php
// src/Screenshot/SnapshotStore.php
namespace App\Screenshot;

use Symfony\Component\Filesystem\Filesystem;

final class SnapshotStore
{
    public function __construct(
        private readonly string $snapshotDirectory,
        private readonly Filesystem $filesystem,
    ) {}

    public function save(
        string $name,
        string $url,
        CapturedScreenshot $capture,
        \DateTimeImmutable $capturedAt,
    ): string {
        if (preg_match('/^[a-z0-9-]+$/', $name) !== 1) {
            throw new \InvalidArgumentException('Invalid snapshot name.');
        }

        $week = $capturedAt->format('o-\WW');
        $base = rtrim($this->snapshotDirectory, '/').'/'.$name.'/'.$week;

        $this->filesystem->mkdir(dirname($base), 0700);
        $this->filesystem->dumpFile($base.'.png', $capture->png);
        $this->filesystem->dumpFile($base.'.json', json_encode([
            'captured_at' => $capturedAt->format(DATE_ATOM),
            'url_sha256' => hash('sha256', $url),
            'attempts' => $capture->attempts,
            'cache_headers' => $capture->cacheHeaders,
            'quota_headers' => $capture->quotaHeaders,
        ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
        $this->filesystem->chmod([$base.'.png', $base.'.json'], 0600);

        return $base.'.png';
    }
}

Only a hash of the target URL enters metadata. That avoids preserving sensitive query strings while still making configuration changes detectable.

Run captures through a Symfony command

<?php
// src/Command/CaptureWeeklySnapshotsCommand.php
namespace App\Command;

use App\Screenshot\CaptureFailed;
use App\Screenshot\ScreenshotApiClient;
use App\Screenshot\SnapshotStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
    name: 'app:snapshots:capture',
    description: 'Capture this week’s configured business pages.'
)]
final class CaptureWeeklySnapshotsCommand extends Command
{
    public function __construct(
        private readonly ScreenshotApiClient $api,
        private readonly SnapshotStore $store,
        private readonly array $snapshotTargets,
        private readonly LoggerInterface $logger,
    ) {
        parent::__construct();
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output
    ): int {
        $failed = false;
        $capturedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));

        foreach ($this->snapshotTargets as $name => $url) {
            try {
                $capture = $this->api->capture($url);
                $path = $this->store->save($name, $url, $capture, $capturedAt);

                $this->logger->info('snapshot.captured', [
                    'target' => $name,
                    'week' => $capturedAt->format('o-\WW'),
                    'attempts' => $capture->attempts,
                    'quota_headers' => $capture->quotaHeaders,
                ]);
                $output->writeln(sprintf('%s: %s', $name, $path));
            } catch (CaptureFailed $exception) {
                $failed = true;
                $this->logger->error('snapshot.failed', [
                    'target' => $name,
                    'kind' => $exception->kind,
                    'status' => $exception->status,
                ]);
                $output->writeln(sprintf(
                    '<error>%s: %s</error>',
                    $name,
                    $exception->getMessage()
                ));
            } catch (\Throwable $exception) {
                $failed = true;
                $this->logger->error('snapshot.storage_failed', [
                    'target' => $name,
                    'exception' => $exception::class,
                ]);
            }
        }

        return $failed ? Command::FAILURE : Command::SUCCESS;
    }
}

One failed page does not prevent the remaining pages from being captured, but the command returns a non-zero status if anything failed. That balance produces the most useful archive while still notifying the scheduler that intervention may be required.

Test the boundary without making network requests

MockHttpClient provides a deterministic transport. These tests prove that valid PNG data is mapped correctly and an authentication failure is not retried.

<?php
// tests/Screenshot/ScreenshotApiClientTest.php
namespace App\Tests\Screenshot;

use App\Screenshot\CaptureFailed;
use App\Screenshot\ScreenshotApiClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ScreenshotApiClientTest extends TestCase
{
    public function testItMapsPngAndServiceHeaders(): void
    {
        $png = "\x89PNG\r\n\x1a\npayload";
        $response = new MockResponse($png, [
            'http_code' => 200,
            'response_headers' => [
                'content-type: image/png',
                'cache-control: public, max-age=60',
                'x-quota-remaining: 9',
            ],
        ]);

        $api = new ScreenshotApiClient(
            new MockHttpClient($response),
            'test-token',
            new NullLogger()
        );

        $capture = $api->capture('https://www.example.com/');

        self::assertSame($png, $capture->png);
        self::assertArrayHasKey('cache-control', $capture->cacheHeaders);
        self::assertArrayHasKey('x-quota-remaining', $capture->quotaHeaders);
        self::assertSame(1, $capture->attempts);
        self::assertSame('GET', $response->getRequestMethod());
    }

    public function testAuthenticationFailureIsNotRetried(): void
    {
        $calls = 0;
        $transport = new MockHttpClient(
            function () use (&$calls): MockResponse {
                $calls++;
                return new MockResponse('denied', ['http_code' => 401]);
            }
        );

        $api = new ScreenshotApiClient(
            $transport,
            'invalid-token',
            new NullLogger()
        );

        try {
            $api->capture('https://www.example.com/');
            self::fail('Expected CaptureFailed.');
        } catch (CaptureFailed $exception) {
            self::assertSame('authentication', $exception->kind);
            self::assertSame(401, $exception->status);
        }

        self::assertSame(1, $calls);
    }
}
php bin/phpunit
php bin/console app:snapshots:capture -vv
find var/snapshots -type f -maxdepth 3 -print

Deploy the weekly schedule safely

Make var/snapshots persistent across releases; an ephemeral container filesystem would erase the history during deployment. Back it up according to the business’s retention needs, keep it outside the public web root, and decide explicitly how many years of images should remain.

On a traditional Linux host, this cron entry runs early every Monday. flock prevents overlapping executions from consuming duplicate quota. Ensure the cron environment receives SCREENSHOT_API_TOKEN and the three target variables through your deployment mechanism; do not add the token to the cron command itself.

17 3 * * 1 cd /srv/business-site && /usr/bin/flock -n var/weekly-snapshots.lock /usr/bin/php bin/console app:snapshots:capture --env=prod

Alert on a non-zero exit status and on repeated snapshot.failed records. Useful dimensions are the target name, failure kind, HTTP status, attempt count, and reported quota headers. Never log the token, response body, or full target URL.

Common production failures

  • HTTP 401 or 403: the token is missing, incorrect, revoked, or belongs to the wrong service. Replace the environment secret and verify activation.
  • HTTP 429: the service is limiting requests or the available quota has been reached. Inspect preserved quota metadata and the selected plan instead of creating an unlimited retry loop.
  • Invalid response: an intermediary or upstream failure returned something other than PNG data. Retain the structured error, but do not save the body as a screenshot.
  • Transport failure: verify DNS, outbound HTTPS policy, and proxy configuration. The bounded retry handles short interruptions, not persistent network policy errors.
  • Empty history after deployment: confirm that var/snapshots is writable and persistent, and that the scheduler starts in the intended release directory.

Final verification checklist

  • The token comes from the documentation page’s Service token panel and is supplied through environment configuration.
  • No credential appears in source control, fixtures, process arguments, logs, or stored metadata.
  • Each configured URL uses HTTPS and is controlled by deployment configuration.
  • A manual command creates both PNG and JSON files for every target.
  • The PNG opens correctly and its corresponding metadata contains cache and quota headers when the service provides them.
  • Tests pass without contacting the external service.
  • The scheduler preserves a non-zero exit status, prevents overlap, and writes into persistent storage.
  • Monitoring distinguishes authentication, request, quota, upstream, transport, and storage failures.

The value of this system is not merely that it takes screenshots. It turns a website’s visual state into an accountable weekly record, while keeping browsers, credentials, retries, and failure handling out of the business owner’s way. Months later, when someone asks when a page changed, the answer is no longer a guess hidden in a deployment log. It is a picture.

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.