Native PHP 8.3: Weekly Page Screenshots for Small Businesses with Screenshot API
A website can break without going offline. A missing promotion, collapsed navigation menu, expired certificate warning, or accidental redesign may leave every health check green while customers see the wrong page. For a small business, a weekly screenshot is a simple, legible audit trail: open a folder and see what the storefront, booking page, or contact page looked like each week.
This tutorial builds that history collector in native PHP 8.3. A scheduled command captures several business pages, validates the PNG response, writes it atomically, and records cache and quota information for operations. It uses the Screenshot API so the application does not have to install, patch, or monitor Chromium.
Get access and copy the service token
- 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 Screenshot API documentation. Find the Service token panel and copy the service-scoped token shown there.
- Store that token in the project’s environment configuration. Regenerating the service token revokes the previously active token, so deployments using the old value must be updated together.
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 because it keeps credentials out of URLs, browser history, and common proxy access logs.
Verify the exact API call
The capture operation is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Its required url query parameter identifies the page to capture. The successful body is PNG data, not JSON.
SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'
curl --fail-with-body --silent --show-error \
--get \
--header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
--data-urlencode "url=https://example.com/" \
--dump-header response-headers.txt \
--output test.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
file test.png
Inspect both test.png and response-headers.txt. The latter contains the cache and quota headers returned by the service. Their values belong in operational telemetry, while the binary response must never be written to a text log.
For the application, create .env.local and keep it out of version control:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
Add .env.local, var/, and test captures to .gitignore. In production, an operating-system environment file or secret manager can provide the same variable; the application code does not change.
Choose a deliberately small architecture
A weekly collector for three or five pages does not need a queue cluster. One synchronous CLI command, launched by cron, is easier to inspect and recover. The important production properties are elsewhere:
- A dedicated API boundary owns authentication, timeouts, retries, and response validation.
- A transport interface makes tests deterministic without contacting the service.
- A process lock prevents overlapping scheduled runs.
- An ISO week filename makes a repeated run idempotent.
- A temporary file and atomic rename prevent half-written screenshots.
- Structured logs expose outcomes and operational headers without exposing credentials or PNG bytes.
The images live under var/screenshots/<page>/<ISO-year>-W<week>.png. Keep this directory outside the public web root. It can later be mounted into an authenticated owner portal or included in encrypted backups.
weekly-shots/
├── bin/capture-weekly.php
├── config/bootstrap.php
├── config/pages.php
├── src/Http/Response.php
├── src/Http/Transport.php
├── src/Http/CurlTransport.php
├── src/Screenshot/Capture.php
├── src/Screenshot/ScreenshotClient.php
├── src/Screenshot/ScreenshotException.php
├── tests/ScreenshotClientTest.php
├── composer.json
└── var/screenshots/
Use Composer only for autoloading and PHPUnit. Runtime HTTP calls remain native cURL:
{
"require": {
"php": "^8.3",
"ext-curl": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"scripts": {
"test": "phpunit tests"
}
}
Build a bounded native cURL transport
The transport collects all response headers rather than guessing undocumented names. Domain code can retain the complete boundary response and separately select cache, quota, rate-limit, and retry information for logs.
<?php
// src/Http/Response.php
namespace App\Http;
final readonly class Response
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
// src/Http/Transport.php
namespace App\Http;
interface Transport
{
public function get(
string $url,
array $headers,
int $connectTimeout,
int $timeout,
): Response;
}
final class TransportException extends \RuntimeException {}
<?php
// src/Http/CurlTransport.php
namespace App\Http;
final class CurlTransport implements Transport
{
public function get(
string $url,
array $headers,
int $connectTimeout,
int $timeout,
): Response {
$handle = curl_init($url);
if ($handle === false) {
throw new TransportException('Unable to initialize cURL.');
}
$receivedHeaders = [];
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_USERAGENT => 'weekly-page-history/1.0',
CURLOPT_HEADERFUNCTION => static function (
$curl,
string $line
) use (&$receivedHeaders): int {
$length = strlen($line);
$line = trim($line);
if ($line === '' || !str_contains($line, ':')) {
return $length;
}
[$name, $value] = explode(':', $line, 2);
$receivedHeaders[strtolower(trim($name))][] = trim($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 Response($status, $receivedHeaders, $body);
}
}
Redirects are disabled for the API request, TLS verification remains enabled by default, and separate connection and total timeouts keep a stalled dependency from occupying the weekly process indefinitely.
Map the response into the screenshot domain
A successful HTTP status alone is insufficient. A proxy or upstream error could return HTML with a 200 status. The client therefore checks both Content-Type and the eight-byte PNG signature.
<?php
// src/Screenshot/Capture.php
namespace App\Screenshot;
final readonly class Capture
{
public function __construct(
public string $png,
public array $headers,
) {}
public function operationalHeaders(): array
{
return array_filter(
$this->headers,
static fn (string $name): bool =>
preg_match('/cache|quota|rate.?limit|retry-after/i', $name) === 1,
ARRAY_FILTER_USE_KEY,
);
}
}
// src/Screenshot/ScreenshotException.php
namespace App\Screenshot;
final class ScreenshotException extends \RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
public readonly array $headers = [],
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
<?php
// src/Screenshot/ScreenshotClient.php
namespace App\Screenshot;
use App\Http\Response;
use App\Http\Transport;
use App\Http\TransportException;
final class ScreenshotClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';
private \Closure $sleep;
public function __construct(
private Transport $transport,
private string $token,
private int $maxAttempts = 3,
?callable $sleeper = null,
) {
if ($token === '') {
throw new \InvalidArgumentException('Screenshot token is missing.');
}
$this->sleep = $sleeper === null
? static fn (int $seconds) => sleep($seconds)
: \Closure::fromCallable($sleeper);
}
public function capture(string $pageUrl): Capture
{
if (filter_var($pageUrl, FILTER_VALIDATE_URL) === false) {
throw new \InvalidArgumentException('Page URL is invalid.');
}
$url = self::ENDPOINT . '?' . http_build_query(
['url' => $pageUrl],
'',
'&',
PHP_QUERY_RFC3986,
);
for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
try {
$response = $this->transport->get(
$url,
[
'Authorization: Bearer ' . $this->token,
'Accept: image/png',
],
5,
30,
);
} catch (TransportException $exception) {
if ($attempt === $this->maxAttempts) {
throw new ScreenshotException(
'Screenshot transport exhausted its retries.',
previous: $exception,
);
}
($this->sleep)(min(4, 2 ** ($attempt - 1)));
continue;
}
if ($this->shouldRetry($response) && $attempt < $this->maxAttempts) {
($this->sleep)($this->retryDelay($response, $attempt));
continue;
}
if ($response->status < 200 || $response->status >= 300) {
throw new ScreenshotException(
'Screenshot API 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 API returned a non-PNG response.',
$response->status,
$response->headers,
);
}
return new Capture($response->body, $response->headers);
}
throw new \LogicException('Unreachable retry state.');
}
private function shouldRetry(Response $response): bool
{
return $response->status === 429 || $response->status >= 500;
}
private function retryDelay(Response $response, int $attempt): int
{
$value = $response->headers['retry-after'][0] ?? null;
if (is_string($value) && ctype_digit($value)) {
return max(1, min(10, (int) $value));
}
return min(4, 2 ** ($attempt - 1));
}
}
Only transport failures, HTTP 429, and server-side failures are retried. Authentication and validation failures are not: repeating a bad token or malformed request merely wastes quota and delays diagnosis. Even Retry-After is capped so a scheduled process cannot sleep indefinitely.
Capture each page once per ISO week
The bootstrap reads local configuration without replacing environment values already injected by production:
<?php
// config/bootstrap.php
$path = dirname(__DIR__) . '/.env.local';
if (is_file($path)) {
foreach (parse_ini_file($path, false, INI_SCANNER_RAW) ?: [] as $key => $value) {
if (
preg_match('/\A[A-Z][A-Z0-9_]*\z/', $key) === 1
&& getenv($key) === false
) {
putenv($key . '=' . $value);
}
}
}
// config/pages.php
return [
'home' => 'https://example.com/',
'services' => 'https://example.com/services',
'contact' => 'https://example.com/contact',
];
Replace the example addresses with pages the owner actually depends on. Treat this file as trusted administrator configuration, and permit HTTPS pages only.
<?php
// bin/capture-weekly.php
declare(strict_types=1);
use App\Http\CurlTransport;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/config/bootstrap.php';
$root = dirname(__DIR__);
$pages = require $root . '/config/pages.php';
$token = (string) (getenv('SCREENSHOT_API_TOKEN') ?: '');
$log = static function (string $level, string $event, array $context = []): void {
fwrite(STDERR, json_encode([
'time' => gmdate(DATE_ATOM),
'level' => $level,
'event' => $event,
...$context,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL);
};
if ($token === '') {
$log('error', 'configuration_failed', ['reason' => 'missing_token']);
exit(2);
}
@mkdir($root . '/var/screenshots', 0750, true);
$lock = fopen($root . '/var/capture.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
$log('warning', 'capture_skipped', ['reason' => 'already_running']);
exit(0);
}
$client = new ScreenshotClient(new CurlTransport(), $token);
$week = (new DateTimeImmutable('now', new DateTimeZone('UTC')))
->format('o-\WW');
$failed = false;
foreach ($pages as $name => $pageUrl) {
if (
preg_match('/\A[a-z0-9][a-z0-9-]*\z/', $name) !== 1
|| parse_url($pageUrl, PHP_URL_SCHEME) !== 'https'
) {
$log('error', 'page_configuration_invalid', ['page' => $name]);
$failed = true;
continue;
}
$directory = $root . '/var/screenshots/' . $name;
@mkdir($directory, 0750, true);
$target = $directory . '/' . $week . '.png';
if (is_file($target)) {
$log('info', 'capture_exists', ['page' => $name, 'week' => $week]);
continue;
}
try {
$capture = $client->capture($pageUrl);
$temporary = $target . '.' . bin2hex(random_bytes(6)) . '.tmp';
$written = file_put_contents($temporary, $capture->png, LOCK_EX);
if ($written !== strlen($capture->png)) {
@unlink($temporary);
throw new RuntimeException('Could not write the complete PNG.');
}
chmod($temporary, 0640);
if (!rename($temporary, $target)) {
@unlink($temporary);
throw new RuntimeException('Could not publish the PNG atomically.');
}
$log('info', 'capture_stored', [
'page' => $name,
'week' => $week,
'bytes' => $written,
'service_headers' => $capture->operationalHeaders(),
]);
} catch (ScreenshotException $exception) {
$log('error', 'capture_failed', [
'page' => $name,
'status' => $exception->status,
'reason' => $exception->getMessage(),
]);
$failed = true;
} catch (Throwable $exception) {
$log('error', 'storage_failed', [
'page' => $name,
'reason' => $exception->getMessage(),
]);
$failed = true;
}
}
flock($lock, LOCK_UN);
fclose($lock);
exit($failed ? 1 : 0);
Test retries without using quota
The fake transport supplies an exact response sequence. No network timing, live token, or external availability can make this test flaky.
<?php
// tests/ScreenshotClientTest.php
use App\Http\Response;
use App\Http\Transport;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
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 $timeout,
): Response {
$this->calls++;
return array_shift($this->responses);
}
}
final class ScreenshotClientTest extends TestCase
{
public function testRetriesServerFailureThenReturnsPng(): void
{
$png = "\x89PNG\r\n\x1a\npayload";
$transport = new FakeTransport([
new Response(503, [], 'unavailable'),
new Response(200, [
'content-type' => ['image/png'],
'x-cache' => ['HIT'],
], $png),
]);
$sleeps = [];
$client = new ScreenshotClient(
$transport,
'test-token',
3,
static function (int $seconds) use (&$sleeps): void {
$sleeps[] = $seconds;
},
);
$capture = $client->capture('https://example.com/');
self::assertSame($png, $capture->png);
self::assertSame(2, $transport->calls);
self::assertSame([1], $sleeps);
self::assertSame(['x-cache' => ['HIT']], $capture->operationalHeaders());
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([
new Response(401, ['content-type' => ['application/json']], '{}'),
]);
$client = new ScreenshotClient($transport, 'test-token', 3, static fn () => 0);
try {
$client->capture('https://example.com/');
self::fail('An exception was expected.');
} catch (ScreenshotException $exception) {
self::assertSame(401, $exception->status);
self::assertSame(1, $transport->calls);
}
}
}
Run composer install, then composer test. Add further cases for HTTP 429, transport exhaustion, invalid PNG signatures, and invalid configured URLs as the project evolves.
Deploy and operate the collector
Run the command manually once with php bin/capture-weekly.php. A successful run produces one PNG per configured page and newline-delimited JSON logs. Then schedule it in UTC, for example every Monday at 03:17:
17 3 * * 1 cd /srv/weekly-shots && /usr/bin/php bin/capture-weekly.php >> var/cron.log 2>&1
Give the deployment user read access to the environment secret and write access only to var/. Set restrictive permissions on screenshots because they may reveal unpublished prices, customer-facing mistakes, or staging information. Rotate logs, back up the image directory according to the owner’s retention needs, and alert on a nonzero exit code or the absence of a weekly capture_stored event.
Common failures
- HTTP 401 or 403: confirm the service-scoped token, plan activation, and whether somebody regenerated the token.
- HTTP 429: inspect quota and rate-limit headers, reduce duplicate captures, or revisit the selected plan. The command retries briefly but will not wait forever.
- HTTP 400-class validation error: check the configured URL. These failures are deliberately not retried.
- Non-PNG response: retain status and safe headers, but do not save the body as an image. It may be an upstream error document.
- Missing weekly file: inspect cron’s environment, PHP path, directory permissions, exit status, and structured log events.
- Repeated
capture_exists: this is expected when a scheduler reruns within the same ISO week. Delete a file intentionally if that page must be recaptured.
Final verification checklist
- The account and Free, Plus, or Pro plan are active.
- The current service token exists only in environment-backed configuration.
- The exact GET endpoint receives a URL-encoded
urlparameter. - Connection and total response timeouts are bounded.
- Only transport, quota, and server failures receive limited retries.
- HTTP status, content type, and PNG signature are validated.
- Cache and quota-related headers are retained for operational visibility.
- Overlapping and duplicate weekly runs cannot corrupt the archive.
- Tests pass with no live request or real credential.
- The scheduler, permissions, logging, backups, and failure alerts are verified.
The result is intentionally modest: a directory of trustworthy weekly images, not a miniature browser platform. That restraint is the strength of the design. The service handles screenshot infrastructure; PHP handles scheduling, validation, storage, and evidence. When the owner asks, “What did customers see last week?”, the answer is no longer a guess buried in deployment history. It is a PNG waiting in the archive.