Tutorials

Native PHP 8.3: Automate Client Website Before/After Snapshots with Screenshot API

Native PHP 8.3: Automate Client Website Before/After Snapshots with Screenshot API

A website update can look perfectly healthy to a deployment script while quietly breaking the page that customers actually see. A missing stylesheet, an oversized banner, or a mobile navigation regression may still return HTTP 200. A visual checkpoint closes that gap.

This tutorial builds a production-oriented Native PHP 8.3 command that captures a client website immediately before and after an update. It uses the Screenshot API to obtain cached desktop or mobile PNG screenshots without operating Chromium infrastructure. Captures are validated, written atomically, accompanied by metadata, and protected by bounded retries and a hostname allowlist.

Get access to the Screenshot API

Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.

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

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 access logs and monitoring systems.

Regenerating the service token revokes the previously active token. Treat rotation as a coordinated deployment: update the runtime secret, restart or redeploy the application, verify a capture, and only then consider the rotation complete.

Confirm the exact HTTP contract

The capture operation is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Its required url query parameter identifies the page to capture. A successful response contains an image/png body plus cache and quota-related response headers.

Test the token before writing application code:

curl --fail-with-body --silent --show-error --get \
  --url 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --data-urlencode 'url=https://www.client.example/' \
  --dump-header capture.headers \
  --output capture.png

file capture.png

Use a real, publicly reachable client URL in place of the example. Keep capture.headers while testing so you can inspect the cache and quota headers returned for your plan without assuming undocumented names.

Store configuration outside the codebase

Create a local .env file and exclude it from version control. Native PHP does not load this file automatically; the shell launcher below exports it before running PHP.

SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'
SCREENSHOT_TARGET_URL='https://www.client.example/'
SCREENSHOT_ALLOWED_HOSTS='www.client.example'
SCREENSHOT_OUTPUT_DIR='var/snapshots'

In production, inject the same variables through your process manager, container secret mechanism, or deployment platform instead of committing an environment file.

Architecture and project structure

The deployment wrapper performs a before capture, runs the existing deployment command, and then performs an after capture. The PHP boundary is intentionally narrow: a transport handles cURL, a client maps HTTP responses into a domain result, and a CLI command owns filesystem persistence.

website-snapshots/
├── bin/
│   ├── snapshot.php
│   └── release-with-snapshots
├── src/
│   └── Screenshot.php
├── tests/
│   └── ScreenshotClientTest.php
├── var/
│   └── snapshots/
├── composer.json
└── .env

This synchronous design makes the before image a deployment gate: if it cannot be captured, the update does not start. The trade-off is a few seconds of deployment latency. For an ordinary client site, that is usually preferable to silently producing an incomplete visual record.

Install the minimal dependencies

The production implementation uses native cURL. PHPUnit 11 supplies deterministic tests and supports PHP 8.3.

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer install
composer dump-autoload

Build the API boundary

Create src/Screenshot.php. The transport is replaceable, which keeps tests off the network. The client validates the target host, applies timeouts, retries only transient failures, verifies both the media type and PNG signature, and retains cache and quota metadata without depending on undocumented header names.

<?php
declare(strict_types=1);

namespace App;

use Closure;
use RuntimeException;
use Throwable;

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

final readonly class ScreenshotResult
{
    public function __construct(
        public string $png,
        public int $status,
        public array $serviceHeaders,
    ) {}
}

final class TransportException extends RuntimeException {}

final class ScreenshotFailure extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        public readonly ?int $status = null,
        ?Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

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

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

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

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$received): int {
                $length = strlen($line);
                $trimmed = trim($line);

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

                return $length;
            },
        ]);

        $body = curl_exec($handle);
        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);

        if ($body === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new TransportException($message);
        }

        curl_close($handle);
        return new HttpResponse($status, $received, $body);
    }
}

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

    private Closure $pause;

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        private readonly array $allowedHosts,
        ?callable $pause = null,
    ) {
        if ($token === '') {
            throw new ScreenshotFailure('configuration', 'Missing service token.');
        }

        $this->pause = $pause === null
            ? static fn (int $milliseconds) => usleep($milliseconds * 1000)
            : Closure::fromCallable($pause);
    }

    public function capture(string $targetUrl): ScreenshotResult
    {
        $this->assertAllowedUrl($targetUrl);

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

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get(
                    $requestUrl,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Accept: image/png',
                    ],
                    3000,
                    30000,
                );
            } catch (TransportException $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotFailure(
                        'transport',
                        'Screenshot service could not be reached.',
                        null,
                        $exception,
                    );
                }

                ($this->pause)($this->backoffMs($attempt));
                continue;
            }

            if ($response->status === 200) {
                return $this->mapSuccess($response);
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new ScreenshotFailure(
                    'authentication',
                    'The Screenshot API rejected the service token.',
                    $response->status,
                );
            }

            $transient = $response->status === 429
                || $response->status >= 500;

            if ($transient && $attempt < 3) {
                ($this->pause)($this->retryDelayMs($response, $attempt));
                continue;
            }

            $kind = $response->status === 429 ? 'quota' : 'request_rejected';

            throw new ScreenshotFailure(
                $kind,
                'Screenshot capture failed with HTTP ' . $response->status . '.',
                $response->status,
            );
        }

        throw new ScreenshotFailure('internal', 'Capture loop ended unexpectedly.');
    }

    private function assertAllowedUrl(string $url): void
    {
        $parts = parse_url($url);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = strtolower((string) ($parts['host'] ?? ''));
        $allowed = array_map('strtolower', $this->allowedHosts);

        if (!filter_var($url, FILTER_VALIDATE_URL)
            || !in_array($scheme, ['http', 'https'], true)
            || $host === ''
            || !in_array($host, $allowed, true)
        ) {
            throw new ScreenshotFailure(
                'invalid_input',
                'Target URL is invalid or its host is not allowed.',
            );
        }
    }

    private function mapSuccess(HttpResponse $response): ScreenshotResult
    {
        $contentType = strtolower(
            trim(explode(';', $this->header($response, 'content-type') ?? '')[0])
        );

        if ($contentType !== 'image/png'
            || !str_starts_with($response->body, "\x89PNG\r\n\x1a\n")
        ) {
            throw new ScreenshotFailure(
                'invalid_response',
                'Successful response did not contain a valid PNG.',
                $response->status,
            );
        }

        $metadata = [];
        $standardCacheHeaders = [
            'cache-control', 'age', 'expires', 'etag', 'last-modified', 'vary',
        ];

        foreach ($response->headers as $name => $values) {
            if (in_array($name, $standardCacheHeaders, true)
                || str_contains($name, 'cache')
                || str_contains($name, 'quota')
                || preg_match('/rate-?limit/', $name) === 1
                || $name === 'retry-after'
            ) {
                $metadata[$name] = $values;
            }
        }

        return new ScreenshotResult(
            $response->body,
            $response->status,
            $metadata,
        );
    }

    private function retryDelayMs(HttpResponse $response, int $attempt): int
    {
        $retryAfter = $this->header($response, 'retry-after');

        if ($retryAfter !== null && ctype_digit($retryAfter)) {
            return min(30000, (int) $retryAfter * 1000);
        }

        return $this->backoffMs($attempt);
    }

    private function backoffMs(int $attempt): int
    {
        return min(5000, 200 * (2 ** ($attempt - 1)) + random_int(0, 100));
    }

    private function header(HttpResponse $response, string $name): ?string
    {
        $values = $response->headers[strtolower($name)] ?? [];
        return $values === [] ? null : $values[array_key_last($values)];
    }
}

Client errors, authentication failures, and malformed successful responses are not blindly retried. Network failures, HTTP 429, and server errors receive at most three attempts. A numeric Retry-After value is honored but capped, preventing a deployment worker from sleeping indefinitely.

Create the snapshot command

Create bin/snapshot.php. It accepts before or after plus a release identifier. Both the PNG and its manifest use temporary files followed by renames, so readers never observe partially written files.

<?php
declare(strict_types=1);

use App\CurlTransport;
use App\ScreenshotClient;
use App\ScreenshotFailure;

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

function environment(string $name): string
{
    $value = getenv($name);

    if ($value === false || $value === '') {
        throw new RuntimeException("Missing environment variable: {$name}");
    }

    return $value;
}

function atomicWrite(string $path, string $contents): void
{
    $temporary = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
    $written = file_put_contents($temporary, $contents, LOCK_EX);

    if ($written !== strlen($contents) || !rename($temporary, $path)) {
        @unlink($temporary);
        throw new RuntimeException("Could not write {$path}");
    }
}

try {
    $stage = $argv[1] ?? '';
    $release = $argv[2] ?? '';

    if (!in_array($stage, ['before', 'after'], true)) {
        throw new RuntimeException('Stage must be before or after.');
    }

    if (preg_match('/^[A-Za-z0-9._-]{1,80}$/', $release) !== 1) {
        throw new RuntimeException('Release identifier is invalid.');
    }

    $targetUrl = environment('SCREENSHOT_TARGET_URL');
    $hosts = array_values(array_filter(array_map(
        'trim',
        explode(',', environment('SCREENSHOT_ALLOWED_HOSTS')),
    )));

    $client = new ScreenshotClient(
        new CurlTransport(),
        environment('SCREENSHOT_API_TOKEN'),
        $hosts,
    );

    $result = $client->capture($targetUrl);
    $directory = rtrim(environment('SCREENSHOT_OUTPUT_DIR'), '/')
        . '/' . $release;

    if (!is_dir($directory) && !mkdir($directory, 0770, true)
        && !is_dir($directory)
    ) {
        throw new RuntimeException('Could not create snapshot directory.');
    }

    $imagePath = $directory . '/' . $stage . '.png';
    $manifestPath = $directory . '/' . $stage . '.json';

    atomicWrite($imagePath, $result->png);
    atomicWrite($manifestPath, json_encode([
        'release' => $release,
        'stage' => $stage,
        'target_host' => parse_url($targetUrl, PHP_URL_HOST),
        'captured_at' => gmdate(DATE_ATOM),
        'bytes' => strlen($result->png),
        'sha256' => hash('sha256', $result->png),
        'service_headers' => $result->serviceHeaders,
    ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));

    fwrite(STDOUT, json_encode([
        'event' => 'snapshot.captured',
        'release' => $release,
        'stage' => $stage,
        'bytes' => strlen($result->png),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
} catch (ScreenshotFailure $failure) {
    fwrite(STDERR, json_encode([
        'event' => 'snapshot.failed',
        'kind' => $failure->kind,
        'status' => $failure->status,
        'message' => $failure->getMessage(),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    exit(2);
} catch (Throwable $failure) {
    fwrite(STDERR, json_encode([
        'event' => 'snapshot.failed',
        'kind' => 'local',
        'message' => $failure->getMessage(),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    exit(1);
}

The logs deliberately exclude the token, full target URL, response body, and response request headers. The manifest records the image digest and only cache or quota-related service headers.

Wrap the real deployment

Create an executable bin/release-with-snapshots. The wrapper accepts the actual deployment and health-check command as its arguments. It refuses to deploy if the before capture fails. Once deployment starts, it attempts the after capture even when the deployment command exits unsuccessfully, preserving evidence of a partial update.

#!/usr/bin/env bash
set -u

if [ "$#" -eq 0 ]; then
  echo "Usage: RELEASE_ID=id bin/release-with-snapshots command [args...]" >&2
  exit 64
fi

: "${RELEASE_ID:?RELEASE_ID must be set}"

set -a
. ./.env
set +a

php bin/snapshot.php before "$RELEASE_ID" || exit $?

"$@"
deploy_status=$?

php bin/snapshot.php after "$RELEASE_ID"
after_status=$?

if [ "$deploy_status" -ne 0 ]; then
  exit "$deploy_status"
fi

exit "$after_status"

Make it executable with chmod 750 bin/release-with-snapshots. Invoke it from the repository root, passing your existing deployment workflow as the remaining arguments. That workflow should include its normal readiness or health check so the after image represents the released page, not an intermediate restart screen.

Test without calling the service

Create tests/ScreenshotClientTest.php. The fake transport supplies exact response sequences and records calls, making retry behavior deterministic and fast.

<?php
declare(strict_types=1);

namespace Tests;

use App\HttpResponse;
use App\ScreenshotClient;
use App\ScreenshotFailure;
use App\Transport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public array $calls = [];

    public function __construct(private array $responses) {}

    public function get(
        string $url,
        array $headers,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        $this->calls[] = compact(
            'url', 'headers', 'connectTimeoutMs', 'timeoutMs'
        );

        return array_shift($this->responses);
    }
}

final class ScreenshotClientTest extends TestCase
{
    private string $png = "\x89PNG\r\n\x1a\nfake-png-data";

    public function testMapsPngAndCacheMetadata(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(200, [
                'content-type' => ['image/png'],
                'cache-control' => ['public, max-age=60'],
                'x-unrelated' => ['ignored'],
            ], $this->png),
        ]);

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

        self::assertSame($this->png, $result->png);
        self::assertArrayHasKey('cache-control', $result->serviceHeaders);
        self::assertArrayNotHasKey('x-unrelated', $result->serviceHeaders);
    }

    public function testRetriesQuotaResponseThenSucceeds(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(429, ['retry-after' => ['0']], ''),
            new HttpResponse(
                200,
                ['content-type' => ['image/png']],
                $this->png,
            ),
        ]);
        $delays = [];

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

        $client->capture('https://client.example/');
        self::assertCount(2, $transport->calls);
        self::assertSame([0], $delays);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(401, ['content-type' => ['application/json']], ''),
        ]);

        try {
            $this->client($transport)->capture('https://client.example/');
            self::fail('Expected an authentication failure.');
        } catch (ScreenshotFailure $failure) {
            self::assertSame('authentication', $failure->kind);
        }

        self::assertCount(1, $transport->calls);
    }

    private function client(FakeTransport $transport): ScreenshotClient
    {
        return new ScreenshotClient(
            $transport,
            'test-token',
            ['client.example'],
            static function (int $milliseconds): void {},
        );
    }
}
vendor/bin/phpunit tests

Security, operations, and common failures

Do not turn this command into an unrestricted screenshot proxy. The hostname allowlist prevents a caller from substituting arbitrary URLs, including internal services. Use exact public hostnames, review redirects at the destination, and keep staging authentication outside this integration unless the documented API contract explicitly supports it.

Restrict access to the snapshot directory because images can contain customer names, unpublished designs, or account data. Define a retention period, back up only what the business needs, and ensure web-server document roots do not expose var/snapshots.

Send the structured JSON events to the logging system already used by the deployment process. Alert on repeated authentication, quota, and invalid_response failures. Compare manifest hashes as a quick signal, but remember that identical hashes prove identical PNG bytes, not that the page is semantically correct.

  • HTTP 401 or 403: verify the environment received the current service-scoped token. A regenerated token makes the old one unusable.
  • HTTP 429: inspect the preserved quota-related headers, confirm plan capacity, and avoid parallel duplicate captures.
  • Invalid PNG: retain the status and content type in operational diagnostics, but never save an unexpected body as an image.
  • Timeouts: confirm that the target is publicly reachable and stable. Do not remove timeout bounds to conceal a slow page.
  • Unexpected before/after content: ensure DNS, CDN invalidation, and the deployment health check have completed before the after capture.

Final verification checklist

  • The service plan is active and the current service token is injected at runtime.
  • The minimal cURL request returns a PNG and exposes response headers for inspection.
  • The configured target hostname exactly matches the allowlist.
  • PHPUnit passes without making an external network request.
  • A dry run creates before.png, after.png, and two JSON manifests under one release directory.
  • Failed authentication is not retried, while network, quota, and server failures receive bounded retries.
  • Logs and stored metadata contain no token or full URL.
  • The production deployment command includes readiness verification before the after capture.

The valuable artifact is not merely a screenshot. It is a trustworthy visual boundary around a change: what customers could see before the release, what they could see afterward, and enough operational context to explain a missing capture. With that boundary automated, visual evidence becomes part of shipping rather than an easily forgotten final check.

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.