Laravel: Автоматизирајте ги снимките пред и по ажурирањето на веб-страниците на клиентите со Screenshot API
A website update can succeed technically and still introduce a visual regression: a missing hero image, an unexpected font fallback, or a navigation bar wrapping at the wrong breakpoint. Without a reliable “before” image, proving what changed becomes guesswork.
This tutorial builds a production-oriented Laravel workflow that captures PNG screenshots immediately before and after a client website update. The deployment process invokes a synchronous Artisan command, while a dedicated service handles authentication, timeouts, retries, response validation, quota signals, and storage.
Get access to the Screenshot API
First, register an account or sign in. Open the Screenshot API service page, choose an available Free, Plus, or Pro plan, and complete its activation.
Next, open the official Screenshot API documentation. Find the Service token panel and copy the service-scoped token shown there. This service requires authentication; it is not a token-free API.
Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update every deployed environment that uses the old value, rebuild cached Laravel configuration, verify the integration, and only then consider the rotation complete.
Confirm the exact request contract
The capture operation is an HTTP GET request to 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. We will use the Bearer form so credentials never appear in URLs or intermediary access logs.
Run one minimal test before writing integration code:
export SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
curl --get --silent --show-error --fail-with-body \
--dump-header response-headers.txt \
--header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
--header "Accept: image/png" \
--data-urlencode "url=https://www.example.com" \
--output screenshot.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
A successful response body is image/png. Inspect response-headers.txt as well as the image: cache and quota headers are operational data, not decorative metadata. Their exact names and availability should be consumed defensively rather than assumed.
Now place the credential and deployment defaults in Laravel’s environment configuration. Never commit the real token.
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
CLIENT_SITE_URL=https://www.example.com
CLIENT_SITE_HOST=www.example.com
SCREENSHOT_DISK=local
Add this entry to the array returned by config/services.php:
'screenshot_api' => [
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'token' => env('SCREENSHOT_API_TOKEN'),
'site_url' => env('CLIENT_SITE_URL'),
'allowed_host' => env('CLIENT_SITE_HOST'),
'disk' => env('SCREENSHOT_DISK', 'local'),
],
If production uses cached configuration, run php artisan config:cache during deployment after updating environment values.
Choose a deployment-safe architecture
The project uses a synchronous command rather than a queued job. A “before” capture must finish before the release changes, while the “after” capture must occur only after the new release passes its readiness check. A queue would weaken that ordering unless the deployment also waited for job completion.
The resulting structure is deliberately small:
app/Data/ScreenshotCapture.php
app/Exceptions/ScreenshotApiException.php
app/Services/ScreenshotApi.php
app/Console/Commands/CaptureWebsiteSnapshot.php
config/services.php
tests/Feature/CaptureWebsiteSnapshotTest.php
Use PHP 8.3 or later, a Laravel application with its built-in HTTP client, a writable Laravel filesystem disk, and a deployment identifier such as an immutable release number or commit hash.
Map the remote response into the domain
The rest of the application should not manipulate an arbitrary HTTP response. Create app/Data/ScreenshotCapture.php:
<?php
namespace App\Data;
final readonly class ScreenshotCapture
{
public function __construct(
public string $png,
public string $contentType,
public array $operationalHeaders,
) {}
}
Create app/Exceptions/ScreenshotApiException.php. Its stable failure category lets commands, logs, and alerts distinguish configuration, authentication, quota, request, transport, upstream, and invalid-response failures.
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
final class ScreenshotApiException extends RuntimeException
{
public function __construct(
public readonly string $failure,
string $message,
public readonly ?int $status = null,
public readonly ?int $retryAfterSeconds = null,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Build the bounded HTTP client
Create app/Services/ScreenshotApi.php. The client retries connection failures and server errors with a short exponential backoff. A quota response is retried only once, only when it supplies a small numeric Retry-After value. Authentication and other client errors are never blindly retried.
<?php
namespace App\Services;
use App\Data\ScreenshotCapture;
use App\Exceptions\ScreenshotApiException;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
final class ScreenshotApi
{
private Closure $sleep;
public function __construct(?Closure $sleep = null)
{
$this->sleep = $sleep ?? static fn (int $milliseconds) =>
usleep($milliseconds * 1000);
}
public function capture(string $url): ScreenshotCapture
{
$token = (string) config('services.screenshot_api.token');
$endpoint = (string) config('services.screenshot_api.endpoint');
if ($token === '' || $endpoint === '') {
throw new ScreenshotApiException(
'configuration',
'Screenshot API configuration is incomplete.'
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::accept('image/png')
->withToken($token)
->connectTimeout(5)
->timeout(45)
->get($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new ScreenshotApiException(
'transport',
'Screenshot API connection failed.',
previous: $exception
);
}
$this->pause($url, $attempt, null, 250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->successful()) {
return $this->mapSuccess($response);
}
if ($response->status() === 429) {
$retryAfter = $this->numericRetryAfter($response);
if ($attempt === 1 && $retryAfter !== null && $retryAfter <= 10) {
$this->pause($url, $attempt, 429, $retryAfter * 1000);
continue;
}
throw new ScreenshotApiException(
'quota',
'Screenshot API quota or rate limit prevented capture.',
429,
$retryAfter
);
}
if ($response->serverError() && $attempt < 3) {
$this->pause(
$url,
$attempt,
$response->status(),
250 * (2 ** ($attempt - 1))
);
continue;
}
$status = $response->status();
$failure = in_array($status, [401, 403], true)
? 'authentication'
: ($response->clientError() ? 'request' : 'upstream');
throw new ScreenshotApiException(
$failure,
'Screenshot API returned HTTP '.$status.'.',
$status
);
}
throw new ScreenshotApiException('upstream', 'Capture attempts were exhausted.');
}
private function mapSuccess(Response $response): ScreenshotCapture
{
$contentType = strtolower(trim(explode(
';',
(string) $response->header('Content-Type')
)[0]));
$body = $response->body();
if ($contentType !== 'image/png' ||
! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotApiException(
'invalid_response',
'Successful response was not a valid PNG.',
$response->status()
);
}
$headers = [];
foreach ($response->headers() as $name => $values) {
$key = strtolower($name);
$compact = str_replace('-', '', $key);
if (str_contains($key, 'cache') ||
str_contains($key, 'quota') ||
str_contains($compact, 'ratelimit') ||
$key === 'retry-after') {
$headers[$name] = implode(', ', (array) $values);
}
}
return new ScreenshotCapture($body, $contentType, $headers);
}
private function numericRetryAfter(Response $response): ?int
{
$value = $response->header('Retry-After');
return is_string($value) && ctype_digit($value)
? (int) $value
: null;
}
private function pause(
string $url,
int $attempt,
?int $status,
int $milliseconds
): void {
Log::warning('Screenshot API retry scheduled.', [
'host' => parse_url($url, PHP_URL_HOST),
'attempt' => $attempt,
'status' => $status,
'delay_ms' => $milliseconds,
]);
($this->sleep)($milliseconds);
}
}
The response mapper checks both the declared media type and PNG signature. It preserves cache and quota-related headers without depending on undocumented header names. It deliberately does not log response bodies or credentials.
Create the snapshot command
Create app/Console/Commands/CaptureWebsiteSnapshot.php:
<?php
namespace App\Console\Commands;
use App\Exceptions\ScreenshotApiException;
use App\Services\ScreenshotApi;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class CaptureWebsiteSnapshot extends Command
{
protected $signature = 'snapshots:capture
{release : Immutable release identifier}
{phase : before or after}
{--url= : Override the configured website URL}
{--force : Replace an existing phase snapshot}';
protected $description = 'Capture a deployment website snapshot';
public function handle(ScreenshotApi $api): int
{
$release = (string) $this->argument('release');
$phase = (string) $this->argument('phase');
$url = (string) ($this->option('url')
?: config('services.screenshot_api.site_url'));
$allowedHost = (string) config('services.screenshot_api.allowed_host');
if (strlen($release) > 100 ||
! preg_match('/\A[A-Za-z0-9._-]+\z/', $release)) {
$this->error('Release identifier contains invalid characters.');
return self::FAILURE;
}
$host = parse_url($url, PHP_URL_HOST);
$scheme = parse_url($url, PHP_URL_SCHEME);
if (! in_array($phase, ['before', 'after'], true) ||
! filter_var($url, FILTER_VALIDATE_URL) ||
$scheme !== 'https' ||
! is_string($host) ||
strcasecmp($host, $allowedHost) !== 0) {
$this->error('Phase or target URL is not allowed.');
return self::FAILURE;
}
$disk = Storage::disk(
(string) config('services.screenshot_api.disk', 'local')
);
$base = "snapshots/{$release}/{$phase}";
$imagePath = "{$base}.png";
$metadataPath = "{$base}.json";
if ($disk->exists($imagePath) && ! $this->option('force')) {
$this->error('Snapshot already exists; use --force to replace it.');
return self::FAILURE;
}
try {
$capture = $api->capture($url);
$metadata = json_encode([
'release' => $release,
'phase' => $phase,
'url' => $url,
'captured_at' => now()->toIso8601String(),
'bytes' => strlen($capture->png),
'sha256' => hash('sha256', $capture->png),
'content_type' => $capture->contentType,
'operational_headers' => $capture->operationalHeaders,
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
if (! $disk->put($imagePath, $capture->png) ||
! $disk->put($metadataPath, $metadata)) {
$disk->delete([$imagePath, $metadataPath]);
throw new \RuntimeException('Snapshot storage write failed.');
}
} catch (ScreenshotApiException $exception) {
Log::error('Snapshot capture failed.', [
'release' => $release,
'phase' => $phase,
'host' => $host,
'failure' => $exception->failure,
'status' => $exception->status,
'retry_after' => $exception->retryAfterSeconds,
]);
$this->error("Capture failed: {$exception->failure}.");
return self::FAILURE;
} catch (Throwable $exception) {
Log::error('Snapshot persistence failed.', [
'release' => $release,
'phase' => $phase,
'exception' => $exception::class,
]);
$this->error('Snapshot could not be stored.');
return self::FAILURE;
}
Log::info('Deployment snapshot stored.', [
'release' => $release,
'phase' => $phase,
'path' => $imagePath,
'operational_headers' => $capture->operationalHeaders,
]);
$this->info("Stored {$imagePath}");
return self::SUCCESS;
}
}
The hostname allowlist prevents a deployment argument from turning the command into an arbitrary URL capture mechanism. Each PNG is accompanied by JSON containing its checksum, size, capture time, and available cache or quota metadata.
Place it around the release switch
Wrap your existing, health-checked release operation with the two commands. The release switch must return a nonzero status on failure:
set -euo pipefail
: "${RELEASE_ID:?Set an immutable RELEASE_ID}"
: "${RELEASE_SWITCH:?Set the release-switch executable}"
php artisan snapshots:capture "$RELEASE_ID" before
"$RELEASE_SWITCH" "$RELEASE_ID"
php artisan snapshots:capture "$RELEASE_ID" after
Because the shell stops on the first failure, a missing “after” image means either deployment or post-deployment capture failed. Do not capture “after” until the application’s readiness check confirms that the updated release is serving traffic.
Test success, retries, and authentication failure
Laravel’s Http::fake() keeps tests deterministic and prevents real API usage. Create tests/Feature/CaptureWebsiteSnapshotTest.php:
<?php
namespace Tests\Feature;
use App\Exceptions\ScreenshotApiException;
use App\Services\ScreenshotApi;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class CaptureWebsiteSnapshotTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('services.screenshot_api', [
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'token' => 'test-token',
'site_url' => 'https://www.example.com',
'allowed_host' => 'www.example.com',
'disk' => 'local',
]);
}
public function test_command_stores_png_and_metadata(): void
{
Storage::fake('local');
$png = "\x89PNG\r\n\x1a\npayload";
Http::fake(fn () => Http::response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'private',
]));
$this->artisan('snapshots:capture release-42 before')
->assertSuccessful();
Storage::disk('local')
->assertExists('snapshots/release-42/before.png');
Storage::disk('local')
->assertExists('snapshots/release-42/before.json');
Http::assertSent(fn ($request) =>
$request->url() ===
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture?url='.
urlencode('https://www.example.com') &&
$request->hasHeader('Authorization', 'Bearer test-token')
);
}
public function test_server_error_is_retried_then_succeeds(): void
{
$png = "\x89PNG\r\n\x1a\npayload";
Http::fakeSequence()
->push('', 503)
->push($png, 200, ['Content-Type' => 'image/png']);
$service = new ScreenshotApi(static fn (int $milliseconds) => null);
$this->assertSame($png, $service->capture(
'https://www.example.com'
)->png);
Http::assertSentCount(2);
}
public function test_authentication_failure_is_not_retried(): void
{
Http::fake(fn () => Http::response('', 401));
try {
app(ScreenshotApi::class)->capture('https://www.example.com');
$this->fail('Expected authentication failure.');
} catch (ScreenshotApiException $exception) {
$this->assertSame('authentication', $exception->failure);
}
Http::assertSentCount(1);
}
}
Run the suite with php artisan test. In production, alert on command failures, repeated transport or upstream errors, low-quota signals exposed by returned headers, and missing snapshot pairs.
Common production failures
- HTTP 401 or 403: verify the service-scoped token, especially after regeneration, then rebuild Laravel’s configuration cache.
- HTTP 429: inspect recorded quota and retry metadata. Upgrade or reduce capture frequency instead of adding aggressive retries.
- Invalid PNG: a successful status with an unexpected body is rejected at the API boundary rather than stored as a misleading image.
- Matching before and after images: confirm that the new release passed readiness checks. Also inspect cache headers because the service intentionally supports cached captures.
- Target failures: check public DNS, TLS, redirects, authentication walls, and whether the website is reachable from an external capture service.
- Storage failures: verify disk credentials, permissions, capacity, retention policy, and whether snapshot files should remain private.
Final verification checklist
- Confirm that the real token exists only in protected environment configuration.
- Run a manual
beforecapture and open the stored PNG. - Verify that JSON metadata contains a checksum and any returned cache or quota headers.
- Confirm that invalid hosts and duplicate snapshot paths are rejected.
- Run the automated tests without making external requests.
- Execute a non-production release and verify that both phase files share the same release identifier.
- Test deployment failure and ensure the “after” command does not run.
- Define private storage retention, monitoring, and token-rotation procedures.
A useful snapshot system is more than an API call. It is an ordered deployment boundary with validated binary data, bounded failure behavior, protected credentials, durable evidence, and enough metadata to explain what happened later. Once those pieces are in place, every client website update leaves behind a simple, trustworthy visual record: what users saw before, and what they received after.