Laravel Snapshot API: Automate Client Website Before-and-After Updates
A website update can be technically successful and still leave an awkward question: what exactly changed? Memory is unreliable, browser caches mislead reviewers, and a hurried rollback can erase the evidence. A consistent pair of before-and-after screenshots gives clients and developers a durable visual record of each release.
This tutorial builds that workflow in Laravel on PHP 8.3 or later. A synchronous Artisan command captures a PNG through the Screenshot API, stores it privately with operational metadata, and returns a meaningful exit code. A deployment pipeline runs the command immediately before and after publishing a client update.
The synchronous design is deliberate. A queued “before” job might not run until after deployment, destroying the temporal guarantee. For two release-bound captures, blocking the pipeline for a bounded HTTP request is the safer trade-off.
Get access and copy the service token
Begin by creating an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
- 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 is not token-free: every capture must be authenticated. Regenerating the service token revokes the previously active token, so treat rotation as a deployment change and update every environment that uses it.
The authentication contract supports a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because credentials in query strings are more likely to appear in access logs and monitoring traces.
Confirm the exact endpoint
The API call is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Its required query parameter is url, and a successful response contains an image/png body plus cache and quota response headers.
Make one minimal request before writing application code:
curl --fail-with-body --silent --show-error \
--get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--data-urlencode 'url=https://client.example/' \
--dump-header /tmp/client-screenshot.headers \
--output /tmp/client-screenshot.png
file /tmp/client-screenshot.png
The separate header file is useful because the body is binary. Verify that the result is a PNG and review the returned cache and quota headers without assuming undocumented header names.
Store the credential in Laravel configuration
Put the real token only in the deployment environment or local .env file. Commit the placeholder to .env.example, never the credential itself.
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_CONNECT_TIMEOUT=5
SCREENSHOT_TIMEOUT=25
CLIENT_SNAPSHOT_DISK=local
CLIENT_SNAPSHOT_PREFIX=client-snapshots
CLIENT_SNAPSHOT_ALLOWED_HOSTS=client.example,www.client.example
Add the following entries. Application code reads Laravel configuration rather than calling env(), which keeps it compatible with cached production configuration.
<?php
// config/services.php
return [
// Existing services...
'screenshot' => [
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'token' => env('SCREENSHOT_API_TOKEN'),
'connect_timeout' => (int) env('SCREENSHOT_CONNECT_TIMEOUT', 5),
'timeout' => (int) env('SCREENSHOT_TIMEOUT', 25),
],
];
// config/client-snapshots.php
return [
'disk' => env('CLIENT_SNAPSHOT_DISK', 'local'),
'prefix' => env('CLIENT_SNAPSHOT_PREFIX', 'client-snapshots'),
'allowed_hosts' => array_values(array_filter(array_map(
'trim',
explode(',', env('CLIENT_SNAPSHOT_ALLOWED_HOSTS', ''))
))),
];
Shape the application boundary
The finished feature has a small, testable structure:
app/Domain/Screenshots/ScreenshotCapture.phpmaps a successful binary response.app/Domain/Screenshots/ScreenshotFailure.phpnames actionable failure categories.app/Domain/Screenshots/ScreenshotException.phpcarries the category, status, and operational headers.app/Services/ScreenshotClient.phpowns the external HTTP contract.app/Console/Commands/CaptureClientSnapshot.phpvalidates release input and stores artifacts.
The domain objects prevent controllers or commands from interpreting raw HTTP responses. They also preserve cache and quota metadata under its original response-header names instead of inventing response fields.
<?php
// app/Domain/Screenshots/ScreenshotFailure.php
namespace App\Domain\Screenshots;
enum ScreenshotFailure: string
{
case InvalidRequest = 'invalid_request';
case Authentication = 'authentication';
case Quota = 'quota';
case Upstream = 'upstream';
case Network = 'network';
case UnexpectedResponse = 'unexpected_response';
}
// app/Domain/Screenshots/ScreenshotCapture.php
namespace App\Domain\Screenshots;
final readonly class ScreenshotCapture
{
public function __construct(
public string $png,
public array $responseHeaders,
) {}
public function operationalHeaders(): array
{
return array_filter(
$this->responseHeaders,
static function (mixed $_values, string $name): bool {
$name = strtolower($name);
foreach (['cache', 'quota', 'rate', 'limit', 'retry-after'] as $term) {
if (str_contains($name, $term)) {
return true;
}
}
return false;
},
ARRAY_FILTER_USE_BOTH,
);
}
}
// app/Domain/Screenshots/ScreenshotException.php
namespace App\Domain\Screenshots;
use RuntimeException;
final class ScreenshotException extends RuntimeException
{
public function __construct(
public readonly ScreenshotFailure $kind,
string $message,
public readonly ?int $status = null,
public readonly array $operationalHeaders = [],
) {
parent::__construct($message);
}
}
Build a defensive Screenshot API client
The client applies separate connection and total-response timeouts. It retries only connection failures and server-side failures, with two bounded delays. Authentication, validation, and quota failures are not blindly retried: an immediate retry would repeat the same invalid request or consume more capacity without changing the cause.
A successful status alone is insufficient. The boundary also verifies the media type and PNG signature before allowing bytes into storage.
<?php
// app/Services/ScreenshotClient.php
namespace App\Services;
use App\Domain\Screenshots\ScreenshotCapture;
use App\Domain\Screenshots\ScreenshotException;
use App\Domain\Screenshots\ScreenshotFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use LogicException;
use Throwable;
final class ScreenshotClient
{
public function capture(string $url): ScreenshotCapture
{
$this->validateUrl($url);
$token = (string) config('services.screenshot.token');
if ($token === '') {
throw new LogicException('SCREENSHOT_API_TOKEN is not configured.');
}
try {
$response = Http::withToken($token)
->accept('image/png')
->connectTimeout((int) config('services.screenshot.connect_timeout', 5))
->timeout((int) config('services.screenshot.timeout', 25))
->retry(
[200, 600],
when: static fn (Throwable $error): bool =>
$error instanceof ConnectionException
|| ($error instanceof RequestException
&& $error->response->serverError()),
throw: false,
)
->get(
(string) config('services.screenshot.endpoint'),
['url' => $url],
);
} catch (ConnectionException $error) {
throw new ScreenshotException(
ScreenshotFailure::Network,
'The screenshot service could not be reached.',
previous: $error,
);
}
$headers = $response->headers();
if (! $response->successful()) {
$kind = match (true) {
in_array($response->status(), [400, 422], true)
=> ScreenshotFailure::InvalidRequest,
in_array($response->status(), [401, 403], true)
=> ScreenshotFailure::Authentication,
$response->status() === 429
=> ScreenshotFailure::Quota,
$response->serverError()
=> ScreenshotFailure::Upstream,
default
=> ScreenshotFailure::UnexpectedResponse,
};
throw new ScreenshotException(
$kind,
'Screenshot capture failed.',
$response->status(),
$this->operationalHeaders($headers),
);
}
$body = $response->body();
$contentType = strtolower(trim(explode(
';',
(string) $response->header('Content-Type')
)[0]));
if ($contentType !== 'image/png'
|| ! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotException(
ScreenshotFailure::UnexpectedResponse,
'The service returned a non-PNG response.',
$response->status(),
$this->operationalHeaders($headers),
);
}
return new ScreenshotCapture($body, $headers);
}
private function validateUrl(string $url): void
{
$parts = parse_url($url);
if ($parts === false
|| ! isset($parts['scheme'], $parts['host'])
|| ! in_array(strtolower($parts['scheme']), ['http', 'https'], true)
|| isset($parts['user'])
|| isset($parts['pass'])) {
throw new ScreenshotException(
ScreenshotFailure::InvalidRequest,
'The target must be an HTTP or HTTPS URL without credentials.',
);
}
}
private function operationalHeaders(array $headers): array
{
return (new ScreenshotCapture('', $headers))->operationalHeaders();
}
}
The error body is intentionally excluded from exceptions and logs. Authentication failures and upstream pages can contain details that do not belong in deployment output.
Create the release command
The command accepts only before or after, and only hosts explicitly allowlisted in configuration. That prevents a compromised CI variable from turning this integration into an unrestricted URL-fetching facility.
Each PNG receives a JSON sidecar containing its phase, release identifier, timestamp, target hash, and cache or quota metadata. The complete URL is not stored or logged because query strings can contain sensitive values.
<?php
// app/Console/Commands/CaptureClientSnapshot.php
namespace App\Console\Commands;
use App\Domain\Screenshots\ScreenshotException;
use App\Services\ScreenshotClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use JsonException;
final class CaptureClientSnapshot extends Command
{
protected $signature = 'client:snapshot
{site : Public client URL}
{phase : before or after}
{--release=manual : Deployment identifier}';
protected $description = 'Capture a client website release snapshot';
public function handle(ScreenshotClient $client): int
{
$site = (string) $this->argument('site');
$phase = strtolower((string) $this->argument('phase'));
$host = strtolower((string) parse_url($site, PHP_URL_HOST));
if (! in_array($phase, ['before', 'after'], true)) {
$this->error('Phase must be before or after.');
return self::INVALID;
}
$allowed = array_map(
'strtolower',
config('client-snapshots.allowed_hosts', [])
);
if ($host === '' || ! in_array($host, $allowed, true)) {
$this->error('The target host is not allowlisted.');
return self::INVALID;
}
try {
$capture = $client->capture($site);
} catch (ScreenshotException $error) {
Log::warning('Client snapshot capture failed', [
'host' => $host,
'phase' => $phase,
'failure_kind' => $error->kind->value,
'http_status' => $error->status,
'operational_headers' => $error->operationalHeaders,
]);
$this->error("Capture failed: {$error->kind->value}");
return self::FAILURE;
}
$release = preg_replace(
'/[^A-Za-z0-9._-]/',
'-',
(string) $this->option('release')
) ?: 'manual';
$timestamp = now()->utc()->format('Ymd\THis\Z');
$prefix = trim((string) config('client-snapshots.prefix'), '/');
$base = "{$prefix}/{$host}/{$release}/{$timestamp}-{$phase}";
$disk = Storage::disk((string) config('client-snapshots.disk'));
$metadata = [
'host' => $host,
'target_sha256' => hash('sha256', $site),
'release' => $release,
'phase' => $phase,
'captured_at' => now()->utc()->toIso8601String(),
'response_headers' => $capture->operationalHeaders(),
];
try {
$pngStored = $disk->put(
"{$base}.png",
$capture->png,
['visibility' => 'private']
);
$jsonStored = $disk->put(
"{$base}.json",
json_encode(
$metadata,
JSON_THROW_ON_ERROR
| JSON_PRETTY_PRINT
| JSON_UNESCAPED_SLASHES
),
['visibility' => 'private']
);
} catch (JsonException $error) {
Log::error('Snapshot metadata encoding failed', [
'host' => $host,
'phase' => $phase,
]);
return self::FAILURE;
}
if (! $pngStored || ! $jsonStored) {
if ($pngStored && ! $jsonStored) {
$disk->delete("{$base}.png");
}
$this->error('Snapshot storage failed.');
return self::FAILURE;
}
Log::info('Client snapshot captured', [
'host' => $host,
'phase' => $phase,
'release' => $release,
'artifact' => "{$base}.png",
'operational_headers' => $capture->operationalHeaders(),
]);
$this->info("Stored {$base}.png");
return self::SUCCESS;
}
}
Put captures around deployment
Run the commands in the same deployment stage that changes the website. With shell error handling enabled, a missing “before” image aborts deployment. A failed “after” image marks the completed deployment for investigation rather than pretending the evidence exists.
set -euo pipefail
php artisan client:snapshot \
"$CLIENT_URL" before --release="$RELEASE_ID"
# Run the existing client deployment step here.
php artisan client:snapshot \
"$CLIENT_URL" after --release="$RELEASE_ID"
If the site needs time to become healthy, perform that check in the deployment system before the after-capture. Avoid an arbitrary long sleep: poll a known public health URL with a strict deadline, then capture once the released page is actually ready.
Test success, retries, and release storage
Laravel’s HTTP fake makes binary tests deterministic and prevents real quota consumption. The cache and quota header names below are deliberately synthetic test fixtures; they verify name-agnostic preservation without claiming undocumented production names.
<?php
// tests/Feature/ClientSnapshotTest.php
namespace Tests\Feature;
use App\Domain\Screenshots\ScreenshotException;
use App\Domain\Screenshots\ScreenshotFailure;
use App\Services\ScreenshotClient;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class ClientSnapshotTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('services.screenshot', [
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'token' => 'test-token',
'connect_timeout' => 1,
'timeout' => 2,
]);
Http::preventStrayRequests();
}
public function test_transient_server_failure_is_retried(): void
{
$png = "\x89PNG\r\n\x1a\nfake-test-bytes";
Http::fakeSequence()
->push('temporarily unavailable', 503)
->push($png, 200, [
'Content-Type' => 'image/png',
'Cache-Metadata-Test' => 'hit',
'Quota-Metadata-Test' => 'remaining',
]);
$capture = app(ScreenshotClient::class)
->capture('https://client.example/');
$this->assertSame($png, $capture->png);
$this->assertArrayHasKey(
'Cache-Metadata-Test',
$capture->operationalHeaders()
);
Http::assertSentCount(2);
}
public function test_authentication_failure_is_not_retried(): void
{
Http::fake([
'*' => Http::response('unauthorized', 401),
]);
try {
app(ScreenshotClient::class)
->capture('https://client.example/');
$this->fail('Expected ScreenshotException.');
} catch (ScreenshotException $error) {
$this->assertSame(
ScreenshotFailure::Authentication,
$error->kind
);
}
Http::assertSentCount(1);
}
public function test_command_stores_private_snapshot_and_metadata(): void
{
Storage::fake('snapshots');
config()->set('client-snapshots.disk', 'snapshots');
config()->set('client-snapshots.prefix', 'client-snapshots');
config()->set(
'client-snapshots.allowed_hosts',
['client.example']
);
Http::fake([
'*' => Http::response(
"\x89PNG\r\n\x1a\nfake-test-bytes",
200,
['Content-Type' => 'image/png']
),
]);
$exit = Artisan::call('client:snapshot', [
'site' => 'https://client.example/',
'phase' => 'before',
'--release' => 'release-42',
]);
$this->assertSame(0, $exit);
$files = Storage::disk('snapshots')->allFiles();
$this->assertCount(2, $files);
$this->assertCount(1, preg_grep('/\.png$/', $files));
$this->assertCount(1, preg_grep('/\.json$/', $files));
}
}
Run the focused suite with php artisan test --filter=ClientSnapshotTest. Additional useful cases include a non-PNG 200 response, a disallowed host, a 429 response, and a failed storage write.
Security, observability, and deployment details
Store snapshots on a private disk. Website captures can expose unpublished copy, customer names, preview banners, or account state. If the application uses object storage, grant the deployment identity write access only to the snapshot prefix and expose files through short-lived, authenticated download flows.
Never pass authenticated preview URLs containing secrets unless the service and your organization explicitly permit that data flow. The command rejects URL-embedded usernames and passwords, but applications should also avoid bearer credentials in query strings.
Production logs should make failures diagnosable without leaking the token, full target URL, binary response, or response body. The structured fields above support alerts by failure_kind, status, host, phase, and release. Cache and quota headers are retained with their original names so operators can interpret the contract documented by the service.
During deployment, provide SCREENSHOT_API_TOKEN through the platform’s secret store, then run:
php artisan config:cache
php artisan client:snapshot \
'https://client.example/' before --release='verification'
Rotate the token by replacing the secret, rebuilding Laravel’s configuration cache, verifying one capture, and only then removing assumptions about the old value. Remember that regenerating the service token immediately revokes its predecessor.
Common production failures
- 401 or 403: the token is missing, revoked, incorrectly copied, or Laravel is still using stale cached configuration. Do not retry automatically.
- 400 or 422: the required
urlvalue is malformed or unacceptable. Validate configuration rather than repeating the request. - 429: treat the capture as quota-limited, retain returned quota-related headers, and stop. Move the deployment forward only if your release policy explicitly allows missing evidence.
- 5xx or connection failure: the bounded retry handles brief faults. Persistent failures return a nonzero command result for the pipeline.
- 200 with a non-PNG body: reject it. Saving an HTML error page with a PNG extension creates deceptive evidence.
- Correct image, wrong release: ensure the before command completes before publishing and the after command runs only after the deployed page passes its readiness check.
Final verification checklist
- The account and Free, Plus, or Pro plan are active.
- The service-scoped token is present only in environment-backed configuration.
- The exact GET endpoint receives the required
urlquery parameter. - Bearer authentication, timeouts, bounded retries, and failure classification work under tests.
- PNG content type and signature are validated before storage.
- Cache and quota response headers are preserved without fabricated field mappings.
- Only allowlisted client hosts can be captured.
- PNG and JSON artifacts are private and grouped by host, release, timestamp, and phase.
- The deployment stops if its before-capture fails and reports an after-capture failure.
- Logs contain operational context but no token, binary body, or full sensitive URL.
The real value of this integration is not merely producing two images. It makes visual evidence part of the release contract: captured at the correct moments, tied to a release, protected like an artifact, and honest about failure. When the next client asks what changed, the answer is no longer a recollection. It is a reproducible pair of snapshots.