Tutorials

Symfony: Automate Client Website Before/After Snapshots for Seamless Updates

Symfony: Automate Client Website Before/After Snapshots for Seamless Updates

A website update can pass every automated test and still ship a visual surprise: a missing hero image, a collapsed navigation bar, or a mobile breakpoint that no longer behaves. Before-and-after screenshots give developers and clients a durable visual record of each release without requiring a team to maintain browsers, drivers, and Chromium containers.

This tutorial builds that workflow as a production Symfony command. It captures several public pages before deployment, repeats the capture afterward, validates every PNG, records relevant cache and quota headers, and stores each set atomically under a release identifier. The command is suitable for a developer laptop, CI pipeline, or deployment host.

Get access to the Screenshot API

Register through the registration page, or use the sign-in page if you already have an account.

  1. Open the Screenshot API service page.
  2. Choose an available Free, Plus, or Pro plan and complete its activation.
  3. Open the official documentation.
  4. Find the Service token panel and copy the service-scoped token.

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. A Bearer token keeps the credential out of URLs, browser history, proxy query logs, and most request metrics, so that is the method used here.

Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update every deployment environment before removing the old configuration from your secret-management process.

Confirm the endpoint before writing application code

The exact request is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, with the required url query parameter. A successful response contains an image/png body plus cache and quota-related response headers.

curl --fail-with-body --silent --show-error \
  --get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --data-urlencode 'url=https://client.example/' \
  --output homepage.png

Open homepage.png and confirm that it is the expected page before continuing. Do not commit that test image if it contains private client material.

For local Symfony development, place the token in .env.local, which should remain uncommitted. In production, inject the same variable through the hosting platform or secret manager instead of building it into the container image.

# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

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

    App\Screenshot\ScreenshotClient:
        arguments:
            $screenshotApiToken: '%env(string:SCREENSHOT_API_TOKEN)%'

Architecture: a small boundary with strong guarantees

The project deliberately uses a console command instead of an HTTP controller. Captures belong in a controlled release process, not behind a public route that could consume quota or turn arbitrary user input into remote browsing work.

The implementation has three parts:

  • ScreenshotClient owns authentication, timeouts, retries, response validation, and header extraction.
  • ScreenshotCapture maps the remote response into a domain value containing PNG bytes and operational metadata.
  • CaptureSnapshotsCommand captures a complete named set into a staging directory, then promotes it atomically.

Install the first-party components if the application does not already contain them:

composer require symfony/http-client symfony/console symfony/filesystem
composer require --dev symfony/phpunit-bridge

The resulting files are src/Screenshot/ScreenshotCapture.php, src/Screenshot/ScreenshotFailure.php, src/Screenshot/ScreenshotClient.php, src/Command/CaptureSnapshotsCommand.php, and tests/Screenshot/ScreenshotClientTest.php.

Map and validate the API response

Remote binary data should not leak throughout the application as an unstructured response object. The following value and exception classes make success and failure explicit.

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

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

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

final class ScreenshotFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly int $status = 0,
        public readonly array $operationalHeaders = [],
        ?\Throwable $previous = null,
    ) {
        parent::__construct(
            sprintf('Screenshot capture failed: %s (HTTP %d)', $kind, $status),
            0,
            $previous,
        );
    }
}

The client bounds both connection and overall response time. It retries transport failures and server-side 5xx responses twice with short exponential backoff. It does not blindly retry malformed requests, rejected credentials, or HTTP 429 responses: those require configuration, token, quota, or scheduling decisions rather than another immediate request.

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

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

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

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly string $screenshotApiToken,
        private readonly LoggerInterface $logger,
        private readonly ?\Closure $sleep = null,
    ) {}

    public function capture(string $url): ScreenshotCapture
    {
        if (!filter_var($url, FILTER_VALIDATE_URL)
            || !in_array(parse_url($url, PHP_URL_SCHEME), ['https', 'http'], true)
        ) {
            throw new \InvalidArgumentException('A valid HTTP(S) URL is required.');
        }

        for ($attempt = 1; $attempt <= 3; ++$attempt) {
            try {
                $response = $this->http->request('GET', self::ENDPOINT, [
                    'auth_bearer' => $this->screenshotApiToken,
                    'query' => ['url' => $url],
                    'timeout' => 5.0,
                    'max_duration' => 30.0,
                ]);

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

                if ($status >= 500 && $attempt < 3) {
                    $this->backoff($attempt);
                    continue;
                }

                if ($status === 429) {
                    throw new ScreenshotFailure('quota_or_rate_limit', $status, $headers);
                }

                if ($status === 401 || $status === 403) {
                    throw new ScreenshotFailure('authentication', $status, $headers);
                }

                if ($status !== 200) {
                    throw new ScreenshotFailure('unexpected_status', $status, $headers);
                }

                $contentType = strtolower($response->getHeaders(false)['content-type'][0] ?? '');
                $png = $response->getContent(false);

                if (!str_starts_with($contentType, 'image/png')
                    || !str_starts_with($png, "\x89PNG\r\n\x1a\n")
                ) {
                    throw new ScreenshotFailure('invalid_png', $status, $headers);
                }

                $this->logger->info('Screenshot captured', [
                    'target_host' => parse_url($url, PHP_URL_HOST),
                    'target_hash' => hash('sha256', $url),
                    'bytes' => strlen($png),
                    'attempt' => $attempt,
                ]);

                return new ScreenshotCapture($png, $status, $headers);
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotFailure(
                        'transport',
                        previous: $exception,
                    );
                }

                $this->backoff($attempt);
            }
        }

        throw new ScreenshotFailure('retry_exhausted');
    }

    private function backoff(int $attempt): void
    {
        $microseconds = 200_000 * (2 ** ($attempt - 1));
        ($this->sleep ?? static fn (int $delay) => usleep($delay))($microseconds);
    }

    private function operationalHeaders(array $headers): array
    {
        return array_filter(
            $headers,
            static fn (string $name): bool =>
                preg_match('/cache|quota|rate-limit|retry-after/i', $name) === 1,
            ARRAY_FILTER_USE_KEY,
        );
    }
}

The header mapper intentionally does not assume undocumented header names. It retains returned headers whose names identify cache, quota, rate-limit, or retry timing information. This preserves useful evidence while keeping application behavior independent of a speculative response schema.

Capture an atomic before-or-after set

The command accepts a phase, a release identifier, and one or more URLs. It writes into a temporary directory first. If any page fails, the temporary set is removed; users never mistake an incomplete run for a valid comparison.

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

use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotFailure;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Filesystem;

#[AsCommand(
    name: 'app:snapshots:capture',
    description: 'Capture an atomic before-or-after website snapshot set.',
)]
final class CaptureSnapshotsCommand extends Command
{
    public function __construct(
        private readonly ScreenshotClient $client,
        private readonly Filesystem $filesystem,
        private readonly string $projectDir,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('phase', InputArgument::REQUIRED, 'before or after')
            ->addArgument('release', InputArgument::REQUIRED, 'Safe release identifier')
            ->addArgument(
                'urls',
                InputArgument::IS_ARRAY | InputArgument::REQUIRED,
                'Public URLs to capture',
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $phase = (string) $input->getArgument('phase');
        $release = (string) $input->getArgument('release');
        $urls = $input->getArgument('urls');

        if (!in_array($phase, ['before', 'after'], true)) {
            $output->writeln('<error>Phase must be before or after.</error>');
            return Command::INVALID;
        }

        if (preg_match('/\A[a-zA-Z0-9._-]+\z/', $release) !== 1) {
            $output->writeln('<error>Release contains unsafe characters.</error>');
            return Command::INVALID;
        }

        $root = $this->projectDir.'/var/snapshots/'.$release;
        $target = $root.'/'.$phase;
        $staging = $root.'/'.sprintf('.%s-%s', $phase, bin2hex(random_bytes(6)));

        if (is_dir($target)) {
            $output->writeln('<error>This snapshot set already exists.</error>');
            return Command::FAILURE;
        }

        $this->filesystem->mkdir($staging);
        $manifest = [];

        try {
            foreach ($urls as $url) {
                $capture = $this->client->capture($url);
                $name = $this->filename($url);

                $this->filesystem->dumpFile($staging.'/'.$name.'.png', $capture->png);
                $manifest[] = [
                    'url' => $url,
                    'file' => $name.'.png',
                    'bytes' => strlen($capture->png),
                    'http_status' => $capture->status,
                    'operational_headers' => $capture->operationalHeaders,
                ];
            }

            $this->filesystem->dumpFile(
                $staging.'/manifest.json',
                json_encode(
                    $manifest,
                    JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
                )."\n",
            );
            $this->filesystem->rename($staging, $target);
        } catch (ScreenshotFailure | \Throwable $exception) {
            $this->filesystem->remove($staging);
            $output->writeln('<error>'.$exception->getMessage().'</error>');
            return Command::FAILURE;
        }

        $output->writeln(sprintf(
            '<info>Captured %d %s snapshots in %s</info>',
            count($manifest),
            $phase,
            $target,
        ));

        return Command::SUCCESS;
    }

    private function filename(string $url): string
    {
        $source = (parse_url($url, PHP_URL_HOST) ?: 'page')
            .'-'.(parse_url($url, PHP_URL_PATH) ?: 'home');
        $slug = trim((string) preg_replace('/[^a-z0-9]+/i', '-', $source), '-');

        return substr($slug, 0, 80).'-'.substr(hash('sha256', $url), 0, 10);
    }
}

Symfony can autowire $projectDir from its standard project-directory parameter when explicitly bound:

# config/services.yaml
    App\Command\CaptureSnapshotsCommand:
        arguments:
            $projectDir: '%kernel.project_dir%'

Automate it around deployment

Capture the same ordered URL set on both sides of the deployment. The pre-deployment command must run while the old version is still serving traffic; the post-deployment command should run only after the application and its public assets are healthy.

set -euo pipefail

RELEASE_ID="${CI_COMMIT_SHA:-manual-$(date -u +%Y%m%dT%H%M%SZ)}"
SNAPSHOT_URLS=(
  'https://client.example/'
  'https://client.example/services'
  'https://client.example/contact'
)

php bin/console app:snapshots:capture before "$RELEASE_ID" "${SNAPSHOT_URLS[@]}"

# Run the application's existing deployment and health-check steps here.

php bin/console app:snapshots:capture after "$RELEASE_ID" "${SNAPSHOT_URLS[@]}"

Archive var/snapshots/$RELEASE_ID as a private CI artifact or copy it to access-controlled object storage. Do not place snapshots under public/: even public pages may reveal release timing, personalized content, preview banners, or customer information.

Test the boundary without making network calls

MockHttpClient gives the test a deterministic transport. The first test proves binary and metadata mapping; the second proves that authentication failures are returned immediately rather than retried.

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

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

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

        $client = new ScreenshotClient(
            $http,
            'test-token',
            new NullLogger(),
            static fn (int $microseconds) => null,
        );

        $capture = $client->capture('https://client.example/');

        self::assertSame($png, $capture->png);
        self::assertSame(200, $capture->status);
        self::assertArrayHasKey('cache-control', $capture->operationalHeaders);
        self::assertArrayHasKey('x-test-quota', $capture->operationalHeaders);
    }

    public function testItDoesNotRetryAuthenticationFailure(): void
    {
        $requests = 0;
        $http = new MockHttpClient(
            function () use (&$requests): MockResponse {
                ++$requests;
                return new MockResponse('rejected', ['http_code' => 401]);
            }
        );

        $client = new ScreenshotClient(
            $http,
            'test-token',
            new NullLogger(),
            static fn (int $microseconds) => null,
        );

        try {
            $client->capture('https://client.example/');
            self::fail('Expected ScreenshotFailure.');
        } catch (ScreenshotFailure $failure) {
            self::assertSame('authentication', $failure->kind);
            self::assertSame(1, $requests);
        }
    }
}
php bin/phpunit
php bin/console app:snapshots:capture before local-check \
  'https://client.example/' \
  'https://client.example/contact'

Security, observability, and operational failures

Restrict capture targets to an approved hostname list if URLs can originate anywhere except trusted deployment configuration. This protects quota, prevents accidental capture of sensitive destinations, and makes the artifact set predictable. Keep preview credentials out of target URLs; query strings can appear in manifests and process listings.

Logs should include the target host, a URL hash, byte count, attempt number, phase, and release identifier. Never log the token, authorization headers, PNG body, or an unrestricted target URL. Alert separately on authentication failures, rate or quota failures, retry exhaustion, invalid PNG responses, and incomplete post-deployment capture runs.

An HTTP 429 result should stop the set and preserve its operational headers for diagnosis. Use returned retry timing information when scheduling a later run, but cap automated delays to suit the deployment window. A 401 or 403 usually indicates a missing, revoked, or incorrectly deployed service token. An invalid PNG often means the service returned an unexpected response despite its status, so rejecting it is safer than saving a corrupt artifact.

For multiple CI workers, make the release identifier unique and allow only one snapshot job per release. Define artifact retention deliberately: snapshots are useful for release review, but indefinite storage increases cost and privacy exposure.

Final verification checklist

  • The service plan is active and the token came from the documentation page’s Service token panel.
  • SCREENSHOT_API_TOKEN is injected at runtime and absent from source control, logs, fixtures, and images.
  • The minimal request returns a valid PNG for an approved public URL.
  • The command creates complete before and after directories with matching filenames and manifests.
  • Transport and 5xx failures receive only bounded retries; authentication, validation, and quota failures do not loop blindly.
  • Cache and quota-related response headers are retained without assuming undocumented names.
  • Tests pass with MockHttpClient, and no test reaches the live service.
  • Snapshot artifacts are access-controlled, retained for a defined period, and excluded from the public web root.

The most valuable release evidence is evidence people will actually create. By reducing visual capture to two predictable Symfony commands, every update can carry its own before-and-after record. The browser infrastructure disappears from your maintenance list, while the client conversation becomes concrete: not “the deployment probably changed only these pages,” but “here is exactly what the site looked like on both sides of the release.”

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.