Symfony Snapshot Automation: Pre-Update Website Visual Audits with Screenshot API
A website update can pass unit tests, return healthy HTTP responses, and still ship a broken hero image, missing font, or misplaced checkout button. Those defects live in the rendered page, so a production release deserves evidence from the rendered page.
This tutorial builds a Symfony automation that captures one PNG before deployment and another afterward. Each image is stored with a SHA-256 digest plus cache and quota metadata returned by the Screenshot API. The deployment pipeline receives a non-zero exit code when capture fails, while developers avoid maintaining Chromium, browser drivers, and screenshot workers.
Get access to the Screenshot API
Register at https://ai.mihajlo.mk/register, or sign in through https://ai.mihajlo.mk/login.
Open the Screenshot API service page, choose the available Free, Plus, or Pro plan, and complete its activation. Then visit the official Screenshot API documentation. Find the Service token panel and copy the service-scoped token shown there.
The service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use a Bearer token because credentials in query strings are more likely to appear in proxy and access logs.
Regenerating the service token revokes the previously active token. Treat rotation as a coordinated deployment: update the secret in the deployment environment and promptly restart or redeploy every process that uses it.
Confirm the exact endpoint
The capture operation is:
GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Required query parameter: url
Successful body: image/png
Make one minimal request before writing application code. The header file lets you inspect the service’s current cache and quota headers without printing the binary response:
export SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
curl --fail-with-body --silent --show-error \
--get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
--header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
--data-urlencode 'url=https://www.example.com/' \
--dump-header /tmp/screenshot.headers \
--output /tmp/screenshot.png
Once that succeeds, put the credential in an environment-backed secret. For local Symfony development, use the uncommitted .env.local file:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_AUDIT_HOSTS=www.example.com,preview.example.com
Set the same variables through your production secret manager or hosting platform. Do not commit .env.local, a real token, captured private pages, or header dumps.
Choose a deliberately small architecture
A synchronous console command is a better fit than Messenger here. A deployment needs an immediate success or failure signal, and adding a queue would separate that signal from the pipeline which must act on it.
The resulting project has four responsibilities:
ScreenshotClientowns authentication, timeouts, retries, PNG validation, and response-header mapping.ScreenshotCaptureis the validated domain result.SnapshotStorewrites PNG and JSON artifacts atomically.WebsiteSnapshotCommandvalidates deployment input and returns a useful process exit code.
Install the first-party Symfony components used below:
composer require symfony/http-client symfony/console symfony/filesystem symfony/monolog-bundle
composer require --dev symfony/test-pack
The relevant files will be src/Screenshot/ScreenshotCapture.php, src/Screenshot/ScreenshotApiException.php, src/Screenshot/ScreenshotClient.php, src/Screenshot/SnapshotStore.php, src/Command/WebsiteSnapshotCommand.php, and tests/Screenshot/ScreenshotClientTest.php.
Configure dependency injection
Keep the endpoint in configuration, but not because it is expected to vary casually. Having one authoritative value makes tests and reviews clearer.
# config/services.yaml
parameters:
screenshot.endpoint: 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$screenshotEndpoint: '%screenshot.endpoint%'
$screenshotToken: '%env(SCREENSHOT_API_TOKEN)%'
$snapshotDirectory: '%kernel.project_dir%/var/audits'
$allowedHosts: '%env(WEBSITE_AUDIT_HOSTS)%'
App\:
resource: '../src/'
Build a defensive API boundary
The API returns bytes rather than JSON. That makes validation especially important: an upstream HTML error page must never be accepted as a screenshot merely because an intermediary returned status 200.
<?php
// src/Screenshot/ScreenshotCapture.php
namespace App\Screenshot;
final readonly class ScreenshotCapture
{
public function __construct(
public string $png,
public array $operationalHeaders,
) {}
}
// src/Screenshot/ScreenshotApiException.php
namespace App\Screenshot;
final class ScreenshotApiException extends \RuntimeException
{
public function __construct(
public readonly string $category,
public readonly ?int $status = null,
?\Throwable $previous = null,
) {
parent::__construct('Screenshot capture failed: '.$category, 0, $previous);
}
}
// 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 \Closure $sleep;
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $screenshotEndpoint,
private string $screenshotToken,
?callable $sleep = null,
) {
$this->sleep = $sleep
? \Closure::fromCallable($sleep)
: static fn (int $seconds) => sleep($seconds);
}
public function capture(string $url): ScreenshotCapture
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('GET', $this->screenshotEndpoint, [
'headers' => [
'Authorization' => 'Bearer '.$this->screenshotToken,
'Accept' => 'image/png',
],
'query' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 45.0,
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
if (($status === 429 || $status >= 500) && $attempt < 3) {
$response->cancel();
($this->sleep)($this->retryDelay($headers, $attempt));
continue;
}
if ($status === 401 || $status === 403) {
throw new ScreenshotApiException('authentication', $status);
}
if ($status === 429) {
throw new ScreenshotApiException('quota_or_rate_limit', $status);
}
if ($status >= 400 && $status < 500) {
throw new ScreenshotApiException('invalid_request', $status);
}
if ($status >= 500) {
throw new ScreenshotApiException('upstream_unavailable', $status);
}
$png = $response->getContent(false);
$type = strtolower($headers['content-type'][0] ?? '');
if (!str_starts_with($type, 'image/png')
|| !str_starts_with($png, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotApiException('invalid_png', $status);
}
if (strlen($png) > 15_000_000) {
throw new ScreenshotApiException('image_too_large', $status);
}
return new ScreenshotCapture(
$png,
$this->operationalHeaders($headers),
);
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new ScreenshotApiException(
'transport',
null,
$exception,
);
}
($this->sleep)(min(2 ** ($attempt - 1), 4));
}
}
throw new ScreenshotApiException('unexpected');
}
private function retryDelay(array $headers, int $attempt): int
{
$value = $headers['retry-after'][0] ?? null;
return is_string($value) && ctype_digit($value)
? min((int) $value, 10)
: min(2 ** ($attempt - 1), 4);
}
private function operationalHeaders(array $headers): array
{
$kept = [];
foreach ($headers as $name => $values) {
$lower = strtolower($name);
if (in_array($lower, ['content-type', 'cache-control', 'age', 'expires'], true)
|| str_contains($lower, 'cache')
|| str_contains($lower, 'quota')
|| str_contains($lower, 'ratelimit')
|| str_contains($lower, 'rate-limit')) {
$kept[$lower] = array_values($values);
}
}
return $kept;
}
}
Only transport failures, HTTP 429, and server errors are retried. Authentication and validation failures need human or configuration changes, so retrying them would waste quota and delay a clear diagnosis. Retry-After is honored when it is an integer, but capped so an individual pipeline cannot stall indefinitely.
The header mapper deliberately avoids assuming undocumented response fields. It retains cache and quota-related headers under their original normalized names, allowing the application to preserve whichever headers the service currently returns.
Store artifacts and expose the command
Each release gets a directory containing before.png, after.png, and corresponding metadata files. Symfony’s dumpFile() replaces files atomically, preventing readers from observing a partially written PNG.
<?php
// src/Screenshot/SnapshotStore.php
namespace App\Screenshot;
use Symfony\Component\Filesystem\Filesystem;
final class SnapshotStore
{
public function __construct(private string $snapshotDirectory) {}
public function save(
string $release,
string $phase,
ScreenshotCapture $capture,
): string {
$directory = $this->snapshotDirectory.'/'.$release;
$filesystem = new Filesystem();
$filesystem->mkdir($directory, 0750);
$image = $directory.'/'.$phase.'.png';
$metadata = $directory.'/'.$phase.'.json';
$filesystem->dumpFile($image, $capture->png);
$filesystem->dumpFile($metadata, json_encode([
'release' => $release,
'phase' => $phase,
'captured_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
'bytes' => strlen($capture->png),
'sha256' => hash('sha256', $capture->png),
'service_headers' => $capture->operationalHeaders,
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
return $image;
}
}
// src/Command/WebsiteSnapshotCommand.php
namespace App\Command;
use App\Screenshot\ScreenshotApiException;
use App\Screenshot\ScreenshotClient;
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\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:website-snapshot',
description: 'Capture a before or after deployment snapshot.',
)]
final class WebsiteSnapshotCommand extends Command
{
public function __construct(
private ScreenshotClient $client,
private SnapshotStore $store,
private LoggerInterface $logger,
private string $allowedHosts,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('release', InputArgument::REQUIRED)
->addArgument('phase', InputArgument::REQUIRED)
->addArgument('url', InputArgument::REQUIRED);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$release = (string) $input->getArgument('release');
$phase = (string) $input->getArgument('phase');
$url = (string) $input->getArgument('url');
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
$hosts = array_map(
static fn (string $value) => strtolower(trim($value)),
explode(',', $this->allowedHosts),
);
if (!preg_match('/^[A-Za-z0-9._-]{1,100}$/', $release)
|| !in_array($phase, ['before', 'after'], true)
|| parse_url($url, PHP_URL_SCHEME) !== 'https'
|| !in_array($host, $hosts, true)) {
$output->writeln('<error>Invalid release, phase, or URL.</error>');
return Command::INVALID;
}
try {
$path = $this->store->save(
$release,
$phase,
$this->client->capture($url),
);
$this->logger->info('Website snapshot captured', [
'release' => $release,
'phase' => $phase,
'host' => $host,
]);
$output->writeln('Saved '.$path);
return Command::SUCCESS;
} catch (ScreenshotApiException $exception) {
$this->logger->error('Website snapshot failed', [
'release' => $release,
'phase' => $phase,
'host' => $host,
'category' => $exception->category,
'status' => $exception->status,
]);
$output->writeln('<error>Capture failed: '
.$exception->category.'</error>');
return Command::FAILURE;
}
}
}
The hostname allowlist prevents a compromised pipeline parameter from spending quota on arbitrary targets. Logging only the hostname also avoids exposing signed preview URLs and their query parameters.
Test retries without making network calls
MockHttpClient gives the boundary deterministic responses. Injecting a no-op sleeper keeps retry tests fast.
<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;
use App\Screenshot\ScreenshotApiException;
use App\Screenshot\ScreenshotClient;
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 testRetriesServerFailureAndReturnsPng(): void
{
$png = "\x89PNG\r\n\x1a\npayload";
$http = new MockHttpClient([
new MockResponse('', ['http_code' => 503]),
new MockResponse($png, [
'http_code' => 200,
'response_headers' => [
'Content-Type: image/png',
'Cache-Control: max-age=60',
],
]),
]);
$client = new ScreenshotClient(
$http,
new NullLogger(),
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'YOUR_SERVICE_TOKEN',
static fn (int $seconds) => null,
);
$capture = $client->capture('https://www.example.com/');
self::assertSame($png, $capture->png);
self::assertSame(
['max-age=60'],
$capture->operationalHeaders['cache-control'],
);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$client = new ScreenshotClient(
new MockHttpClient([
new MockResponse('', ['http_code' => 401]),
]),
new NullLogger(),
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'YOUR_SERVICE_TOKEN',
static fn (int $seconds) => null,
);
try {
$client->capture('https://www.example.com/');
self::fail('An authentication exception was expected.');
} catch (ScreenshotApiException $exception) {
self::assertSame('authentication', $exception->category);
self::assertSame(401, $exception->status);
}
}
}
Run the suite with php bin/phpunit. Useful follow-up cases include an HTTP 429 sequence, an HTML body mislabeled as success, an oversized image, and command rejection of an unapproved hostname.
Connect it to deployment
Use the same immutable release identifier for both images. Add these commands around the existing deployment step:
php bin/console app:website-snapshot release-2026-09-02 before \
https://www.example.com/
# Run the existing deployment and health-check steps here.
php bin/console app:website-snapshot release-2026-09-02 after \
https://www.example.com/
Failing the before command should stop deployment because the comparison would be incomplete. An after failure should fail the visual-audit stage without pretending the application rollback has occurred. Whether that failure initiates rollback is a separate release-policy decision.
On a single deployment runner, var/audits is sufficient. In ephemeral containers, upload that directory as a protected pipeline artifact or replace SnapshotStore with the project’s existing durable storage abstraction. Keep captures access-controlled: client pages can contain names, account information, preview content, or commercially sensitive designs.
Common production failures
- Authentication: HTTP 401 or 403 usually means the secret is absent, malformed, or was revoked by token regeneration. Confirm secret injection without printing its value.
- Quota or throttling: HTTP 429 is retried only within the bounded policy. Preserve the returned quota headers in metadata and reduce duplicate pipeline captures.
- Invalid request: A 4xx response other than authentication or throttling should not be retried. Check that
urlis an absolute, reachable HTTPS address. - Unexpected image: A successful status with the wrong content type or PNG signature is rejected. This catches proxy error pages and malformed responses.
- Misleading comparisons: Cookie banners, rotating promotions, animations, and personalized pages create legitimate visual differences. Prefer a stable audit URL and deterministic content.
Monitor failure counts by category and HTTP status, but never attach the token or complete target URL to logs. Alert on repeated authentication failures immediately; they will not heal through retries. Watch quota-related failures as a capacity signal rather than an application exception to suppress.
Final verification checklist
- The service plan is active and the service-scoped token is held outside source control.
- The exact GET endpoint succeeds with the required
urlparameter. - Only approved HTTPS hostnames can be captured.
- Timeouts and retries are bounded, and authentication failures are not retried.
- The response is validated as a PNG before storage.
- Cache and quota headers are preserved without assuming undocumented fields.
- Tests pass with no external network traffic.
- The pipeline produces matching
before.pngandafter.pngartifacts for one release identifier.
A visual audit should leave evidence, not merely a green checkmark. With two authenticated API calls and a narrow Symfony boundary, every client update gains a durable view of what users could see immediately before and after release—without turning browser maintenance into another production system.