Tutorials

Native PHP 8.3: Capture Website Update Before/After Snapshots Automatically

Native PHP 8.3: Capture Website Update Before/After Snapshots Automatically

A website update can look perfect in a diff and still ship a broken hero image, an unexpected cookie banner, or a layout that collapses only at production width. A before-and-after screenshot pair gives developers, freelancers, and clients a durable visual record of what actually changed.

This tutorial builds that record with Native PHP 8.3, native cURL, and a small command-line application. It captures a PNG immediately before deployment, captures another after the updated site passes its health check, and stores both images with response metadata for troubleshooting and auditability.

Get access to the Screenshot API

First, register an account, or use the sign-in page if you already have one.

  1. Open the Screenshot API service page.
  2. Choose an available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation.
  4. Find the Service token panel and copy the service-scoped token.
  5. Store it in environment-backed configuration, never in PHP source or deployment scripts.

Regenerating the service token revokes the previously active token. Coordinate rotation so the new value reaches every running environment before old processes make another request.

The service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. This project uses a Bearer header because query-string credentials can appear in access logs and monitoring systems.

Verify the exact endpoint

The capture operation is an HTTP GET request to https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Its required query parameter is url, and a successful capture returns an image/png body plus cache and quota-related response headers.

export SCREENSHOT_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_TOKEN}" \
  --header 'Accept: image/png' \
  --data-urlencode 'url=https://client.example/' \
  --dump-header smoke.headers \
  --output smoke.png

file smoke.png

Inspect smoke.headers as well as the PNG. Header names should be consumed according to the current official documentation. The implementation below preserves every returned response header instead of inventing fixed cache or quota field names.

Architecture and project layout

The deployment pipeline invokes one command twice: once before changing the site and once after the updated site is healthy. A release identifier joins the two captures. The API boundary is isolated behind a transport interface, allowing PHPUnit to test retries and failures without making network calls.

website-snapshots/
├── bin/snapshot
├── src/
│   ├── CurlTransport.php
│   ├── HttpResponse.php
│   ├── ScreenshotClient.php
│   └── Transport.php
├── tests/ScreenshotClientTest.php
├── var/snapshots/
├── .env
├── .env.example
├── composer.json
└── phpunit.xml

Remote capture avoids maintaining Chromium, browser patches, fonts, and sandboxing infrastructure. The trade-off is a network dependency and plan quota, so captures need bounded timeouts, deliberate retries, and persistent metadata.

Install and configure the project

The application requires PHP 8.3, the cURL extension, Composer, and PHPUnit 11. The only runtime package is vlucas/phpdotenv 5.6, used to load local environment configuration consistently.

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "vlucas/phpdotenv": "^5.6"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "scripts": {
    "test": "phpunit"
  }
}
composer install
mkdir -p var/snapshots
chmod 750 var var/snapshots
cp .env.example .env
chmod 600 .env

Put placeholders in .env.example, then place the real token only in the ignored .env file. Production should inject the same variables through its secret-management facility.

SCREENSHOT_TOKEN=YOUR_SERVICE_TOKEN
SNAPSHOT_ALLOWED_HOSTS=client.example,www.client.example

The host allowlist is an important application-level boundary. It prevents an argument mistake—or an attacker who gains access to the command—from turning your account into a general-purpose URL capture proxy. This example permits only HTTPS URLs whose hostname exactly matches the configured list.

Build a defensive API boundary

The response and transport types keep HTTP mechanics out of the deployment command. Response headers are normalized to lowercase arrays so repeated headers are not discarded.

<?php
// src/Transport.php
namespace App;

interface Transport
{
    public function get(
        string $url,
        array $headers,
        int $connectTimeout,
        int $responseTimeout
    ): HttpResponse;
}

final class TransportException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly bool $transient
    ) {
        parent::__construct($message);
    }
}
<?php
// src/HttpResponse.php
namespace App;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public string $body,
        public array $headers
    ) {}

    public function header(string $name): ?string
    {
        $values = $this->headers[strtolower($name)] ?? null;
        return $values === null ? null : implode(', ', $values);
    }
}

The cURL transport verifies TLS by retaining cURL’s secure defaults, refuses redirects at the API boundary, and separates connection timeout from total response timeout. Its header callback resets collected headers when a new HTTP status line appears, preventing an interim response from contaminating the final metadata.

<?php
// src/CurlTransport.php
namespace App;

final class CurlTransport implements Transport
{
    public function get(
        string $url,
        array $headers,
        int $connectTimeout,
        int $responseTimeout
    ): HttpResponse {
        $responseHeaders = [];
        $handle = curl_init($url);

        if ($handle === false) {
            throw new TransportException('Unable to initialize cURL', false);
        }

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_CONNECTTIMEOUT => $connectTimeout,
            CURLOPT_TIMEOUT => $responseTimeout,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
                use (&$responseHeaders): int {
                $length = strlen($line);
                $trimmed = trim($line);

                if (str_starts_with($trimmed, 'HTTP/')) {
                    $responseHeaders = [];
                } elseif ($trimmed !== '' && str_contains($trimmed, ':')) {
                    [$name, $value] = explode(':', $trimmed, 2);
                    $responseHeaders[strtolower(trim($name))][] = trim($value);
                }

                return $length;
            },
        ]);

        $body = curl_exec($handle);

        if ($body === false) {
            $number = curl_errno($handle);
            $message = curl_error($handle);
            curl_close($handle);

            $transient = in_array($number, [
                CURLE_OPERATION_TIMEDOUT,
                CURLE_COULDNT_CONNECT,
                CURLE_COULDNT_RESOLVE_HOST,
                CURLE_SEND_ERROR,
                CURLE_RECV_ERROR,
            ], true);

            throw new TransportException(
                "Screenshot transport failed: {$message}",
                $transient
            );
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $body, $responseHeaders);
    }
}

Map PNG responses and retry safely

The client makes at most three attempts. It retries transient transport errors, HTTP 429 responses, and server-side 5xx responses. Validation failures and authentication failures are returned immediately because another identical request will not repair them.

A numeric Retry-After header is honored when present and capped at ten seconds. Otherwise, retries use bounded exponential backoff. Successful bodies must have both an image/png content type and the PNG signature; this prevents an HTML error page from being archived as evidence.

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

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

final class ScreenshotException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $status = null
    ) {
        parent::__construct($message);
    }
}

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

    private \Closure $sleep;

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        private readonly array $allowedHosts,
        ?\Closure $sleep = null
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('Missing screenshot token');
        }

        $this->sleep = $sleep ?? static fn(int $microseconds) =>
            usleep($microseconds);
    }

    public function capture(string $target): CaptureResult
    {
        $parts = parse_url($target);
        $host = strtolower($parts['host'] ?? '');

        if (
            filter_var($target, FILTER_VALIDATE_URL) === false ||
            ($parts['scheme'] ?? '') !== 'https' ||
            !in_array($host, $this->allowedHosts, true) ||
            isset($parts['user']) ||
            isset($parts['pass'])
        ) {
            throw new \InvalidArgumentException('Target URL is not allowed');
        }

        $url = self::ENDPOINT . '?' . http_build_query(
            ['url' => $target],
            '',
            '&',
            PHP_QUERY_RFC3986
        );

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get($url, [
                    'Authorization: Bearer ' . $this->token,
                    'Accept: image/png',
                ], 5, 30);
            } catch (TransportException $error) {
                if (!$error->transient || $attempt === 3) {
                    throw new ScreenshotException($error->getMessage());
                }

                ($this->sleep)(250_000 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 200) {
                $type = strtolower($response->header('content-type') ?? '');
                $signature = "\x89PNG\r\n\x1a\n";

                if (
                    !str_starts_with($type, 'image/png') ||
                    !str_starts_with($response->body, $signature)
                ) {
                    throw new ScreenshotException(
                        'Capture returned an invalid PNG',
                        200
                    );
                }

                return new CaptureResult(
                    $response->body,
                    $response->headers,
                    $attempt
                );
            }

            $retryable = $response->status === 429 ||
                ($response->status >= 500 && $response->status <= 599);

            if (!$retryable || $attempt === 3) {
                throw new ScreenshotException(
                    "Capture failed with HTTP {$response->status}",
                    $response->status
                );
            }

            $retryAfter = trim($response->header('retry-after') ?? '');
            $delay = ctype_digit($retryAfter)
                ? min(10, (int) $retryAfter) * 1_000_000
                : min(2_000_000, 250_000 * (2 ** ($attempt - 1)));

            ($this->sleep)($delay);
        }

        throw new ScreenshotException('Capture attempts exhausted');
    }
}

Create the before-and-after command

The command uses a release identifier and a phase of before or after. Each phase is written to a temporary directory and renamed into place only after both the image and metadata are durable. An existing phase is never overwritten.

<?php
// bin/snapshot
declare(strict_types=1);

use App\CurlTransport;
use App\ScreenshotClient;

require dirname(__DIR__) . '/vendor/autoload.php';

Dotenv\Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
umask(0027);

[$script, $phase, $release, $target] = $argv + [null, null, null, null];

if (
    !in_array($phase, ['before', 'after'], true) ||
    !is_string($release) ||
    preg_match('/^[a-zA-Z0-9._-]{1,80}$/', $release) !== 1 ||
    !is_string($target)
) {
    fwrite(STDERR, "Usage: php bin/snapshot before|after RELEASE URL\n");
    exit(64);
}

$hosts = array_values(array_filter(array_map(
    static fn(string $host): string => strtolower(trim($host)),
    explode(',', $_ENV['SNAPSHOT_ALLOWED_HOSTS'] ?? '')
)));

try {
    $client = new ScreenshotClient(
        new CurlTransport(),
        $_ENV['SCREENSHOT_TOKEN'] ?? '',
        $hosts
    );

    $capture = $client->capture($target);
    $base = dirname(__DIR__) . "/var/snapshots/{$release}";
    $final = "{$base}/{$phase}";

    if (file_exists($final)) {
        throw new RuntimeException("Snapshot phase already exists: {$phase}");
    }

    if (!is_dir($base) && !mkdir($base, 0750, true) && !is_dir($base)) {
        throw new RuntimeException('Cannot create snapshot directory');
    }

    $temporary = $base . '/.' . $phase . '-' . bin2hex(random_bytes(6));

    if (!mkdir($temporary, 0750)) {
        throw new RuntimeException('Cannot create staging directory');
    }

    $metadata = [
        'release' => $release,
        'phase' => $phase,
        'target' => $target,
        'captured_at' => gmdate(DATE_ATOM),
        'sha256' => hash('sha256', $capture->png),
        'bytes' => strlen($capture->png),
        'attempts' => $capture->attempts,
        'response_headers' => $capture->headers,
    ];

    file_put_contents(
        "{$temporary}/image.png",
        $capture->png,
        LOCK_EX | FILE_BINARY
    );
    file_put_contents(
        "{$temporary}/metadata.json",
        json_encode($metadata, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
        LOCK_EX
    );
    chmod("{$temporary}/image.png", 0640);
    chmod("{$temporary}/metadata.json", 0640);

    if (!rename($temporary, $final)) {
        throw new RuntimeException('Cannot publish snapshot atomically');
    }

    fwrite(STDOUT, json_encode([
        'event' => 'snapshot_captured',
        'release' => $release,
        'phase' => $phase,
        'host' => parse_url($target, PHP_URL_HOST),
        'attempts' => $capture->attempts,
        'sha256' => $metadata['sha256'],
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
} catch (Throwable $error) {
    fwrite(STDERR, json_encode([
        'event' => 'snapshot_failed',
        'release' => $release,
        'phase' => $phase,
        'error_type' => $error::class,
        'message' => $error->getMessage(),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    exit(1);
}

The logs intentionally exclude the token, authorization header, and response body. The metadata retains response headers so cache behavior and remaining quota can be inspected using the names documented for the active service plan.

Test retries without calling the service

A deterministic fake transport makes failure paths fast and repeatable. These tests prove that a server failure is retried, an authentication failure is not retried, and a non-PNG response is rejected.

<?php
// tests/ScreenshotClientTest.php
namespace Tests;

use App\CaptureResult;
use App\HttpResponse;
use App\ScreenshotClient;
use App\ScreenshotException;
use App\Transport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function get(
        string $url,
        array $headers,
        int $connectTimeout,
        int $responseTimeout
    ): HttpResponse {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class ScreenshotClientTest extends TestCase
{
    private const PNG = "\x89PNG\r\n\x1a\nfake";

    public function testRetriesServerFailureThenMapsPng(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(503, '', []),
            new HttpResponse(200, self::PNG, [
                'content-type' => ['image/png'],
                'x-cache' => ['HIT'],
            ]),
        ]);
        $sleeps = [];

        $client = new ScreenshotClient(
            $transport,
            'test-token',
            ['client.example'],
            static function (int $delay) use (&$sleeps): void {
                $sleeps[] = $delay;
            }
        );

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

        self::assertInstanceOf(CaptureResult::class, $result);
        self::assertSame(2, $result->attempts);
        self::assertSame(2, $transport->calls);
        self::assertSame([250_000], $sleeps);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(401, 'unauthorized', []),
        ]);
        $client = new ScreenshotClient(
            $transport,
            'test-token',
            ['client.example'],
            static function (): void {}
        );

        try {
            $client->capture('https://client.example/');
            self::fail('Expected ScreenshotException');
        } catch (ScreenshotException $error) {
            self::assertSame(401, $error->status);
            self::assertSame(1, $transport->calls);
        }
    }

    public function testRejectsNonPngSuccessBody(): void
    {
        $this->expectException(ScreenshotException::class);

        $client = new ScreenshotClient(
            new FakeTransport([
                new HttpResponse(200, '<html>error</html>', [
                    'content-type' => ['text/html'],
                ]),
            ]),
            'test-token',
            ['client.example']
        );

        $client->capture('https://client.example/');
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
  <testsuites>
    <testsuite name="snapshot-tests">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

Connect it to deployment

composer test

php bin/snapshot before release-2026-08-29 https://client.example/

# Run the existing deployment and wait for its health check to pass.

php bin/snapshot after release-2026-08-29 https://client.example/

Make the first capture a deployment prerequisite. If it fails, decide explicitly whether the release may continue. Run the second capture only after the public URL is ready; otherwise it documents an intermediate state rather than the finished update.

On hosts with ephemeral filesystems, copy the completed release directory to durable private storage using your existing artifact process. Keep snapshots access-controlled because they may contain customer names, unpublished offers, account state, or other visible business data.

Monitor structured snapshot_failed events, repeated retries, HTTP 429 responses, and changes in the preserved cache or quota headers. Alerting on every cache miss is usually noise; sustained quota pressure or missing after-captures is operationally meaningful.

Common failures

  • HTTP 401 or 403: verify the service-scoped token and confirm that a regenerated token has been deployed everywhere. Do not retry automatically.
  • HTTP 429: inspect quota headers, honor Retry-After when supplied, and reduce duplicate captures rather than adding unbounded retries.
  • Invalid PNG: preserve the failure event and investigate the status, content type, target accessibility, and current service documentation. Never save the body as an image.
  • Target URL is not allowed: add the exact intended hostname to SNAPSHOT_ALLOWED_HOSTS; do not disable validation.
  • After snapshot differs unexpectedly: confirm that the health check waited for caches, assets, and the intended production hostname before capture.

Final verification checklist

  • The token exists only in environment-backed configuration and secret storage.
  • The exact GET endpoint receives one encoded url query parameter.
  • Both captures open as valid PNG files.
  • Before and after directories share the same release identifier.
  • Metadata contains timestamps, hashes, attempts, and returned response headers.
  • Authentication and validation failures are not retried.
  • Timeouts, 429 responses, and 5xx responses have bounded retry behavior.
  • Snapshot storage and logs reveal no service token.
  • Production artifacts survive deployment-host replacement.

A screenshot pair is simple, but its value comes from discipline: capture the real public site at the right moments, preserve enough evidence to explain failures, and make the process repeatable. With those safeguards in place, every website update gains a visual receipt—one that is far easier to trust than memory, a hurried browser check, or a hopeful “looks good” message.

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.