Symfony: Weekly Website Page Visual History for Small Businesses with Screenshot API
A website can change quietly: a misplaced banner, a broken stylesheet, an expired promotion, or a deployment that alters an important page without anyone noticing. For a small business, weekly screenshots provide a simple visual record that answers a practical question: “What did customers see that week?”
This tutorial builds that record as a production-oriented Symfony application feature. A console command captures selected public pages, stores each PNG in a week-based directory, records operational metadata, and behaves predictably under network failures, quota limits, duplicate runs, and overlapping schedules. The remote Screenshot API supplies the browser infrastructure, so the application does not need to operate Chromium.
Get access to the Screenshot API
Start by registering an account, or use the sign-in page if you already have one.
- Open the Screenshot API service page.
- Choose an 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.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer header because query-string credentials can leak into access logs, browser history, and monitoring systems.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: update every runtime that uses the old value, deploy the new environment configuration, verify a capture, and only then regard the rotation as complete.
Confirm the endpoint before writing application code
The exact request is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. The required url query parameter identifies the page to capture. 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' \
--header 'Accept: image/png' \
--data-urlencode 'url=https://example.com/' \
--dump-header /tmp/screenshot-headers.txt \
--output /tmp/home.png
The command deliberately saves the headers as well as the PNG. That makes it possible to inspect cache and quota information without printing binary data into the terminal.
Now place the real credential in .env.local, which should remain outside version control. Production platforms should inject the same variable through their secret or environment-management facility.
# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
Architecture for a modest, dependable archive
The application uses a synchronous Symfony command rather than Messenger. One weekly run over a short, fixed list of pages does not justify another worker process. A system scheduler invokes the command, while Symfony Lock prevents two copies from writing the same week simultaneously.
Each capture is stored under var/screenshots/YYYY-Www/. A PNG is accompanied by a JSON sidecar containing its URL, timestamp, checksum, number of HTTP attempts, and selected cache or quota headers. The sidecar turns an image directory into an auditable archive without introducing a database.
src/
Command/CaptureWeeklyScreenshotsCommand.php
Screenshot/ScreenshotCapture.php
Screenshot/ScreenshotClient.php
Screenshot/ScreenshotFailure.php
tests/
Screenshot/ScreenshotClientTest.php
var/
screenshots/
2026-W41/
home.png
home.json
This storage model is intentionally local and simple. It suits a single small-business application host, but the directory must live on persistent storage and be included in backups. Multiple disposable application replicas should write to shared durable storage or upload completed files through a dedicated storage adapter.
Install and configure the Symfony components
The project requires PHP 8.3 or later and an existing Symfony application with Console and dependency injection available. Install the HTTP client, filesystem, lock component, and test tooling:
composer require symfony/http-client symfony/filesystem symfony/lock
composer require --dev symfony/test-pack
Define the important public pages in configuration. Keeping this list server-controlled avoids turning the command into a general-purpose URL fetcher.
# config/services.yaml
parameters:
app.screenshot_pages:
- { name: 'home', url: 'https://www.example.com/' }
- { name: 'services', url: 'https://www.example.com/services' }
- { name: 'contact', url: 'https://www.example.com/contact' }
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
Symfony\Component\Filesystem\Filesystem: ~
App\Screenshot\ScreenshotClient:
arguments:
$token: '%env(string:SCREENSHOT_API_TOKEN)%'
$endpoint: 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
App\Command\CaptureWeeklyScreenshotsCommand:
arguments:
$pages: '%app.screenshot_pages%'
$projectDir: '%kernel.project_dir%'
For a single host, configure a filesystem-backed lock:
# config/packages/lock.yaml
framework:
lock: 'flock'
Map the remote response into domain objects
The API boundary should return something more meaningful than an HTTP response. These small classes distinguish successful captures from structured failures without exposing transport details to the command.
<?php
// src/Screenshot/ScreenshotCapture.php
namespace App\Screenshot;
final readonly class ScreenshotCapture
{
public function __construct(
public string $png,
public array $serviceHeaders,
public int $attempts,
) {
}
}
// src/Screenshot/ScreenshotFailure.php
namespace App\Screenshot;
final class ScreenshotFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Build a defensive HTTP client
The client enforces HTTPS, applies bounded connection and overall response timeouts, and retries only transport failures and transient server responses. Authentication, validation, and quota failures are not blindly retried. Repeating a quota-limited request usually consumes time without improving the outcome.
Because application code should not guess undocumented response fields, the boundary normalizes and retains headers whose names identify cache, quota, rate-limit, or retry information. The archive can therefore preserve service metadata without coupling the domain layer to fabricated header names.
<?php
// src/Screenshot/ScreenshotClient.php
namespace App\Screenshot;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class ScreenshotClient
{
public function __construct(
private HttpClientInterface $http,
private string $token,
private string $endpoint,
private LoggerInterface $logger,
) {
}
public function capture(string $url): ScreenshotCapture
{
$parts = parse_url($url);
if (
false === $parts
|| 'https' !== ($parts['scheme'] ?? null)
|| !isset($parts['host'])
|| isset($parts['user'])
) {
throw new ScreenshotFailure('validation', 'Capture URL must be a public HTTPS URL.');
}
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->http->request('GET', $this->endpoint, [
'query' => ['url' => $url],
'headers' => [
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'image/png',
],
'timeout' => 20.0,
'max_duration' => 45.0,
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
if (in_array($status, [500, 502, 503, 504], true) && $attempt < 3) {
$this->logger->warning('Transient screenshot response; retrying.', [
'host' => $parts['host'],
'status' => $status,
'attempt' => $attempt,
]);
$this->pause($attempt);
continue;
}
if (401 === $status || 403 === $status) {
throw new ScreenshotFailure('authentication', 'Screenshot authentication failed.');
}
if (400 === $status || 422 === $status) {
throw new ScreenshotFailure('validation', 'Screenshot request was rejected.');
}
if (429 === $status) {
throw new ScreenshotFailure('quota', 'Screenshot quota or rate limit was reached.');
}
if ($status < 200 || $status >= 300) {
throw new ScreenshotFailure('http', 'Screenshot service returned HTTP '.$status.'.');
}
$body = $response->getContent(false);
$contentType = strtolower($headers['content-type'][0] ?? '');
if (!str_starts_with($contentType, 'image/png')) {
throw new ScreenshotFailure('protocol', 'Expected an image/png response.');
}
if (!str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotFailure('protocol', 'Response does not have a PNG signature.');
}
if (strlen($body) > 20 * 1024 * 1024) {
throw new ScreenshotFailure('protocol', 'Screenshot exceeds the 20 MiB application limit.');
}
return new ScreenshotCapture(
$body,
$this->operationalHeaders($headers),
$attempt,
);
} catch (TransportExceptionInterface $exception) {
if ($attempt >= 3) {
throw new ScreenshotFailure('network', 'Screenshot transport failed.', $exception);
}
$this->logger->warning('Screenshot transport failed; retrying.', [
'host' => $parts['host'],
'attempt' => $attempt,
]);
$this->pause($attempt);
}
}
throw new ScreenshotFailure('network', 'Screenshot attempts were exhausted.');
}
private function pause(int $attempt): void
{
$milliseconds = min(2000, 250 * (2 ** ($attempt - 1))) + random_int(0, 100);
usleep($milliseconds * 1000);
}
private function operationalHeaders(array $headers): array
{
$selected = [];
foreach ($headers as $name => $values) {
$normalized = strtolower($name);
if (
str_contains($normalized, 'cache')
|| str_contains($normalized, 'quota')
|| str_contains($normalized, 'rate-limit')
|| str_contains($normalized, 'ratelimit')
|| 'retry-after' === $normalized
) {
$selected[$normalized] = implode(', ', $values);
}
}
return $selected;
}
}
Implement the weekly archive command
The command is idempotent: a page with both its PNG and metadata already present is skipped. Atomic filesystem writes reduce the chance of leaving a truncated image. If one page fails, the command continues capturing the others but ultimately returns a failure exit code so scheduling and monitoring systems can raise an alert.
<?php
// src/Command/CaptureWeeklyScreenshotsCommand.php
namespace App\Command;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotFailure;
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;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Lock\LockFactory;
#[AsCommand(
name: 'app:screenshots:capture-weekly',
description: 'Archive weekly screenshots of configured business pages.',
)]
final class CaptureWeeklyScreenshotsCommand extends Command
{
public function __construct(
private readonly ScreenshotClient $client,
private readonly Filesystem $filesystem,
private readonly LockFactory $locks,
private readonly LoggerInterface $logger,
private readonly array $pages,
private readonly string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$lock = $this->locks->createLock('weekly-screenshot-archive', 900);
if (!$lock->acquire()) {
$output->writeln('<comment>Another capture is already running.</comment>');
return Command::SUCCESS;
}
$failed = false;
try {
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$week = $now->format('o-\WW');
$directory = $this->projectDir.'/var/screenshots/'.$week;
$this->filesystem->mkdir($directory, 0750);
foreach ($this->pages as $page) {
$name = (string) ($page['name'] ?? '');
$url = (string) ($page['url'] ?? '');
if (1 !== preg_match('/\A[a-z0-9][a-z0-9-]*\z/D', $name)) {
$failed = true;
$this->logger->error('Invalid screenshot page name.', ['name' => $name]);
continue;
}
$pngPath = $directory.'/'.$name.'.png';
$jsonPath = $directory.'/'.$name.'.json';
if (is_file($pngPath) && is_file($jsonPath)) {
$output->writeln('Skipping existing capture: '.$name);
continue;
}
try {
$capture = $this->client->capture($url);
$this->filesystem->dumpFile($pngPath, $capture->png);
$metadata = [
'page' => $name,
'url' => $url,
'captured_at' => $now->format(DATE_ATOM),
'sha256' => hash('sha256', $capture->png),
'attempts' => $capture->attempts,
'service_headers' => $capture->serviceHeaders,
];
$this->filesystem->dumpFile(
$jsonPath,
json_encode(
$metadata,
JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
)."\n"
);
$output->writeln('<info>Captured '.$name.'</info>');
} catch (ScreenshotFailure $exception) {
$failed = true;
$this->logger->error('Weekly screenshot failed.', [
'page' => $name,
'kind' => $exception->kind,
'exception' => $exception,
]);
$output->writeln('<error>Failed '.$name.': '.$exception->kind.'</error>');
}
}
} finally {
$lock->release();
}
return $failed ? Command::FAILURE : Command::SUCCESS;
}
}
Test the integration without calling the service
MockHttpClient provides a deterministic transport. One test proves successful PNG and operational-header mapping; another ensures an unexpected body cannot silently enter the archive.
<?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 testMapsPngAndOperationalHeaders(): void
{
$png = "\x89PNG\r\n\x1a\nfake-payload";
$response = new MockResponse($png, [
'http_code' => 200,
'response_headers' => [
'content-type: image/png',
'x-cache-test: HIT',
'x-quota-test: 42',
],
]);
$client = new ScreenshotClient(
new MockHttpClient($response),
'TEST_TOKEN',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
new NullLogger(),
);
$result = $client->capture('https://www.example.com/');
self::assertSame($png, $result->png);
self::assertSame(1, $result->attempts);
self::assertSame('HIT', $result->serviceHeaders['x-cache-test']);
}
public function testRejectsNonPngResponse(): void
{
$response = new MockResponse('not an image', [
'http_code' => 200,
'response_headers' => ['content-type: text/plain'],
]);
$client = new ScreenshotClient(
new MockHttpClient($response),
'TEST_TOKEN',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
new NullLogger(),
);
try {
$client->capture('https://www.example.com/');
self::fail('Expected a protocol failure.');
} catch (ScreenshotFailure $exception) {
self::assertSame('protocol', $exception->kind);
}
}
}
php bin/phpunit
php bin/console app:screenshots:capture-weekly --env=prod
Security, observability, and deployment
Only capture an explicit allowlist of public HTTPS pages. Do not accept arbitrary URLs from a controller or command argument: remote screenshot services can otherwise become a route to internal systems. Avoid authenticated admin pages, URLs containing reset tokens, and pages that display customer information.
Never log the service token, Authorization header, or complete exception request options. The implementation logs the configured page name, target host, failure category, status where useful, and retry attempt. Alert on a nonzero command exit, repeated authentication failures, or quota failures. The saved cache and quota headers provide additional operational context without placing credentials in metadata.
Run the command weekly from one scheduler. For example, a Monday UTC cron entry can be:
17 3 * * 1 cd /srv/business-site && php bin/console app:screenshots:capture-weekly --env=prod
Ensure var/screenshots is persistent, writable by the application user, excluded from public web serving, and covered by backups. On several replicas, use a Symfony-supported shared lock store and a shared archive destination. Establish a retention policy deliberately; silently deleting old screenshots defeats the purpose of visual history.
Common failures worth planning for
- Authentication failure: confirm the environment variable is available to the scheduled process, not merely to an interactive shell. A regenerated token invalidates the previous one.
- Quota or rate limit: do not loop aggressively. Review the captured response headers, plan usage around the selected plan, and leave the failed week visible to monitoring.
- HTML instead of PNG: the service or an intermediary returned an unexpected response. Content-type and PNG-signature checks prevent corrupt archive files.
- Missing assets in the screenshot: verify that the target page and its assets are publicly reachable and do not depend on a private session.
- Duplicate scheduler execution: retain the lock and idempotent filename checks. In multi-host deployments, replace the local lock with a shared lock store.
- Empty history after deployment: confirm persistent volume mounting, directory ownership, scheduler working directory, and the production environment name.
Final verification checklist
- The service plan is active and the service-scoped token is supplied through the environment.
- The minimal GET request returns a PNG and exposes response headers for inspection.
- Every configured target is a deliberate public HTTPS page.
- Automated tests pass without contacting the real API.
- A production command run creates matching
.pngand.jsonfiles. - Running the command again in the same ISO week skips completed captures.
- The scheduler reports nonzero exits and the archive directory is backed up.
- No token, Authorization header, or private page content appears in logs or fixtures.
The finished system is deliberately unglamorous: one bounded HTTP boundary, one scheduled command, durable files, checksums, locks, and useful failure categories. That restraint is its strength. Week after week, it quietly builds a trustworthy visual memory of the pages customers actually depend on.