Native PHP 8.3: Automate Weekly Website Page Snapshots for Business Owners
A website changes quietly. A new promotion replaces last week’s offer, a booking button moves, a supplier updates an embedded widget, or an accidental deployment removes half the footer. By the time a business owner notices, the previous page may be impossible to reconstruct.
A weekly screenshot archive provides a simple visual audit trail. The Native PHP 8.3 project below captures selected pages as PNG files, records cache and quota metadata, and keeps an indexed history in SQLite. It runs from cron, prevents overlapping executions, retries only transient failures, and requires no locally maintained Chromium installation.
Get access to the Screenshot API
Before writing integration code, create or access your service account:
- 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 the 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 a Bearer token so the credential does not appear in URLs, browser history, proxy request targets, or routine access logs.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update the environment on every scheduled runner before expecting the next capture to succeed.
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 capture. A successful response contains an image/png body, accompanied by response headers carrying cache and quota information.
Make one minimal request before building the archive:
export SCREENSHOT_TOKEN='YOUR_SERVICE_TOKEN'
curl --get \
--fail-with-body \
--connect-timeout 5 \
--max-time 45 \
--header "Authorization: Bearer ${SCREENSHOT_TOKEN}" \
--data-urlencode 'url=https://example.com/' \
--dump-header response-headers.txt \
--output snapshot.png \
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
file snapshot.png
Inspect response-headers.txt to see the cache and quota header names returned for your account and plan. The application will preserve those headers without assuming undocumented names.
Create the project configuration
Use Composer only for autoloading and the development test runner. PHPUnit ^11.0 is appropriate for this PHP 8.3 project; the production integration itself uses native cURL, PDO, and filesystem APIs.
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-pdo": "*",
"ext-sqlite3": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
mkdir -p page-history/{bin,src,tests,storage/screenshots}
cd page-history
composer install
chmod 700 storage storage/screenshots
Store the copied credential in .env, never in PHP source:
SCREENSHOT_TOKEN="YOUR_SERVICE_TOKEN"
SNAPSHOT_PAGES="home=https://example.com/;contact=https://example.com/contact"
SNAPSHOT_RETENTION_DAYS="365"
Add .env, storage/, and generated PNG files to .gitignore. On a production host, make .env readable only by the account running the command. A secret manager or service-level environment variables can replace this file without changing the application.
Architecture and project structure
The design deliberately stays small. A transport owns cURL mechanics, a client enforces the external API contract, and an archive stores validated domain results. The scheduled command coordinates them but knows nothing about PNG validation or retry policy.
page-history/
bin/snapshot-weekly
src/Transport.php
src/ScreenshotClient.php
src/Archive.php
tests/ScreenshotClientTest.php
bootstrap.php
composer.json
phpunit.xml
.env
SQLite is a good fit for one scheduled process and a modest history. The PNG files remain ordinary files, while the database stores searchable metadata. If several machines must capture concurrently, move the manifest to a shared database and the images to durable object storage; do not place a live SQLite database on an unreliable shared filesystem.
Build the native cURL boundary
The transport returns status, normalized headers, and raw bytes. Resetting headers when a new HTTP status line arrives avoids mixing headers from redirects or intermediary responses.
<?php
// src/Transport.php
declare(strict_types=1);
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
interface Transport
{
public function get(
string $url,
array $headers,
int $connectTimeoutMs,
int $timeoutMs,
): HttpResponse;
}
final class TransportException extends \RuntimeException {}
final class CurlTransport implements Transport
{
public function get(
string $url,
array $headers,
int $connectTimeoutMs,
int $timeoutMs,
): HttpResponse {
$responseHeaders = [];
$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_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);
$name = strtolower(trim($name));
$value = trim($value);
$responseHeaders[$name][] = $value;
}
return $length;
},
]);
$body = curl_exec($handle);
if ($body === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportException('Screenshot transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $responseHeaders, $body);
}
}
The service client classifies failures and validates the response at the application boundary. It retries connection failures, HTTP 429 responses, and server errors. It does not retry validation failures, authentication failures, or other ordinary client errors.
<?php
// src/ScreenshotClient.php
declare(strict_types=1);
namespace App;
final class ScreenshotException extends \RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
public readonly array $responseHeaders = [],
) {
parent::__construct($message);
}
}
final readonly class CaptureResult
{
public function __construct(
public string $png,
public array $headers,
) {}
public function cacheMetadata(): array
{
return $this->matchingHeaders(
['cache', 'age', 'etag', 'expires', 'last-modified']
);
}
public function quotaMetadata(): array
{
return $this->matchingHeaders(
['quota', 'rate-limit', 'ratelimit']
);
}
private function matchingHeaders(array $needles): array
{
return array_filter(
$this->headers,
static fn(array $values, string $name): bool =>
array_any(
$needles,
static fn(string $needle): bool =>
str_contains($name, $needle)
),
ARRAY_FILTER_USE_BOTH
);
}
}
final class ScreenshotClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';
public function __construct(
private readonly Transport $transport,
private readonly string $token,
private readonly \Closure $sleep =
new \Closure(),
) {}
public static function create(
Transport $transport,
string $token,
?\Closure $sleep = null,
): self {
return new self(
$transport,
$token,
$sleep ?? static fn(int $milliseconds) =>
usleep($milliseconds * 1000)
);
}
public function capture(string $pageUrl): CaptureResult
{
if ($this->token === '') {
throw new ScreenshotException('Screenshot token is missing');
}
$parts = parse_url($pageUrl);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true)
|| empty($parts['host'])) {
throw new ScreenshotException('Configured page URL is invalid');
}
$url = self::ENDPOINT . '?' . http_build_query(
['url' => $pageUrl],
'',
'&',
PHP_QUERY_RFC3986
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get(
$url,
[
'Authorization: Bearer ' . $this->token,
'Accept: image/png',
],
5000,
45000
);
} catch (TransportException $exception) {
if ($attempt === 3) {
throw new ScreenshotException(
'Screenshot service could not be reached'
);
}
($this->sleep)($this->backoffMs($attempt));
continue;
}
if (($response->status === 429 || $response->status >= 500)
&& $attempt < 3) {
($this->sleep)(
$this->retryDelayMs($response, $attempt)
);
continue;
}
if ($response->status !== 200) {
throw new ScreenshotException(
'Screenshot service returned HTTP ' . $response->status,
$response->status,
$response->headers
);
}
$contentType = strtolower(
$response->headers['content-type'][0] ?? ''
);
if (!str_starts_with($contentType, 'image/png')
|| !str_starts_with($response->body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotException(
'Screenshot response was not a valid PNG',
200,
$response->headers
);
}
return new CaptureResult($response->body, $response->headers);
}
throw new ScreenshotException('Screenshot capture failed');
}
private function retryDelayMs(
HttpResponse $response,
int $attempt,
): int {
$retryAfter = $response->headers['retry-after'][0] ?? null;
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
return min(5000, (int) $retryAfter * 1000);
}
return $this->backoffMs($attempt);
}
private function backoffMs(int $attempt): int
{
return min(5000, 250 * (2 ** ($attempt - 1)));
}
}
The backoff is intentionally bounded. A weekly process should tolerate a brief service interruption, but it should not occupy the scheduler indefinitely. A numeric Retry-After value is honored up to five seconds; otherwise the client uses a short exponential delay.
Persist PNGs and response metadata atomically
The archive creates the database schema on first use. It writes each image to a temporary file, renames it atomically, and then commits the manifest row. Cache and quota headers are saved as JSON so operational behavior can be reviewed without hard-coding undocumented header names.
<?php
// src/Archive.php
declare(strict_types=1);
namespace App;
final class Archive
{
private \PDO $database;
public function __construct(private readonly string $directory)
{
$images = $directory . '/screenshots';
if (!is_dir($images)
&& !mkdir($images, 0700, true)
&& !is_dir($images)) {
throw new \RuntimeException('Cannot create screenshot directory');
}
$this->database = new \PDO(
'sqlite:' . $directory . '/history.sqlite',
null,
null,
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]
);
$this->database->exec(
'CREATE TABLE IF NOT EXISTS captures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_name TEXT NOT NULL,
page_url TEXT NOT NULL,
captured_at TEXT NOT NULL,
file_path TEXT NOT NULL,
cache_headers TEXT NOT NULL,
quota_headers TEXT NOT NULL
)'
);
}
public function store(
string $name,
string $url,
CaptureResult $capture,
): string {
$time = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$safeName = preg_replace('/[^a-z0-9_-]+/i', '-', $name) ?: 'page';
$filename = sprintf(
'%s-%s-%s.png',
$safeName,
$time->format('Ymd-His'),
bin2hex(random_bytes(4))
);
$relative = 'screenshots/' . $filename;
$final = $this->directory . '/' . $relative;
$temporary = $final . '.tmp';
if (file_put_contents($temporary, $capture->png, LOCK_EX) === false
|| !rename($temporary, $final)) {
@unlink($temporary);
throw new \RuntimeException('Cannot persist screenshot');
}
try {
$statement = $this->database->prepare(
'INSERT INTO captures
(page_name, page_url, captured_at, file_path,
cache_headers, quota_headers)
VALUES (:name, :url, :time, :path, :cache, :quota)'
);
$statement->execute([
':name' => $name,
':url' => $url,
':time' => $time->format(DATE_ATOM),
':path' => $relative,
':cache' => json_encode(
$capture->cacheMetadata(),
JSON_THROW_ON_ERROR
),
':quota' => json_encode(
$capture->quotaMetadata(),
JSON_THROW_ON_ERROR
),
]);
} catch (\Throwable $exception) {
@unlink($final);
throw $exception;
}
return $relative;
}
}
Create the weekly command
The bootstrap file loads local environment configuration without overwriting variables already supplied by the operating system.
<?php
// bootstrap.php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$file = __DIR__ . '/.env';
if (is_file($file)) {
$values = parse_ini_file($file, false, INI_SCANNER_RAW);
if ($values === false) {
throw new RuntimeException('Cannot parse .env');
}
foreach ($values as $name => $value) {
if (getenv((string) $name) === false) {
putenv($name . '=' . $value);
$_ENV[(string) $name] = (string) $value;
}
}
}
The command acquires a non-blocking lock, parses the named page allowlist, captures every page independently, and emits one JSON log event per result. One failure does not prevent the remaining pages from being archived, but any failure produces a nonzero exit status.
<?php
// bin/snapshot-weekly
declare(strict_types=1);
use App\Archive;
use App\CurlTransport;
use App\ScreenshotClient;
require dirname(__DIR__) . '/bootstrap.php';
$storage = dirname(__DIR__) . '/storage';
@mkdir($storage, 0700, true);
$lock = fopen($storage . '/weekly.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
fwrite(STDERR, "{\"event\":\"capture_already_running\"}\n");
exit(1);
}
$token = (string) (getenv('SCREENSHOT_TOKEN') ?: '');
$configured = (string) (getenv('SNAPSHOT_PAGES') ?: '');
$pages = [];
foreach (array_filter(explode(';', $configured)) as $entry) {
[$name, $url] = array_pad(explode('=', $entry, 2), 2, '');
$name = trim($name);
$url = trim($url);
if ($name === '' || $url === '') {
throw new RuntimeException('Invalid SNAPSHOT_PAGES entry');
}
$pages[$name] = $url;
}
if ($pages === []) {
throw new RuntimeException('No snapshot pages are configured');
}
$client = ScreenshotClient::create(new CurlTransport(), $token);
$archive = new Archive($storage);
$failures = 0;
foreach ($pages as $name => $url) {
try {
$capture = $client->capture($url);
$path = $archive->store($name, $url, $capture);
fwrite(STDOUT, json_encode([
'event' => 'capture_stored',
'page' => $name,
'path' => $path,
'bytes' => strlen($capture->png),
'cache' => $capture->cacheMetadata(),
'quota' => $capture->quotaMetadata(),
], JSON_THROW_ON_ERROR) . "\n");
} catch (Throwable $exception) {
$failures++;
fwrite(STDERR, json_encode([
'event' => 'capture_failed',
'page' => $name,
'error_class' => $exception::class,
'message' => $exception->getMessage(),
], JSON_THROW_ON_ERROR) . "\n");
}
}
flock($lock, LOCK_UN);
fclose($lock);
exit($failures === 0 ? 0 : 1);
Make it executable and run it once interactively:
chmod 750 bin/snapshot-weekly
php bin/snapshot-weekly
sqlite3 storage/history.sqlite \
'SELECT page_name, captured_at, file_path FROM captures ORDER BY id DESC;'
Test retries and boundary validation
A deterministic fake transport makes failure paths testable without spending quota or depending on the network.
<?php
// tests/ScreenshotClientTest.php
declare(strict_types=1);
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 $connectTimeoutMs,
int $timeoutMs,
): HttpResponse {
$this->calls++;
return array_shift($this->responses);
}
}
final class ScreenshotClientTest extends TestCase
{
private const PNG = "\x89PNG\r\n\x1a\nfake-test-data";
public function testMapsPngAndOperationalHeaders(): void
{
$transport = new FakeTransport([
new HttpResponse(200, [
'content-type' => ['image/png'],
'x-cache' => ['HIT'],
'x-quota-remaining' => ['9'],
], self::PNG),
]);
$client = ScreenshotClient::create(
$transport,
'test-token',
static fn(int $milliseconds) => null
);
$result = $client->capture('https://example.com/');
self::assertSame(self::PNG, $result->png);
self::assertSame(['x-cache' => ['HIT']],
$result->cacheMetadata());
self::assertSame(['x-quota-remaining' => ['9']],
$result->quotaMetadata());
}
public function testRetriesRateLimitThenSucceeds(): void
{
$transport = new FakeTransport([
new HttpResponse(429, ['retry-after' => ['0']], ''),
new HttpResponse(
200,
['content-type' => ['image/png']],
self::PNG
),
]);
ScreenshotClient::create(
$transport,
'test-token',
static fn(int $milliseconds) => null
)->capture('https://example.com/');
self::assertSame(2, $transport->calls);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([
new HttpResponse(401, ['content-type' => ['text/plain']], ''),
]);
$client = ScreenshotClient::create(
$transport,
'test-token',
static fn(int $milliseconds) => null
);
try {
$client->capture('https://example.com/');
self::fail('Expected ScreenshotException');
} catch (ScreenshotException $exception) {
self::assertSame(401, $exception->status);
self::assertSame(1, $transport->calls);
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="application">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
vendor/bin/phpunit
Deploy and operate it safely
Install the project under a dedicated system account, keep storage outside any public web root, and schedule the command with an absolute path. This cron entry runs every Monday at 06:15 in the server’s configured timezone:
15 6 * * 1 cd /srv/page-history && /usr/bin/php bin/snapshot-weekly
Send stdout and stderr to your normal log collector or cron mail. Alert on a nonzero exit code, repeated capture_failed events, and quota metadata approaching the limit indicated by the actual response headers. Logs deliberately omit the token and image body.
Keep page URLs configuration-controlled rather than accepting arbitrary user input. This prevents the scheduler from becoming a general-purpose URL submission mechanism. Capture only pages the business is permitted to archive, and remember that screenshots may contain prices, customer-facing notices, or other commercially sensitive material.
The example records a retention setting but does not delete anything automatically. Add deletion only after deciding the business retention policy, backup expectations, and whether database records should disappear with their PNG files. Silent, unreviewed cleanup is a poor trade for saving a little disk space.
Common failures
- HTTP 401 or 403: verify the service-scoped token and check whether it was regenerated. These failures are not retried.
- HTTP 429: inspect recorded quota headers, reduce the page set, adjust scheduling, or review the active plan. The client performs only bounded retries.
- HTTP 4xx: verify that the configured URL is valid and reachable as intended. Retrying an unchanged invalid request will not help.
- HTTP 5xx or transport errors: the command retries briefly, then records a structured failure and exits nonzero.
- Invalid PNG: an intermediary or service error may have returned another content type. The client rejects it instead of archiving misleading data.
- Permission errors: ensure the scheduled account can read configuration and write the SQLite database, lock file, and screenshot directory.
Final verification checklist
- The service plan is active and the token came from the documentation page’s Service token panel.
- The token exists only in protected environment configuration and is absent from source control and logs.
- The minimal request produces a PNG and exposes cache and quota headers.
php bin/snapshot-weeklycreates one PNG and one SQLite row per configured page.- A second overlapping process is rejected by the lock.
- PHPUnit verifies successful mapping, bounded 429 retry behavior, and non-retryable authentication failure.
- The scheduler uses absolute paths, captures logs, and alerts on nonzero exit status.
- Backups and retention rules cover both
history.sqliteandstorage/screenshots.
A useful visual history does not need a browser farm or an elaborate observability platform. It needs a precise API boundary, disciplined credential handling, trustworthy storage, and a scheduler that fails visibly. Once those pieces are in place, every Monday’s PNG becomes more than a screenshot: it becomes a dependable record of what customers could actually see.