Capture Client Website's Visual State for Audits with Native PHP Screenshots
A website update can look correct in a pull request and still arrive with a missing font, an unexpected consent banner, or a broken responsive layout. A before-and-after screenshot pair gives a freelancer or small team a durable visual audit record without requiring anyone to maintain Chromium, browser drivers, or a screenshot worker.
This tutorial builds that workflow as a production-oriented Native PHP 8.3 application. A deployment command captures the public site immediately before and after a release, validates the PNG response, writes it atomically, and records cache and quota-related headers for later diagnosis.
Get access and create a service token
Access setup happens before any integration code:
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Screenshot API service page.
- Choose an available Free, Plus, or Pro plan and complete its activation.
- Open the official 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 a Bearer token so the credential does not appear in URLs, proxy access logs, or browser history.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: install the new value everywhere the audit command runs, verify it, and remove any stale secret from your deployment platform.
Confirm the exact endpoint
The API contract used here is:
GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Required query parameter: url
Successful body: image/png
Run one minimal request locally, writing headers and image data to separate files:
export SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'
curl --fail-with-body \
--connect-timeout 5 \
--max-time 45 \
--get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
--header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
--data-urlencode 'url=https://client.example/' \
--dump-header /tmp/screenshot-headers.txt \
--output /tmp/screenshot.png
file /tmp/screenshot.png
Do not commit either the token or a real client screenshot. The API may serve a cached capture, so inspect the returned cache and quota headers when diagnosing freshness or capacity rather than assuming every request launches a new capture.
Architecture and trade-offs
The smallest dependable design has four boundaries: a cURL transport performs HTTP, a screenshot client owns retries and response validation, a DTO exposes the PNG plus operational metadata, and a CLI command owns audit naming and persistence.
The command is synchronous by design. A deployment must not label itself visually verified while its capture is still queued somewhere else. The trade-off is added deployment time, bounded here by explicit timeouts and a small retry budget.
The project layout is deliberately modest:
website-audit/
├── bin/capture.php
├── config/bootstrap.php
├── src/HttpResponse.php
├── src/Transport.php
├── src/CurlTransport.php
├── src/Screenshot.php
├── src/ScreenshotException.php
├── src/ScreenshotClient.php
├── tests/ScreenshotClientTest.php
├── var/audits/
├── .env
├── .env.example
├── .gitignore
└── composer.json
Configure the Native PHP project
Prerequisites are PHP 8.3 or newer, the cURL and JSON extensions, Composer, and PHPUnit for tests. Create the project and install the development dependency:
composer init --name=example/website-audit --no-interaction
composer require --dev phpunit/phpunit:^11.0
Add PSR-4 autoloading to composer.json and regenerate the autoloader:
{
"name": "example/website-audit",
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer dump-autoload
cp .env.example .env
chmod 600 .env
Put placeholders in .env.example, then place the real token only in the untracked .env file or your production secret manager:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
CLIENT_ALLOWED_HOST=client.example
AUDIT_DIRECTORY=var/audits
Add .env and var/audits/ to .gitignore. The bootstrap may load simple environment files without introducing a runtime package:
<?php
declare(strict_types=1);
$envFile = dirname(__DIR__) . '/.env';
if (is_file($envFile)) {
$values = parse_ini_file($envFile, false, INI_SCANNER_RAW);
if ($values === false) {
throw new RuntimeException('Unable to parse .env');
}
foreach ($values as $name => $value) {
if (getenv((string) $name) === false) {
putenv($name . '=' . $value);
}
}
}
function requiredEnv(string $name): string
{
$value = getenv($name);
if ($value === false || trim($value) === '') {
throw new RuntimeException("Missing environment variable: {$name}");
}
return $value;
}
Build a defensive API boundary
The transport returns status, normalized headers, and bytes. Keeping this interface small makes tests deterministic and prevents cURL details from leaking into the deployment command.
<?php
// src/HttpResponse.php
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
// src/Transport.php
namespace App;
interface Transport
{
public function get(string $url, array $headers): HttpResponse;
}
// src/CurlTransport.php
namespace App;
use RuntimeException;
final class CurlTransport implements Transport
{
public function get(string $url, array $headers): HttpResponse
{
$received = [];
$body = '';
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 45,
CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$received): int {
$length = strlen($line);
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$received[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return $length;
},
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$body): int {
if (strlen($body) + strlen($chunk) > 20 * 1024 * 1024) {
return 0;
}
$body .= $chunk;
return strlen($chunk);
},
]);
if (curl_exec($handle) === false) {
$message = curl_error($handle);
curl_close($handle);
throw new RuntimeException('Screenshot transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $received, $body);
}
}
The 20 MiB limit prevents an abnormal response from exhausting PHP memory. Redirects are disabled because the service endpoint is fixed; the target page URL remains a query value for the remote service to process.
Map success and failure into domain objects
Response metadata should be treated defensively. Standard cache headers are preserved explicitly. Quota-related headers are collected by semantic name rather than assuming undocumented fields exist.
<?php
// src/Screenshot.php
namespace App;
final readonly class Screenshot
{
public function __construct(
public string $png,
public array $cacheHeaders,
public array $quotaHeaders,
) {}
}
// src/ScreenshotException.php
namespace App;
use RuntimeException;
final class ScreenshotException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
) {
parent::__construct($message);
}
}
// src/ScreenshotClient.php
namespace App;
use Closure;
use Throwable;
final class ScreenshotClient
{
public function __construct(
private Transport $transport,
private string $token,
private Closure $sleep = new Closure(),
) {
if ($this->sleep === new Closure()) {
$this->sleep = static fn(int $microseconds) => usleep($microseconds);
}
}
public function capture(string $targetUrl): Screenshot
{
$endpoint = 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
. '?url=' . rawurlencode($targetUrl);
for ($attempt = 0; $attempt < 3; $attempt++) {
try {
$response = $this->transport->get($endpoint, [
'Accept: image/png',
'Authorization: Bearer ' . $this->token,
]);
} catch (Throwable $error) {
if ($attempt === 2) {
throw new ScreenshotException('Transport failed after retries.');
}
($this->sleep)(250_000 * (2 ** $attempt));
continue;
}
if ($response->status === 429 || $response->status >= 500) {
if ($attempt === 2) {
throw new ScreenshotException('Temporary API failure.', $response->status);
}
($this->sleep)($this->delay($response, $attempt));
continue;
}
if ($response->status === 401 || $response->status === 403) {
throw new ScreenshotException('Authentication was rejected.', $response->status);
}
if ($response->status < 200 || $response->status >= 300) {
throw new ScreenshotException('Capture request was rejected.', $response->status);
}
$type = strtolower(explode(';', $response->headers['content-type'] ?? '')[0]);
if ($type !== 'image/png' || !str_starts_with($response->body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotException('API returned an invalid PNG.', $response->status);
}
return new Screenshot(
$response->body,
array_intersect_key($response->headers, array_flip([
'cache-control', 'age', 'etag', 'expires',
])),
array_filter(
$response->headers,
static fn(string $name): bool =>
$name === 'retry-after'
|| str_contains($name, 'rate')
|| str_contains($name, 'quota'),
ARRAY_FILTER_USE_KEY,
),
);
}
throw new ScreenshotException('Capture failed.');
}
private function delay(HttpResponse $response, int $attempt): int
{
$retryAfter = $response->headers['retry-after'] ?? null;
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
return min((int) $retryAfter, 5) * 1_000_000;
}
return 250_000 * (2 ** $attempt);
}
}
In production code, initialize the sleeper explicitly with Closure::fromCallable('usleep'); this keeps the delay injectable in tests. Only transport failures, HTTP 429, and server errors are retried. Authentication and request failures stop immediately because repetition cannot repair them.
Create the audit command
The command accepts a phase, release identifier, and HTTPS URL. A host allowlist stops an operator or compromised pipeline variable from turning the screenshot service into a probe for arbitrary targets.
<?php
// bin/capture.php
declare(strict_types=1);
use App\CurlTransport;
use App\ScreenshotClient;
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/config/bootstrap.php';
[$script, $phase, $release, $url] = $argv + [null, null, null, null];
if (!in_array($phase, ['before', 'after'], true)) {
throw new InvalidArgumentException('Phase must be before or after.');
}
if (!is_string($release) || !preg_match('/\A[a-zA-Z0-9._-]{1,80}\z/', $release)) {
throw new InvalidArgumentException('Invalid release identifier.');
}
$parts = is_string($url) ? parse_url($url) : false;
$allowedHost = requiredEnv('CLIENT_ALLOWED_HOST');
if (
$parts === false
|| ($parts['scheme'] ?? null) !== 'https'
|| strcasecmp($parts['host'] ?? '', $allowedHost) !== 0
|| isset($parts['user'])
|| isset($parts['pass'])
) {
throw new InvalidArgumentException('URL must use HTTPS on the allowed client host.');
}
$client = new ScreenshotClient(
new CurlTransport(),
requiredEnv('SCREENSHOT_API_TOKEN'),
Closure::fromCallable('usleep'),
);
$screenshot = $client->capture($url);
$root = dirname(__DIR__) . '/' . trim(requiredEnv('AUDIT_DIRECTORY'), '/');
$directory = $root . '/' . $release;
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
throw new RuntimeException('Could not create audit directory.');
}
$imagePath = $directory . '/' . $phase . '.png';
$tempPath = $imagePath . '.tmp-' . bin2hex(random_bytes(6));
if (file_put_contents($tempPath, $screenshot->png, LOCK_EX) === false
|| !rename($tempPath, $imagePath)) {
@unlink($tempPath);
throw new RuntimeException('Could not persist screenshot.');
}
$metadata = [
'phase' => $phase,
'release' => $release,
'url' => $url,
'captured_at' => gmdate(DATE_ATOM),
'sha256' => hash('sha256', $screenshot->png),
'cache_headers' => $screenshot->cacheHeaders,
'quota_headers' => $screenshot->quotaHeaders,
];
file_put_contents(
$directory . '/' . $phase . '.json',
json_encode($metadata, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL,
LOCK_EX,
);
fwrite(STDOUT, json_encode([
'event' => 'screenshot.saved',
'phase' => $phase,
'release' => $release,
'path' => $imagePath,
], JSON_THROW_ON_ERROR) . PHP_EOL);
The JSON written to standard output is suitable for structured deployment logs. It deliberately excludes the token and image body. The adjacent metadata file keeps checksums, cache information, and any returned quota signals with the evidence itself.
Automate the before-and-after workflow
Wrap the existing release action with the two captures. Use the same stable public URL for both so the comparison measures deployment state rather than route differences:
RELEASE_ID="release-2026-10-10-1"
AUDIT_URL="https://client.example/"
php bin/capture.php before "$RELEASE_ID" "$AUDIT_URL"
./deploy-existing-release.sh
php bin/capture.php after "$RELEASE_ID" "$AUDIT_URL"
If the first capture fails, stop before deployment unless the team has explicitly classified screenshots as non-blocking. If deployment succeeds but the second capture fails, report a partial audit and rerun only the after phase. Never disguise a missing image by copying the other phase.
Test without calling the service
A fake transport makes retry behavior and response mapping reproducible. No test fixture contains a real credential.
<?php
namespace Tests;
use App\HttpResponse;
use App\ScreenshotClient;
use App\ScreenshotException;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class ScreenshotClientTest extends TestCase
{
public function testMapsPngAndOperationalHeaders(): void
{
$fake = new SequenceTransport([
new HttpResponse(200, [
'content-type' => 'image/png',
'cache-control' => 'public, max-age=60',
'x-rate-limit-remaining' => '9',
], "\x89PNG\r\n\x1a\npayload"),
]);
$result = (new ScreenshotClient(
$fake,
'test-token',
static fn(int $delay) => null,
))->capture('https://client.example/');
self::assertSame('public, max-age=60', $result->cacheHeaders['cache-control']);
self::assertSame('9', $result->quotaHeaders['x-rate-limit-remaining']);
self::assertSame(1, $fake->calls);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$fake = new SequenceTransport([
new HttpResponse(401, ['content-type' => 'application/json'], '{}'),
]);
try {
(new ScreenshotClient($fake, 'bad-token', static fn(int $delay) => null))
->capture('https://client.example/');
self::fail('Expected ScreenshotException');
} catch (ScreenshotException $error) {
self::assertSame(401, $error->status);
self::assertSame(1, $fake->calls);
}
}
}
final class SequenceTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(string $url, array $headers): HttpResponse
{
return $this->responses[$this->calls++];
}
}
vendor/bin/phpunit tests
Security, observability, and deployment details
Restrict the token to environment-backed configuration, protect the environment file with operating-system permissions, and never print request headers. If the token is regenerated, replace it atomically across deployment environments because the old token stops working.
Client screenshots may contain names, account information, unpublished offers, or consent state. Keep var/audits outside the public document root, define a retention policy, and give access only to people who need the audit. Encrypt the storage volume or object-storage destination when the images are sensitive.
Log the release, phase, HTTP status category, attempt count, duration, and final outcome. Do not log the Bearer token, PNG bytes, or an unrestricted URL containing sensitive query values. Alert on repeated authentication failures, exhausted retries, and absent after images.
Production containers need PHP’s cURL extension, a writable persistent audit directory, trusted certificate authorities, outbound HTTPS access to ai.mihajlo.mk, and enough memory for the configured 20 MiB ceiling. Run a smoke capture after deployment and after secret rotation.
Common failure modes
- HTTP 401 or 403: verify activation and the current service-scoped token. Do not retry blindly.
- HTTP 429: the quota or rate limit has been reached. Honor a numeric
Retry-Afterwithin a bounded delay, then surface the failure if retries are exhausted. - HTTP 5xx or network timeout: retry briefly with exponential backoff; keep the deployment outcome explicit if recovery fails.
- Successful status with a non-PNG body: reject it. Status alone is insufficient; validate both
Content-Typeand the PNG signature. - Unexpectedly old-looking image: examine recorded cache headers before blaming deployment. Cached capture behavior is part of the service purpose.
- Both images look identical: compare their SHA-256 values, confirm the release actually reached the public host, and check whether application or edge caches still serve the earlier version.
Final verification checklist
- The active plan is enabled and the current service token is stored outside source control.
- The command calls exactly
GET https://ai.mihajlo.mk/api/screenshot-api/v1/capturewith the requiredurlquery parameter. - Only the approved HTTPS client host can be captured.
- Connection, total-duration, response-size, retry, and backoff limits are bounded.
- Authentication and validation failures are not retried.
- The response is verified as a PNG before it is persisted atomically.
- Cache and quota-related response headers are retained without assuming undocumented fields.
- Tests pass through a deterministic fake transport without external requests.
- A real release directory contains distinct
before.png,after.png, and metadata files.
A screenshot pair is simple evidence, but that simplicity is its strength. It turns “the release looked fine” into a dated, checksummed artifact tied to a specific deployment. With the browser infrastructure delegated to the Screenshot API and the integration bounded by careful validation, retries, security controls, and tests, visual auditing becomes an ordinary part of shipping rather than an unreliable task someone remembers afterward.