Laravel: Keep a Weekly Visual Archive of Key Business Pages with Screenshot API
A weekly screenshot is a remarkably useful business record. It shows what customers actually saw: the published price, the seasonal banner, the opening hours, the booking flow, or the promotion that disappeared during an hurried update. Unlike source control or database history, a visual archive preserves the rendered result.
This tutorial builds that archive as a production Laravel feature. A scheduled command dispatches one queued job per important page, the job calls a Screenshot API, validates the PNG response, stores it on a private filesystem disk, and records operational metadata in the database. The external service supplies cached desktop or mobile screenshots, so the application does not need to operate Chromium, browser drivers, or a screenshot worker fleet.
Get access before writing integration code
- Register at https://ai.mihajlo.mk/register, or sign in through https://ai.mihajlo.mk/login.
- 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. Regenerating this token revokes the previously active token, so coordinate rotation with deployment rather than regenerating it casually.
- Place the token in environment-backed configuration. Never commit it, copy it into a test fixture, or include it in a log message.
The API accepts a Bearer token, an X-API-Token header, or a token query parameter. This implementation uses the Bearer form because it keeps the credential out of URLs, proxy access logs, browser history, and routine HTTP diagnostics.
Verify the endpoint with one small request
The exact call is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Its required query parameter is url, and a successful response is an image/png body rather than JSON.
curl --fail-with-body \
--get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--data-urlencode 'url=https://example.com/' \
--output screenshot.png
Inspect the response headers during this first check as well. Cache and quota headers belong to the operational result even though they are not part of the PNG file. The application below preserves them without assuming that optional headers will always be present.
Store the credential and the business-owned page URLs in .env:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
ARCHIVE_HOME_URL=https://example.com/
ARCHIVE_BOOKING_URL=https://example.com/book
VISUAL_ARCHIVE_DISK=local
Keep real production secrets in the deployment platform’s secret manager. The .env file is appropriate for local development but must remain outside version control.
Architecture and project layout
The weekly command is intentionally quick: it dispatches jobs and exits. Each page is captured independently, so one slow or invalid page cannot block the rest. Queue retries handle temporary transport and server failures, while authentication, validation, and quota failures become explicit database states instead of retry storms.
config/services.phpowns the endpoint and token.config/visual-archive.phpdefines the private disk and approved pages.app/Services/ScreenshotClient.phpisolates the HTTP contract and maps it into a domain result.app/Jobs/CapturePageScreenshot.phpstores the PNG and capture record.app/Console/Commands/CaptureWeeklyArchive.phpcreates one job per page.routes/console.phpdefines the weekly schedule.
A database record makes failures and quota signals queryable, while Laravel’s filesystem abstraction allows local storage during development and an object-storage disk in production. Keeping the page list in configuration is a good trade-off for a small business: changes are reviewed and deployed, and arbitrary user-supplied URLs never reach the capture service.
Configure Laravel and create the capture ledger
Add these entries without replacing unrelated service configuration:
<?php
// config/services.php
return [
// Existing services...
'screenshot_api' => [
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'token' => env('SCREENSHOT_API_TOKEN'),
],
];
// config/visual-archive.php
return [
'disk' => env('VISUAL_ARCHIVE_DISK', 'local'),
'pages' => [
'home' => env('ARCHIVE_HOME_URL'),
'booking' => env('ARCHIVE_BOOKING_URL'),
],
];
Create a migration for one record per page and week:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('visual_snapshots', function (Blueprint $table): void {
$table->id();
$table->string('page_key');
$table->date('captured_week');
$table->string('status');
$table->string('path')->nullable();
$table->unsignedSmallInteger('http_status')->nullable();
$table->json('response_headers')->nullable();
$table->json('cache_headers')->nullable();
$table->json('quota_headers')->nullable();
$table->string('error')->nullable();
$table->timestamps();
$table->unique(['page_key', 'captured_week']);
});
}
public function down(): void
{
Schema::dropIfExists('visual_snapshots');
}
};
Run php artisan migrate. The unique constraint provides durable idempotency even if a scheduler runs twice or a worker restarts.
Build a strict API boundary
The client must treat the response as untrusted bytes. A successful status alone is insufficient: an upstream gateway might return an HTML error page. Validate both the media type and the PNG signature before storing anything.
<?php
// app/Services/ScreenshotClient.php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final readonly class ScreenshotResult
{
public function __construct(
public string $png,
public int $status,
public array $headers,
public array $cacheHeaders,
public array $quotaHeaders,
) {}
}
final class ScreenshotCaptureException extends RuntimeException
{
public function __construct(
string $message,
public readonly bool $retryable,
public readonly ?int $status = null,
public readonly array $headers = [],
public readonly array $cacheHeaders = [],
public readonly array $quotaHeaders = [],
) {
parent::__construct($message);
}
}
final class ScreenshotClient
{
public function capture(string $url): ScreenshotResult
{
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
throw new ScreenshotCaptureException(
'Archive URL must be a valid HTTPS URL.',
false,
);
}
$token = (string) config('services.screenshot_api.token');
if ($token === '') {
throw new ScreenshotCaptureException(
'Screenshot API token is not configured.',
false,
);
}
try {
$response = Http::withToken($token)
->accept('image/png')
->connectTimeout(5)
->timeout(45)
->get(
(string) config('services.screenshot_api.endpoint'),
['url' => $url],
);
} catch (ConnectionException $exception) {
throw new ScreenshotCaptureException(
'Screenshot API connection failed.',
true,
);
}
$headers = $response->headers();
$cacheHeaders = $this->matchingHeaders(
$headers,
fn (string $name): bool =>
str_contains($name, 'cache')
|| in_array($name, ['age', 'etag', 'expires'], true),
);
$quotaHeaders = $this->matchingHeaders(
$headers,
fn (string $name): bool =>
str_contains($name, 'quota')
|| str_contains($name, 'rate-limit')
|| $name === 'retry-after',
);
if ($response->status() === 429) {
throw new ScreenshotCaptureException(
'Screenshot API quota or rate limit was reached.',
false,
429,
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
if (in_array($response->status(), [400, 401, 403, 422], true)) {
throw new ScreenshotCaptureException(
'Screenshot API rejected the request.',
false,
$response->status(),
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
if ($response->serverError()) {
throw new ScreenshotCaptureException(
'Screenshot API returned a temporary server error.',
true,
$response->status(),
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
if (! $response->successful()) {
throw new ScreenshotCaptureException(
'Screenshot API returned an unexpected status.',
false,
$response->status(),
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
$body = $response->body();
$mediaType = strtolower(trim(explode(
';',
$response->header('Content-Type', ''),
)[0]));
if ($mediaType !== 'image/png'
|| ! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotCaptureException(
'Screenshot API response was not a valid PNG.',
false,
$response->status(),
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
return new ScreenshotResult(
$body,
$response->status(),
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
private function matchingHeaders(array $headers, callable $match): array
{
return array_filter(
$headers,
fn (string $name): bool => $match(strtolower($name)),
ARRAY_FILTER_USE_KEY,
);
}
}
No response-body excerpt enters an exception or log. That avoids accidentally retaining proxy pages or other unexpected content. The client also does not blindly retry: it classifies failures and leaves retry policy to the queue.
Capture each page in an idempotent queue job
<?php
// app/Jobs/CapturePageScreenshot.php
namespace App\Jobs;
use App\Services\ScreenshotCaptureException;
use App\Services\ScreenshotClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
final class CapturePageScreenshot implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $uniqueFor = 86400;
public array $backoff = [60, 300, 900];
public function __construct(
public readonly string $pageKey,
public readonly string $url,
public readonly string $week,
) {}
public function uniqueId(): string
{
return $this->pageKey.':'.$this->week;
}
public function handle(ScreenshotClient $client): void
{
$identity = [
'page_key' => $this->pageKey,
'captured_week' => $this->week,
];
DB::table('visual_snapshots')->updateOrInsert(
$identity,
[...$identity, 'status' => 'pending',
'updated_at' => now(), 'created_at' => now()],
);
try {
$result = $client->capture($this->url);
} catch (ScreenshotCaptureException $exception) {
DB::table('visual_snapshots')->where($identity)->update([
'status' => $exception->status === 429
? 'quota_limited'
: ($exception->retryable
? 'transient_failure'
: 'permanent_failure'),
'http_status' => $exception->status,
'response_headers' => json_encode($exception->headers),
'cache_headers' => json_encode($exception->cacheHeaders),
'quota_headers' => json_encode($exception->quotaHeaders),
'error' => $exception->getMessage(),
'updated_at' => now(),
]);
if ($exception->retryable) {
throw $exception;
}
return;
}
$path = "visual-archive/{$this->pageKey}/{$this->week}.png";
if (! Storage::disk(config('visual-archive.disk'))
->put($path, $result->png)) {
throw new \RuntimeException('Unable to store screenshot.');
}
DB::table('visual_snapshots')->where($identity)->update([
'status' => 'captured',
'path' => $path,
'http_status' => $result->status,
'response_headers' => json_encode($result->headers),
'cache_headers' => json_encode($result->cacheHeaders),
'quota_headers' => json_encode($result->quotaHeaders),
'error' => null,
'updated_at' => now(),
]);
Log::info('Weekly visual archive captured.', [
'page_key' => $this->pageKey,
'week' => $this->week,
]);
}
}
The log includes a stable page key, not the full URL. That matters when archived URLs eventually contain campaign parameters or other sensitive query data.
Dispatch and schedule the weekly archive
<?php
// app/Console/Commands/CaptureWeeklyArchive.php
namespace App\Console\Commands;
use App\Jobs\CapturePageScreenshot;
use Illuminate\Console\Command;
final class CaptureWeeklyArchive extends Command
{
protected $signature = 'archive:capture';
protected $description = 'Queue the weekly visual page archive';
public function handle(): int
{
$week = now()->startOfWeek()->toDateString();
foreach (config('visual-archive.pages', []) as $key => $url) {
if (is_string($url) && $url !== '') {
CapturePageScreenshot::dispatch($key, $url, $week);
}
}
return self::SUCCESS;
}
}
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('archive:capture')
->weeklyOn(1, '03:15')
->withoutOverlapping();
Production needs both Laravel’s scheduler trigger and a continuously supervised queue worker. Run php artisan schedule:run every minute through the platform scheduler or cron, and operate php artisan queue:work --tries=3 --timeout=60 under a process supervisor. Ensure the worker timeout exceeds the HTTP timeout, then restart workers during deployment so they load new code and configuration.
Test the boundary without calling the service
Http::fake() makes the tests deterministic and proves that the token, method, query parameter, binary validation, and failure mapping behave as intended.
<?php
namespace Tests\Feature;
use App\Services\ScreenshotCaptureException;
use App\Services\ScreenshotClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class ScreenshotClientTest extends TestCase
{
public function test_it_captures_and_maps_a_png(): void
{
config()->set('services.screenshot_api.token', 'test-token');
config()->set('services.screenshot_api.endpoint',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture');
Http::fake([
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
Http::response(
"\x89PNG\r\n\x1a\nfixture",
200,
['Content-Type' => 'image/png',
'Cache-Control' => 'private'],
),
]);
$result = app(ScreenshotClient::class)
->capture('https://example.com/book');
$this->assertSame(200, $result->status);
$this->assertArrayHasKey('Cache-Control', $result->cacheHeaders);
Http::assertSent(fn (Request $request): bool =>
$request->method() === 'GET'
&& $request->hasHeader('Authorization', 'Bearer test-token')
&& $request['url'] === 'https://example.com/book'
);
}
public function test_it_rejects_a_non_png_success_response(): void
{
config()->set('services.screenshot_api.token', 'test-token');
Http::fake([
'*' => Http::response('not an image', 200,
['Content-Type' => 'text/plain']),
]);
$this->expectException(ScreenshotCaptureException::class);
app(ScreenshotClient::class)->capture('https://example.com/');
}
public function test_authentication_failure_is_not_retryable(): void
{
config()->set('services.screenshot_api.token', 'test-token');
Http::fake(['*' => Http::response('', 401)]);
try {
app(ScreenshotClient::class)->capture('https://example.com/');
$this->fail('Expected capture exception.');
} catch (ScreenshotCaptureException $exception) {
$this->assertFalse($exception->retryable);
$this->assertSame(401, $exception->status);
}
}
}
Security, observability, and common failures
Keep the archive disk private and expose images only through an authenticated controller or short-lived signed storage URL. A screenshot can reveal unpublished prices, customer-facing mistakes, or operational details. Apply the same retention and access rules you would use for internal business documents.
Alert on permanent_failure, quota_limited, and records left in transient_failure after queue retries. Track capture duration and successful captures per scheduled run. Preserve relevant response headers, but never log the authorization header or token. When rotating the service token, update the secret first and restart workers immediately because regeneration revokes the previous token.
- 401 or 403: verify plan activation, the service-scoped token, and whether it was recently regenerated. Do not retry automatically.
- 400 or 422: inspect the configured page URL and compare the request with the official documentation.
- 429: inspect the stored quota and retry-related headers, then adjust plan usage or scheduling. Do not make rapid retries.
- Invalid PNG: investigate upstream or proxy responses; never save the body with a
.pngextension. - No weekly record: verify the platform invokes
schedule:run, a queue worker is alive, and configuration caches were rebuilt after deployment.
Final verification checklist
- Run
php artisan testand confirm every HTTP request is faked. - Run
php artisan archive:capture, then process the jobs withphp artisan queue:work --stop-when-empty. - Confirm one database row and one valid PNG exist for every configured page.
- Run the command again and verify the unique page-and-week records are not duplicated.
- Confirm the archive disk is private and the token is absent from source control and logs.
- Verify the production scheduler, queue supervisor, failure alerts, storage retention, and secret-rotation procedure.
The resulting archive is deliberately modest: a few approved URLs, one weekly schedule, private PNG objects, and a searchable operational ledger. That restraint is its strength. It gives a small business owner a dependable visual memory of the website while leaving browser maintenance, rendering infrastructure, and transient failure recovery outside the day-to-day workload.