Туториали

Symfony: Chronicle Key Pages Weekly with Automated Screenshots

Symfony: Автоматизирани неделни снимки од екранот на клучните страници на Chronicle

A website can change quietly. A promotion expires, a booking button disappears, a theme update shifts the layout, or a third-party widget stops rendering. By the time a small business owner notices, nobody remembers what the page looked like last Monday.

This tutorial builds a production-oriented Symfony application that captures important pages once a week and stores a dated PNG history. It uses a hosted Screenshot API, so the application does not need to install Chromium, maintain browser drivers, or manage headless-browser processes.

The finished system has a deliberately small architecture: a Symfony console command reads an approved page list, a dedicated API client retrieves each PNG, and an atomic file writer saves the image with operational metadata. Cron supplies the weekly schedule. Failures are isolated per page, logged safely, and exposed through the command exit code.

Prerequisites and project shape

You need PHP 8.3 or newer, Composer, a Symfony application, and persistent storage writable by the PHP command-line process. Install Symfony’s HTTP client, console integration, logging bundle, and test tooling:

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

The relevant files will be:

  • src/Screenshot/CaptureResult.php for the domain-level API result
  • src/Screenshot/CaptureFailed.php for structured failures
  • src/Screenshot/ScreenshotClient.php for the external API boundary
  • src/Command/CaptureWeeklyScreenshotsCommand.php for orchestration and storage
  • tests/Screenshot/ScreenshotClientTest.php for deterministic transport tests
  • var/visual-history/ for the resulting archive

A queue would add little value here. A small, sequential weekly run is easier to operate, while each page still fails independently. If the page list eventually becomes large or strict execution windows appear, the same client can sit behind Symfony Messenger without changing its API contract.

Get access to the Screenshot API

First, register 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.

Next, open the official documentation. Find the Service token panel and copy the service-scoped token shown there. Regenerating that token revokes the previously active token, so token rotation must update the deployed application promptly.

This service is not tokenless: every request must authenticate. The supported choices are a Bearer token, an X-API-Token header, or a token query parameter. This implementation uses a Bearer token because it keeps the credential out of URLs, browser history, and routine proxy access logs.

Confirm the exact HTTP contract

The capture operation is an HTTP GET request to 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 together with cache and quota-related response headers.

Before adding application code, test the token from a private shell. The first command reads it without displaying it; the request writes the binary response and headers to separate temporary files.

read -rsp "Screenshot API token: " SCREENSHOT_API_TOKEN
export SCREENSHOT_API_TOKEN

curl --fail-with-body --get \
  "https://ai.mihajlo.mk/api/screenshot-api/v1/capture" \
  --header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
  --data-urlencode "url=https://example.com/" \
  --dump-header /tmp/screenshot.headers \
  --output /tmp/screenshot.png

file /tmp/screenshot.png

Inspect the saved headers when diagnosing cache or quota behavior, but do not publish them indiscriminately. The application will retain only headers whose names identify cache, quota, or rate-limit information rather than logging the complete response.

Store configuration outside source control

Put local development values in .env.local, which should not be committed. In production, inject the same names through the deployment platform or Symfony’s secret-management facilities.

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_PAGES='{"home":"https://example.com/","booking":"https://example.com/book"}'

Use stable, filesystem-safe keys for page names. Avoid signed URLs, password-reset links, customer-specific pages, and other sensitive targets. The configured pages must be reachable in the manner expected by the screenshot service; this tutorial does not invent an unsupported mechanism for logging into protected pages.

Wire the token, decoded page map, and storage directory into Symfony services:

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

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

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

    App\Command\CaptureWeeklyScreenshotsCommand:
        arguments:
            $pages: '%env(json:SCREENSHOT_PAGES)%'
            $storageRoot: '%kernel.project_dir%/var/visual-history'

Map the API response at one boundary

Controllers and commands should not pass raw HTTP responses through the application. A small result object makes the valid state explicit: verified PNG bytes plus the operational headers relevant to cache and quota decisions.

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

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

The exception records an HTTP status when one exists, whether the condition may be transient, and the safely filtered headers. It never contains the service token or response body.

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

final class CaptureFailed extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $status = null,
        public readonly bool $retryable = false,
        public readonly array $operationalHeaders = [],
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Build a defensive Symfony HTTP client

The client below bounds inactive connection time and total request duration. It retries transport failures and server errors with short exponential delays. A 429 response is retried only when a numeric standard Retry-After value requests a delay of at most five seconds. Longer or unspecified quota waits are returned to the scheduler instead of being hammered immediately.

Authentication and validation failures are never retried. The client also verifies both the media type and PNG signature, applies an application-level size ceiling, and treats malformed success responses as failures.

<?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 LoggerInterface $logger,
        private readonly string $token,
        private readonly int $maxAttempts = 3,
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('Screenshot API token is empty.');
        }
    }

    public function capture(string $url): CaptureResult
    {
        for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
            try {
                $response = $this->http->request('GET', self::ENDPOINT, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->token,
                        'Accept' => 'image/png',
                    ],
                    'query' => ['url' => $url],
                    'timeout' => 15.0,
                    'max_duration' => 45.0,
                ]);

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

                if ($status !== 200) {
                    $delay = $this->retryDelay($status, $headers, $attempt);

                    if ($delay !== null && $attempt < $this->maxAttempts) {
                        $this->logger->warning('Screenshot request will retry.', [
                            'attempt' => $attempt,
                            'status' => $status,
                            'headers' => $operational,
                        ]);
                        usleep($delay * 1000);
                        continue;
                    }

                    throw new CaptureFailed(
                        'Screenshot service returned HTTP '.$status.'.',
                        $status,
                        $status === 429 || $status >= 500,
                        $operational,
                    );
                }

                $body = $response->getContent(false);
                $contentType = strtolower(trim(explode(
                    ';',
                    $headers['content-type'][0] ?? ''
                )[0]));

                if ($contentType !== 'image/png') {
                    throw new CaptureFailed(
                        'Screenshot response was not image/png.',
                        $status,
                        false,
                        $operational,
                    );
                }

                if (!str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
                    throw new CaptureFailed(
                        'Screenshot response has an invalid PNG signature.',
                        $status,
                        false,
                        $operational,
                    );
                }

                if (strlen($body) > 15 * 1024 * 1024) {
                    throw new CaptureFailed(
                        'Screenshot exceeded the 15 MiB application limit.',
                        $status,
                        false,
                        $operational,
                    );
                }

                return new CaptureResult($body, $operational);
            } catch (TransportExceptionInterface $error) {
                if ($attempt === $this->maxAttempts) {
                    throw new CaptureFailed(
                        'Screenshot transport failed after bounded retries.',
                        null,
                        true,
                        [],
                        $error,
                    );
                }

                $this->logger->warning('Screenshot transport will retry.', [
                    'attempt' => $attempt,
                ]);
                usleep((250 * (2 ** ($attempt - 1))) * 1000);
            }
        }

        throw new \LogicException('Unreachable retry state.');
    }

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

            if (is_string($value) && ctype_digit($value)) {
                $seconds = (int) $value;
                return $seconds <= 5 ? $seconds * 1000 : null;
            }

            return null;
        }

        return $status >= 500 && $status <= 599
            ? 250 * (2 ** ($attempt - 1))
            : null;
    }

    private function operationalHeaders(array $headers): array
    {
        $selected = [];

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

            if (
                str_contains($normalized, 'cache')
                || str_contains($normalized, 'quota')
                || str_contains($normalized, 'rate')
            ) {
                $selected[$normalized] = implode(', ', $values);
            }
        }

        return $selected;
    }
}

The header mapping is intentionally name-tolerant. It handles the documented categories without pretending that an undocumented vendor-specific spelling exists. Because the API may return a cached capture, these values are valuable evidence: the archive records what the service returned that week, while its metadata helps distinguish capture chronology from cache freshness.

Capture every configured page atomically

The command validates page keys and requires HTTPS targets. A non-blocking filesystem lock prevents overlapping cron runs. Each successful capture produces a PNG and adjacent JSON metadata containing its UTC timestamp, byte count, SHA-256 digest, and filtered service headers.

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

use App\Screenshot\CaptureFailed;
use App\Screenshot\ScreenshotClient;
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\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:screenshots:capture-weekly',
    description: 'Archive configured business pages as PNG screenshots.'
)]
final class CaptureWeeklyScreenshotsCommand extends Command
{
    public function __construct(
        private readonly ScreenshotClient $client,
        private readonly LoggerInterface $logger,
        private readonly array $pages,
        private readonly string $storageRoot,
    ) {
        parent::__construct();
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        $io = new SymfonyStyle($input, $output);
        $this->ensureDirectory($this->storageRoot);

        $lock = fopen($this->storageRoot.'/.capture.lock', 'c');
        if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
            $io->warning('Another screenshot run is active.');
            return Command::SUCCESS;
        }

        $failures = 0;
        $capturedAt = new \DateTimeImmutable(
            'now',
            new \DateTimeZone('UTC')
        );

        try {
            foreach ($this->pages as $slug => $url) {
                try {
                    $this->assertPageConfiguration($slug, $url);
                    $result = $this->client->capture($url);

                    $directory = sprintf(
                        '%s/%s/%s',
                        $this->storageRoot,
                        $slug,
                        $capturedAt->format('Y')
                    );
                    $this->ensureDirectory($directory);

                    $base = $directory.'/'.$capturedAt->format(
                        'Y-m-d\TH-i-s\Z'
                    );

                    $this->writeAtomically($base.'.png', $result->png);
                    $metadata = [
                        'captured_at' => $capturedAt->format(DATE_ATOM),
                        'bytes' => strlen($result->png),
                        'sha256' => hash('sha256', $result->png),
                        'service_headers' => $result->operationalHeaders,
                    ];
                    $this->writeAtomically(
                        $base.'.json',
                        json_encode(
                            $metadata,
                            JSON_PRETTY_PRINT
                            | JSON_UNESCAPED_SLASHES
                            | JSON_THROW_ON_ERROR
                        )."\n"
                    );

                    $this->logger->info('Weekly screenshot stored.', [
                        'page' => $slug,
                        'bytes' => strlen($result->png),
                        'headers' => $result->operationalHeaders,
                    ]);
                    $io->writeln('Captured '.$slug);
                } catch (\Throwable $error) {
                    $failures++;
                    $this->logger->error('Weekly screenshot failed.', [
                        'page' => (string) $slug,
                        'status' => $error instanceof CaptureFailed
                            ? $error->status
                            : null,
                        'retryable' => $error instanceof CaptureFailed
                            ? $error->retryable
                            : false,
                        'headers' => $error instanceof CaptureFailed
                            ? $error->operationalHeaders
                            : [],
                        'exception' => $error::class,
                    ]);
                    $io->error('Failed to capture '.$slug);
                }
            }
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }

        return $failures === 0
            ? Command::SUCCESS
            : Command::FAILURE;
    }

    private function assertPageConfiguration(mixed $slug, mixed $url): void
    {
        if (
            !is_string($slug)
            || preg_match('/^[a-z0-9-]+$/D', $slug) !== 1
            || !is_string($url)
            || filter_var($url, FILTER_VALIDATE_URL) === false
            || parse_url($url, PHP_URL_SCHEME) !== 'https'
        ) {
            throw new \InvalidArgumentException(
                'Page configuration requires a safe slug and HTTPS URL.'
            );
        }
    }

    private function ensureDirectory(string $directory): void
    {
        if (
            !is_dir($directory)
            && !mkdir($directory, 0770, true)
            && !is_dir($directory)
        ) {
            throw new \RuntimeException(
                'Cannot create screenshot directory.'
            );
        }
    }

    private function writeAtomically(string $path, string $contents): void
    {
        $temporary = tempnam(dirname($path), '.capture-');
        if ($temporary === false) {
            throw new \RuntimeException('Cannot create temporary file.');
        }

        try {
            if (
                file_put_contents($temporary, $contents, LOCK_EX)
                !== strlen($contents)
            ) {
                throw new \RuntimeException('Incomplete screenshot write.');
            }

            chmod($temporary, 0640);

            if (!rename($temporary, $path)) {
                throw new \RuntimeException('Cannot publish capture file.');
            }
        } finally {
            if (is_file($temporary)) {
                unlink($temporary);
            }
        }
    }
}

One broken page does not suppress the others, but any failure makes the overall command fail. That balance preserves useful work while still giving monitoring systems an actionable signal.

Test the boundary without network access

MockHttpClient makes API tests deterministic. The first test verifies the exact method, endpoint, required query parameter, authentication header, PNG mapping, and operational header filtering. Its quota-named header is deliberately synthetic; the test checks name-tolerant behavior without asserting an undocumented service spelling. The second test confirms that authentication failures are surfaced without retries.

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

use App\Screenshot\CaptureFailed;
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 testItMapsAValidPngResponse(): void
    {
        $png = "\x89PNG\r\n\x1a\npayload";

        $http = new MockHttpClient(
            function (string $method, string $url, array $options) use ($png) {
                self::assertSame('GET', $method);
                self::assertStringStartsWith(
                    'https://ai.mihajlo.mk/api/screenshot-api/v1/capture?',
                    $url
                );
                parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
                self::assertSame('https://example.com/', $query['url']);
                self::assertStringContainsString(
                    'Authorization: Bearer test-token',
                    implode("\n", $options['headers'])
                );

                return new MockResponse($png, [
                    'http_code' => 200,
                    'response_headers' => [
                        'Content-Type: image/png',
                        'Cache-Control: max-age=60',
                        'X-Test-Quota: 3',
                    ],
                ]);
            }
        );

        $result = (new ScreenshotClient(
            $http,
            new NullLogger(),
            'test-token',
            1
        ))->capture('https://example.com/');

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

    public function testItDoesNotRetryAuthenticationFailure(): void
    {
        $http = new MockHttpClient([
            new MockResponse('', ['http_code' => 401]),
        ]);

        $client = new ScreenshotClient(
            $http,
            new NullLogger(),
            'bad-token',
            3
        );

        $this->expectException(CaptureFailed::class);
        $client->capture('https://example.com/');
    }
}

Run the tests and then perform a real application capture:

php bin/phpunit
APP_ENV=prod php bin/console app:screenshots:capture-weekly --no-interaction
find var/visual-history -type f -maxdepth 3 -print

Deploy the weekly schedule

Deploy the token through protected environment configuration, grant the command-line user write access to var/visual-history, and place that directory on persistent storage. Container filesystems and release directories are often replaced during deployment; an archive stored only there is not a history.

A conventional cron entry can run every Monday at 03:17 server time:

17 3 * * 1 cd /srv/shop-history && APP_ENV=prod /usr/bin/php bin/console app:screenshots:capture-weekly --no-interaction >> /var/log/shop-history.log 2>&1

Send nonzero exits to the monitoring mechanism already used by the business. Alert on repeated failures, sustained 429 responses, shrinking quota values, invalid PNG responses, and a missing weekly file. Back up the archive, define a retention policy, and test restoration rather than assuming mounted storage is permanent.

Common production failures

  • HTTP 401 or 403: verify the deployed token and whether somebody regenerated it. Do not keep retrying an invalid credential.
  • HTTP 400 or another validation response: inspect the configured target URL. Confirm that the required url parameter is a complete, correctly encoded URL.
  • HTTP 429: preserve the quota and rate-related headers, allow the next scheduled run to recover, or adjust the plan and capture list. Immediate unbounded retries only consume more capacity.
  • A valid response that looks old: inspect the archived cache headers. Avoid adding random query strings merely to defeat caching unless those URLs are valid, safe, and consistent with the site’s semantics.
  • No files after deployment: check CLI environment variables, directory ownership, persistent-volume mounting, and the cron working directory.
  • Partial history: use the JSON metadata and command logs to identify the failed page. Successful captures remain available even when the command exits unsuccessfully.

Final verification checklist

  1. The account and chosen plan are active, and the service-scoped token comes from the documentation page’s Service token panel.
  2. No real token appears in Git, logs, test fixtures, command history, or stored screenshots.
  3. The minimal request returns an image/png body from the exact documented GET endpoint.
  4. SCREENSHOT_PAGES contains only approved HTTPS pages with safe, stable slugs.
  5. The PHPUnit suite passes without external network access.
  6. A manual command creates readable PNG and JSON files under the persistent archive.
  7. A second simultaneous invocation exits without duplicating work.
  8. Cron uses the intended PHP binary, production environment, working directory, and failure monitoring.
  9. Token rotation, storage backup, retention, and restoration have named operational owners.

A weekly screenshot archive is modest infrastructure, but that is precisely why it works well for a small business. It turns vague recollections into dated evidence without turning the application into a browser-automation platform. Keep the boundary strict, the retries restrained, the credentials invisible, and the storage durable; every Monday, the system will quietly add another trustworthy page to the business’s visual memory.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.