Symfony: Auto-Snapshot Client Websites Pre- and Post-Update
A website update can be technically successful and still be visually wrong. A missing font, an unexpected cookie banner, a broken hero image, or a CSS bundle served from an old cache may escape health checks while remaining obvious to a client.
This tutorial builds a production-oriented Symfony workflow that captures a PNG immediately before deployment and another immediately afterward. The snapshots are grouped by release, accompanied by cache and quota metadata, and suitable for retention as CI artifacts. The application delegates browser rendering to the Screenshot API, so it does not need to install, patch, or operate Chromium.
Get access before writing integration code
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Screenshot API service page.
- Choose the available Free, Plus, or Pro plan and complete its activation.
- Open the official Screenshot API documentation.
- Find the Service token panel and copy the service-scoped token.
The service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. This implementation uses a Bearer token because credentials in query strings are more likely to appear in access logs, browser history, and monitoring systems.
Regenerating the service token revokes the previously active token. Treat rotation as a coordinated deployment: install the new value everywhere that runs the capture command, verify it, and retire any process still configured with the old value.
Confirm the endpoint with one minimal request
The exact operation is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Its required url query parameter identifies the page to render. A successful response contains an image/png body plus response headers describing caching and quota behavior.
curl --fail-with-body --silent --show-error \
--get \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--data-urlencode 'url=https://client.example/' \
--dump-header /tmp/screenshot.headers \
--output /tmp/screenshot.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
file /tmp/screenshot.png
Keep the dumped headers during this check. Header availability and naming should be read from the real response and official documentation rather than guessed in application code.
Project shape and operational choices
The example targets PHP 8.3 and a current Symfony application with Console, HttpClient, and a PSR-compatible logger. Install the required first-party components and test support:
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack
mkdir -p var/snapshots
The relevant files are deliberately few:
src/Screenshot/ScreenshotCapture.phpmaps a successful remote response into the domain.src/Screenshot/ScreenshotException.phpexposes stable failure categories.src/Screenshot/ScreenshotClient.phpowns authentication, timeouts, retries, PNG validation, and response-header mapping.src/Command/CaptureSnapshotCommand.phpvalidates deployment input and writes release artifacts atomically.tests/Screenshot/ScreenshotClientTest.phpexercises the boundary with a deterministic fake transport.
A synchronous console command is preferable here to Messenger. The before-capture must finish before deployment begins, and the after-capture must run only after the new site passes its readiness check. A background queue would weaken that ordering while adding little value.
Configure secrets and dependency injection
Put the local credential in .env.local, which should remain outside version control:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SNAPSHOT_ALLOWED_HOST=client.example
Production should inject the same variables through the deployment platform’s secret facility. Do not place a real token in .env, source control, fixtures, command arguments, logs, or stored snapshot metadata.
Add the scalar bindings to config/services.yaml:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Screenshot\ScreenshotClient:
arguments:
$token: '%env(SCREENSHOT_API_TOKEN)%'
App\Command\CaptureSnapshotCommand:
arguments:
$projectDir: '%kernel.project_dir%'
$allowedHost: '%env(SNAPSHOT_ALLOWED_HOST)%'
Build a defensive Screenshot API boundary
The application should not pass raw HTTP responses through the rest of the system. Map successful responses into an immutable object and failures into categories that a command, test, or monitoring rule can understand.
<?php
// src/Screenshot/ScreenshotCapture.php
namespace App\Screenshot;
final readonly class ScreenshotCapture
{
/**
* @param array<string, list<string>> $serviceHeaders
*/
public function __construct(
public string $png,
public array $serviceHeaders,
) {}
}
// src/Screenshot/ScreenshotException.php
namespace App\Screenshot;
final class ScreenshotException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
The client allows three attempts for transient transport and server failures. Authentication and validation failures are never retried. A 429 response is retried only when it supplies a short numeric Retry-After; an exhausted or long quota delay should be surfaced to the deployment system instead of making the job sleep indefinitely.
<?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';
/** @var \Closure(int): void */
private \Closure $sleep;
public function __construct(
private readonly HttpClientInterface $http,
private readonly LoggerInterface $logger,
private readonly string $token,
?\Closure $sleep = null,
) {
$this->sleep = $sleep ?? static fn (int $microseconds) =>
usleep($microseconds);
}
public function capture(string $url): ScreenshotCapture
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('GET', self::ENDPOINT, [
'auth_bearer' => $this->token,
'query' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 30.0,
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
if ($status === 429) {
$retryAfter = $headers['retry-after'][0] ?? null;
if (
$attempt < 3
&& is_string($retryAfter)
&& ctype_digit($retryAfter)
&& (int) $retryAfter <= 2
) {
($this->sleep)((int) $retryAfter * 1_000_000);
continue;
}
throw new ScreenshotException(
'quota',
'Screenshot quota or rate limit prevented capture.'
);
}
if ($status >= 500 && $attempt < 3) {
($this->sleep)($attempt === 1 ? 250_000 : 750_000);
continue;
}
if ($status === 401 || $status === 403) {
throw new ScreenshotException(
'authentication',
'Screenshot API authentication was rejected.'
);
}
if ($status < 200 || $status >= 300) {
throw new ScreenshotException(
'request',
sprintf('Screenshot API returned HTTP %d.', $status)
);
}
$body = $response->getContent(false);
$contentType = strtolower(
explode(';', $headers['content-type'][0] ?? '')[0]
);
if (
$contentType !== 'image/png'
|| !str_starts_with($body, "\x89PNG\r\n\x1a\n")
) {
throw new ScreenshotException(
'invalid_response',
'Screenshot API did not return a valid PNG response.'
);
}
$metadata = array_filter(
$headers,
static fn (string $name): bool =>
str_contains($name, 'cache')
|| str_contains($name, 'quota')
|| str_contains($name, 'rate'),
ARRAY_FILTER_USE_KEY
);
return new ScreenshotCapture($body, $metadata);
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new ScreenshotException(
'transport',
'Screenshot API could not be reached.',
$exception
);
}
$this->logger->warning('Screenshot transport retry.', [
'attempt' => $attempt,
'exception_class' => $exception::class,
]);
($this->sleep)($attempt === 1 ? 250_000 : 750_000);
}
}
throw new ScreenshotException('internal', 'Capture loop ended unexpectedly.');
}
}
Metadata selection is intentionally defensive: it retains headers whose names indicate cache, quota, or rate-limit information without inventing a vendor-specific header contract. The token and response body are never logged.
Create the deployment command
The command accepts a release identifier, a phase, and a public HTTPS URL. It restricts captures to one configured client host, preventing an accidentally exposed command from spending quota on arbitrary targets. Release identifiers are also constrained before becoming directory names.
<?php
// src/Command/CaptureSnapshotCommand.php
namespace App\Command;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
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\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:snapshots:capture')]
final class CaptureSnapshotCommand extends Command
{
public function __construct(
private readonly ScreenshotClient $screenshots,
private readonly LoggerInterface $logger,
private readonly string $projectDir,
private readonly string $allowedHost,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('release', null, InputOption::VALUE_REQUIRED)
->addOption('phase', null, InputOption::VALUE_REQUIRED)
->addOption('url', null, InputOption::VALUE_REQUIRED);
}
protected function execute(
InputInterface $input,
OutputInterface $output
): int {
$release = (string) $input->getOption('release');
$phase = (string) $input->getOption('phase');
$url = (string) $input->getOption('url');
$host = parse_url($url, PHP_URL_HOST);
$scheme = parse_url($url, PHP_URL_SCHEME);
if (!preg_match('/\A[a-zA-Z0-9._-]{1,80}\z/', $release)) {
$output->writeln('<error>Invalid release identifier.</error>');
return Command::INVALID;
}
if (!in_array($phase, ['before', 'after'], true)) {
$output->writeln('<error>Phase must be before or after.</error>');
return Command::INVALID;
}
if ($scheme !== 'https' || $host !== $this->allowedHost) {
$output->writeln('<error>URL must use the allowed HTTPS host.</error>');
return Command::INVALID;
}
try {
$capture = $this->screenshots->capture($url);
$directory = $this->projectDir.'/var/snapshots/'.$release;
if (!is_dir($directory) && !mkdir($directory, 0770, true)) {
throw new \RuntimeException('Could not create snapshot directory.');
}
$this->atomicWrite($directory.'/'.$phase.'.png', $capture->png);
$this->atomicWrite(
$directory.'/'.$phase.'.json',
json_encode([
'release' => $release,
'phase' => $phase,
'captured_at' => gmdate(DATE_ATOM),
'service_headers' => $capture->serviceHeaders,
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)
);
$this->logger->info('Website snapshot captured.', [
'release' => $release,
'phase' => $phase,
'host' => $host,
]);
$output->writeln('Snapshot saved for '.$release.' '.$phase.'.');
return Command::SUCCESS;
} catch (ScreenshotException $exception) {
$this->logger->error('Website snapshot failed.', [
'release' => $release,
'phase' => $phase,
'kind' => $exception->kind,
]);
$output->writeln('<error>Capture failed: '.$exception->kind.'</error>');
return Command::FAILURE;
}
}
private function atomicWrite(string $path, string $contents): void
{
$temporary = tempnam(dirname($path), 'snapshot-');
if ($temporary === false || file_put_contents($temporary, $contents) === false) {
throw new \RuntimeException('Could not write temporary snapshot.');
}
if (!rename($temporary, $path)) {
@unlink($temporary);
throw new \RuntimeException('Could not publish snapshot.');
}
}
}
Test without calling the real service
MockHttpClient makes response order and content deterministic. Injecting a no-op sleeper keeps retry tests fast.
<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
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 testMapsPngAndServiceHeaders(): void
{
$png = "\x89PNG\r\n\x1a\npayload";
$http = new MockHttpClient([
new MockResponse($png, [
'http_code' => 200,
'response_headers' => [
'content-type: image/png',
'x-cache: HIT',
'x-quota-remaining: 9',
],
]),
]);
$client = new ScreenshotClient(
$http,
new NullLogger(),
'test-token',
static fn (int $microseconds) => null
);
$result = $client->capture('https://client.example/');
self::assertSame($png, $result->png);
self::assertArrayHasKey('x-cache', $result->serviceHeaders);
self::assertArrayHasKey('x-quota-remaining', $result->serviceHeaders);
}
public function testAuthenticationFailureIsStructured(): void
{
$http = new MockHttpClient([
new MockResponse('Unauthorized', ['http_code' => 401]),
]);
$client = new ScreenshotClient(
$http,
new NullLogger(),
'invalid-token',
static fn (int $microseconds) => null
);
try {
$client->capture('https://client.example/');
self::fail('Expected authentication failure.');
} catch (ScreenshotException $exception) {
self::assertSame('authentication', $exception->kind);
self::assertSame(1, $http->getRequestsCount());
}
}
}
The illustrative cache and quota names above exist only in the fake response; production code does not depend on them. Add tests for a non-PNG body, a transport exception, a retryable 500, and a 429 with and without an acceptable Retry-After.
Wire it into deployment
Run the first command before changing the live release. After deployment, wait for the site’s normal readiness check to pass, then run the second:
php bin/console app:snapshots:capture \
--release=2026-10-12.1 \
--phase=before \
--url='https://client.example/?visual_check=2026-10-12.1-before'
# Run the existing deployment and readiness check here.
php bin/console app:snapshots:capture \
--release=2026-10-12.1 \
--phase=after \
--url='https://client.example/?visual_check=2026-10-12.1-after'
Because the service captures cached screenshots, distinct harmless target URLs help avoid confusing a previous capture with the new release. Confirm that the client application ignores the visual_check parameter and that analytics excludes it. If query parameters affect rendering, use stable versioned preview URLs instead.
A failed before-capture should normally stop deployment: there is no valid baseline. An after-capture failure should mark the release job unsuccessful and preserve the deployed state for investigation; it should not automatically roll back a healthy site merely because visual evidence could not be collected.
Security, observability, and deployment details
- Restrict who can execute the command and keep the allowed host configuration narrow.
- Never accept arbitrary snapshot URLs from a public controller. That invites quota abuse and may expose unintended pages to the remote capture service.
- Log release, phase, host, failure kind, and retry attempt, but not tokens, full URLs with sensitive parameters, or PNG bodies.
- Alert on repeated
authentication,quota,transport, andinvalid_responsefailures separately; they demand different remedies. - Ensure
var/snapshotsis writable. In ephemeral containers, publish the release directory as a protected CI artifact before the job ends. - Apply an explicit retention policy because PNGs and their sidecars accumulate over time.
Common failures and final verification
A 401 or 403 usually means the token is absent, malformed, revoked, or not the service token expected by this integration. A 429 means capture should wait according to a reasonable server hint or fail clearly for quota review. A timeout may indicate an unavailable API or a target page that renders too slowly. An apparently unchanged after-image may be a cache effect, an incomplete deployment, or a target URL whose query string was normalized upstream.
Before calling the workflow complete, verify all of the following:
- The minimal request returns a PNG and observable cache and quota headers.
- The real token exists only in environment-backed secret configuration.
- The command rejects non-HTTPS URLs, unapproved hosts, invalid phases, and unsafe release names.
- Authentication and validation errors are not retried.
- Transient retries are bounded by attempt count, backoff, and total request duration.
- Both PNG files open correctly and their JSON sidecars contain no credential.
- The deployment waits for the before-capture and site readiness before taking the after-capture.
- Snapshot artifacts survive the deployment runner long enough for review.
The useful outcome is not merely two image files. It is a durable visual boundary around every client update: one trustworthy record of what was present, one record of what changed, and a failure model that behaves predictably when the network, credentials, quota, or deployment does not.