Laravel: Capture Website Before & After Updates with Automatic Screenshots
A website update can look perfect in a pull request and still produce an unexpected result in production: a missing asset, a shifted layout, an incorrect font, or a cached stylesheet from the previous release. A pair of timestamped screenshots gives developers and clients a simple, durable record of what visitors saw immediately before and after deployment.
This tutorial builds that workflow as a production Laravel command. It captures PNG screenshots at explicit deployment checkpoints, stores operational metadata beside each image, handles transient failures without retrying bad credentials, and can run locally or in continuous delivery. The Screenshot API supplies the browser infrastructure, so the application does not need to install, patch, or operate Chromium.
Get access to the Screenshot API
Register through the registration page, or use the sign-in page if you already have an account. Then open the Screenshot API service page, choose the available Free, Plus, or Pro plan, and complete its activation.
Next, open the official documentation. Find the Service token panel and copy the service-scoped token. This service requires authentication; it is not a token-free endpoint. Regenerating the token revokes the previously active token, so coordinate rotation with every deployed environment that uses it.
Confirm the endpoint before writing Laravel code
The exact request is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. It requires a url query parameter and accepts authentication through a Bearer token, an X-API-Token header, or a token query parameter. Bearer authentication is preferable here because it keeps the credential out of URLs, proxy histories, and routine access logs.
Run one minimal smoke test, replacing only the placeholder token and target URL:
curl --silent --show-error --fail-with-body \
--get "https://ai.mihajlo.mk/api/screenshot-api/v1/capture" \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Accept: image/png" \
--data-urlencode "url=https://client.example/" \
--dump-header /tmp/screenshot.headers \
--output /tmp/screenshot.png
The body should be PNG data, not JSON. The separate header file lets you inspect the service’s cache and quota metadata without corrupting the image.
Now place the credential in Laravel’s environment configuration. Never commit the populated .env file:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_ALLOWED_HOSTS=client.example,www.client.example
Choose deployment checkpoints over a queue
This feature has a strict ordering requirement: the “before” capture must finish before deployment begins, while the “after” capture must happen only after the update and its health checks succeed. A queued job could start too late and accidentally photograph the new release twice. A synchronous Artisan command is therefore the clearer production boundary.
The project has four focused pieces:
- Environment-backed service and filesystem configuration.
- A client that owns authentication, timeouts, retries, and response validation.
- A domain result that keeps PNG bytes separate from operational headers.
- An Artisan command called at the two deployment checkpoints.
The examples target PHP 8.3 or later and use Laravel’s built-in HTTP and filesystem clients. No browser package is required.
Configure the service and snapshot disk
Add the service entry to config/services.php. The endpoint is configurable for testing but defaults to the exact production endpoint. The allowlist prevents an operator, compromised build variable, or future web wrapper from turning the command into an unrestricted URL-capture facility.
<?php
// config/services.php
return [
// Existing services...
'screenshot' => [
'endpoint' => env(
'SCREENSHOT_API_ENDPOINT',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
),
'token' => env('SCREENSHOT_API_TOKEN'),
'allowed_hosts' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('SCREENSHOT_ALLOWED_HOSTS', ''))
))),
],
];
// Add this disk inside config/filesystems.php under "disks":
'snapshots' => [
'driver' => 'local',
'root' => storage_path('app/site-snapshots'),
'throw' => true,
],
A dedicated disk makes retention and backup policy explicit. It can later be replaced with an existing object-storage disk without changing the capture client.
Map the binary response at the API boundary
Create app/Support/Screenshots/CaptureResult.php and CaptureException.php. The result deliberately retains cache and quota-related headers without assuming undocumented header spellings. Header names containing cache, quota, rate-limit, or retry semantics are copied into metadata; all unrelated headers are discarded.
<?php
namespace App\Support\Screenshots;
final readonly class CaptureResult
{
public function __construct(
public string $png,
public array $operationalHeaders,
) {}
}
<?php
namespace App\Support\Screenshots;
use RuntimeException;
use Throwable;
final class CaptureException extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status = null,
?Throwable $previous = null,
) {
parent::__construct(
"Screenshot capture failed: {$kind}",
0,
$previous
);
}
}
The exception exposes a stable failure category without retaining an upstream response body. That avoids accidentally logging HTML error pages, diagnostic details, or reflected request data.
Build a defensive Laravel HTTP client
Create app/Support/Screenshots/ScreenshotClient.php. The request uses bounded connection and total timeouts. It retries connection failures, HTTP 408, quota responses, and server errors because the operation is a safe GET. It does not retry validation or authentication failures.
<?php
namespace App\Support\Screenshots;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Throwable;
final class ScreenshotClient
{
public function capture(string $url): CaptureResult
{
$token = (string) config('services.screenshot.token');
$endpoint = (string) config('services.screenshot.endpoint');
if ($token === '') {
throw new CaptureException('configuration');
}
$delays = [250, 750, 1500];
for ($attempt = 1; $attempt <= 4; $attempt++) {
try {
$response = Http::withToken($token)
->accept('image/png')
->connectTimeout(5)
->timeout(30)
->get($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 4) {
throw new CaptureException(
'transport',
previous: $exception
);
}
usleep($delays[$attempt - 1] * 1000);
continue;
}
if ($response->successful()) {
return $this->mapSuccessfulResponse($response);
}
$status = $response->status();
$retryable = $status === 408
|| $status === 429
|| $status >= 500;
if ($retryable && $attempt < 4) {
$delay = $delays[$attempt - 1];
if ($status === 429) {
$retryAfter = $response->header('Retry-After');
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
$delay = min(((int) $retryAfter) * 1000, 10_000);
}
}
usleep($delay * 1000);
continue;
}
throw new CaptureException(
match ($status) {
400, 422 => 'validation',
401, 403 => 'authentication',
429 => 'quota',
default => $status >= 500 ? 'upstream' : 'http',
},
$status
);
}
throw new CaptureException('unexpected');
}
private function mapSuccessfulResponse(Response $response): CaptureResult
{
$body = $response->body();
$contentType = strtolower(
trim(explode(';', $response->header('Content-Type', ''))[0])
);
if (
$contentType !== 'image/png'
|| ! str_starts_with($body, "\x89PNG\r\n\x1a\n")
) {
throw new CaptureException(
'invalid_response',
$response->status()
);
}
$operational = [];
foreach ($response->headers() as $name => $values) {
$normalized = strtolower($name);
if (
str_contains($normalized, 'cache')
|| str_contains($normalized, 'quota')
|| str_contains($normalized, 'rate-limit')
|| str_contains($normalized, 'ratelimit')
|| $normalized === 'retry-after'
) {
$operational[$name] = array_map(
'strval',
(array) $values
);
}
}
return new CaptureResult($body, $operational);
}
}
The PNG signature check matters. A successful-looking response that actually contains an intermediary’s HTML page should never be saved with a misleading .png extension.
Create the snapshot command
Create app/Console/Commands/CaptureSiteSnapshot.php. It accepts a phase, a release identifier, and a URL. Only HTTPS URLs on the configured allowlist are accepted, while release identifiers are constrained so they cannot escape the snapshot directory.
<?php
namespace App\Console\Commands;
use App\Support\Screenshots\CaptureException;
use App\Support\Screenshots\ScreenshotClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class CaptureSiteSnapshot extends Command
{
protected $signature =
'site:snapshot {phase : before|after} {release} {url}';
protected $description =
'Capture a website snapshot at a deployment checkpoint';
public function handle(ScreenshotClient $client): int
{
$phase = (string) $this->argument('phase');
$release = (string) $this->argument('release');
$url = (string) $this->argument('url');
if (! in_array($phase, ['before', 'after'], true)) {
$this->error('Phase must be before or after.');
return self::INVALID;
}
if (! preg_match('/\A[A-Za-z0-9._-]+\z/', $release)) {
$this->error('Release contains unsupported characters.');
return self::INVALID;
}
$parts = parse_url($url);
$host = strtolower((string) ($parts['host'] ?? ''));
$allowed = config('services.screenshot.allowed_hosts', []);
if (
($parts['scheme'] ?? null) !== 'https'
|| $host === ''
|| isset($parts['user'])
|| isset($parts['pass'])
|| ! in_array($host, $allowed, true)
) {
$this->error('URL must be HTTPS and use an allowed host.');
return self::INVALID;
}
try {
$capture = $client->capture($url);
$base = "{$release}/{$phase}";
Storage::disk('snapshots')->put(
"{$base}.png",
$capture->png
);
Storage::disk('snapshots')->put(
"{$base}.json",
json_encode([
'release' => $release,
'phase' => $phase,
'target_host' => $host,
'captured_at' => now()->toIso8601String(),
'sha256' => hash('sha256', $capture->png),
'cache_and_quota_headers' =>
$capture->operationalHeaders,
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)
);
Log::info('Website snapshot captured', [
'release' => $release,
'phase' => $phase,
'target_host' => $host,
'cache_and_quota_headers' =>
$capture->operationalHeaders,
]);
$this->info("Captured {$phase} snapshot for {$release}.");
return self::SUCCESS;
} catch (CaptureException $exception) {
Log::error('Website snapshot API failed', [
'release' => $release,
'phase' => $phase,
'target_host' => $host,
'failure_kind' => $exception->kind,
'status' => $exception->status,
]);
$this->error("Capture failed: {$exception->kind}.");
return self::FAILURE;
} catch (Throwable $exception) {
Log::error('Website snapshot storage failed', [
'release' => $release,
'phase' => $phase,
'target_host' => $host,
'exception_class' => $exception::class,
]);
$this->error('Capture could not be persisted.');
return self::FAILURE;
}
}
}
The command logs the hostname, never the complete URL. That prevents query strings containing preview tokens or customer-specific values from reaching centralized logs. The service token is never included in metadata or exceptions.
Place the command around deployment
Let the delivery system provide stable RELEASE_ID and SITE_URL variables. Stop the release if either capture command fails:
set -e
php artisan site:snapshot before "$RELEASE_ID" "$SITE_URL"
# Run the application's existing deployment and health-check steps here.
php artisan site:snapshot after "$RELEASE_ID" "$SITE_URL"
Do not move the first command behind the deployment step, and do not run the two captures concurrently. If an update fails and rolls back, retain the before image and record the failed release in the deployment system; do not manufacture an “after” image for a state that never became healthy.
Test success and non-retryable failures
Laravel’s Http::fake() provides a deterministic transport. The synthetic cache and quota header names below test name-agnostic metadata preservation; they are test fixtures, not claims about specific production header names.
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class CaptureSiteSnapshotTest extends TestCase
{
public function test_it_persists_png_and_metadata(): void
{
Storage::fake('snapshots');
config([
'services.screenshot.token' => 'test-token',
'services.screenshot.allowed_hosts' => ['client.example'],
]);
$png = "\x89PNG\r\n\x1a\nfake-image-data";
Http::fake([
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
Http::response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Test' => 'hit',
'Quota-Test' => '9',
]),
]);
$this->artisan(
'site:snapshot before release-42 https://client.example/'
)->assertSuccessful();
Storage::disk('snapshots')
->assertExists('release-42/before.png');
Storage::disk('snapshots')
->assertExists('release-42/before.json');
$metadata = Storage::disk('snapshots')
->get('release-42/before.json');
$this->assertStringContainsString('Cache-Test', $metadata);
Http::assertSentCount(1);
}
public function test_authentication_failure_is_not_retried(): void
{
Storage::fake('snapshots');
config([
'services.screenshot.token' => 'invalid-test-token',
'services.screenshot.allowed_hosts' => ['client.example'],
]);
Http::fake([
'*' => Http::response('', 401),
]);
$this->artisan(
'site:snapshot after release-42 https://client.example/'
)->assertFailed();
Http::assertSentCount(1);
Storage::disk('snapshots')
->assertMissing('release-42/after.png');
}
}
Add companion tests for a rejected hostname, a non-PNG success response, and a transient server response followed by success. Keep real tokens out of test fixtures.
Production checks and common failures
- HTTP 401 or 403: verify the deployed secret and whether somebody regenerated the service token. Do not retry automatically.
- HTTP 400 or 422: inspect the submitted URL and configuration. Repeating an unchanged invalid request only wastes quota.
- HTTP 429: the client retries with bounded backoff and honors a numeric
Retry-Aftervalue up to ten seconds. Persistent quota exhaustion fails the deployment checkpoint. - HTTP 5xx or connection failure: four total attempts provide limited resilience without allowing the pipeline to hang indefinitely.
- HTTP 200 with invalid content: verify that intermediaries are not replacing the PNG response. The client rejects incorrect media types and PNG signatures.
- Unexpected page appearance: confirm the target URL is publicly reachable and that the site’s assets are available before the after-capture begins.
During deployment, inject SCREENSHOT_API_TOKEN through the platform’s secret store, run Laravel’s normal configuration-cache command if the application uses cached configuration, and confirm php artisan list includes site:snapshot. Give the runtime identity write access only to the configured snapshot location.
Define retention deliberately. Screenshots may contain names, account data, unpublished designs, or authenticated preview URLs. Restrict storage access, encrypt remote storage where applicable, avoid capturing private pages with credentials embedded in URLs, and expire artifacts according to the client’s agreement.
Final verification checklist
- The token comes from the documentation page’s Service token panel and exists only in environment-backed configuration.
- The command sends an authenticated
GETrequest to the exact capture endpoint with the requiredurlparameter. - Only approved HTTPS hosts can be captured.
- The response must be both
image/pngand a valid-looking PNG byte stream. - Cache and quota metadata is retained beside each image.
- Authentication and validation failures are not retried.
- The before capture completes before deployment, and the after capture follows successful health checks.
- Automated tests run without network access or real credentials.
A screenshot pair will not replace monitoring, browser tests, or careful review. It does something narrower and surprisingly valuable: it turns a fleeting visual state into a release artifact. When the next update prompts the question “What exactly changed?”, the answer is already waiting beside the deployment that caused it.