Laravel: Tjedne snimke zaslona web-stranice za vlasnike malih poduzeća
A website can break quietly. A theme update shifts a call-to-action below the fold, a booking page loses its styling, or a seasonal promotion survives weeks past its deadline. Uptime monitoring still reports success because the server continues returning HTTP 200.
A weekly screenshot history catches a different class of problem: unwanted visual change. This tutorial builds a production-oriented Laravel application that captures important pages every Monday, stores private PNG files, records operational metadata, and retries only failures that may recover.
Get access to the Screenshot API
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. Then visit the official documentation. Find the Service token panel and copy its service-scoped token.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form so the credential does not appear in URLs, proxy logs, or browser history. Regenerating the service token revokes the previously active token, so plan token rotation as a deployment rather than an isolated dashboard action.
The exact request is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, with a required url query parameter. Test it without writing the image to your terminal:
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 \
--dump-header screenshot.headers
A successful response body is image/png. The response also includes cache and quota information in headers. The application will retain those headers as metadata without assuming undocumented response fields.
Store the token in Laravel’s environment configuration before building the feature:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
BUSINESS_SITE_URL=https://www.example.com
VISUAL_HISTORY_DISK=local
QUEUE_CONNECTION=database
Never commit .env. Production secrets should come from the hosting platform’s encrypted secret store, while .env.example contains only placeholders.
Architecture that fits a small business
The project needs PHP 8.3 or newer, a maintained Laravel application, a database, a configured queue, and writable private storage. No browser automation package is required because the API supplies the rendered PNG.
A weekly scheduler creates one database record per configured page and period. Queue jobs perform the slower network and storage work. A dedicated client validates the remote boundary and maps it into a small domain object. This separates four concerns:
- The command decides what should be captured.
- The queue controls retries and prevents scheduler timeouts.
- The client understands HTTP, authentication, and PNG validation.
- The snapshot record provides an auditable history of successes and failures.
The relevant files are config/services.php, config/visual-history.php, app/Services/ScreenshotClient.php, app/Jobs/CapturePageSnapshot.php, app/Console/Commands/CaptureVisualHistory.php, app/Models/PageSnapshot.php, a migration, and routes/console.php.
Configure the service and monitored pages
Add the following entry to the array returned by config/services.php:
'screenshot' => [
'base_url' => 'https://ai.mihajlo.mk/api/screenshot-api',
'token' => env('SCREENSHOT_API_TOKEN'),
'connect_timeout' => 5,
'timeout' => 45,
],
Create config/visual-history.php. Keep the initial list short: the homepage, contact page, and the page most closely tied to revenue are usually more useful than an exhaustive crawl.
<?php
$site = rtrim(env('BUSINESS_SITE_URL', 'https://example.com'), '/');
return [
'disk' => env('VISUAL_HISTORY_DISK', 'local'),
'pages' => [
['key' => 'home', 'url' => $site],
['key' => 'contact', 'url' => $site.'/contact'],
['key' => 'booking', 'url' => $site.'/booking'],
],
];
These URLs are trusted deployment configuration, not visitor input. That distinction matters: accepting arbitrary URLs would create a server-side request forgery surface in both your application and the downstream capture service.
Persist an idempotent weekly history
Create the model and migration with Artisan:
php artisan make:model PageSnapshot -m
php artisan make:job CapturePageSnapshot
php artisan make:command CaptureVisualHistory
php artisan queue:table
php artisan migrate
Define the snapshot table in the generated migration:
Schema::create('page_snapshots', function (Blueprint $table) {
$table->id();
$table->string('page_key', 80);
$table->text('url');
$table->date('period_start');
$table->timestamp('captured_at')->nullable();
$table->string('status', 20)->default('pending');
$table->string('disk')->nullable();
$table->string('path')->nullable();
$table->unsignedSmallInteger('http_status')->nullable();
$table->json('response_headers')->nullable();
$table->text('error')->nullable();
$table->timestamps();
$table->unique(['page_key', 'period_start']);
});
The unique constraint makes scheduling idempotent. If a scheduler runs twice, it cannot create two “weekly” captures for the same page.
In PageSnapshot, allow the fields above through $fillable and cast period_start to date, captured_at to datetime, and response_headers to array.
Build a defensive API boundary
Create a small immutable response object and two exception classes under app/Services:
<?php
namespace App\Services;
final readonly class CaptureImage
{
public function __construct(
public string $bytes,
public int $status,
public array $operationalHeaders,
) {}
}
class PermanentCaptureException extends \RuntimeException {}
final class RetryableCaptureException extends \RuntimeException
{
public function __construct(
string $message,
public readonly ?int $retryAfter = null,
) {
parent::__construct($message);
}
}
The client uses Laravel’s built-in HTTP client, bounded timeouts, and explicit failure classification. It does not retry authentication, validation, or other non-transient client errors.
<?php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
final class ScreenshotClient
{
public function capture(string $url): CaptureImage
{
if (! filter_var($url, FILTER_VALIDATE_URL)
|| ! in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new PermanentCaptureException('Configured capture URL is invalid.');
}
$token = config('services.screenshot.token');
if (! is_string($token) || $token === '') {
throw new PermanentCaptureException('Screenshot API token is missing.');
}
try {
$response = Http::baseUrl(config('services.screenshot.base_url'))
->withToken($token)
->accept('image/png')
->connectTimeout(config('services.screenshot.connect_timeout'))
->timeout(config('services.screenshot.timeout'))
->get('/v1/capture', ['url' => $url]);
} catch (ConnectionException $e) {
throw new RetryableCaptureException('Screenshot service connection failed.');
}
if ($response->status() === 429 || $response->serverError()) {
$retryAfter = filter_var(
$response->header('Retry-After'),
FILTER_VALIDATE_INT
);
throw new RetryableCaptureException(
'Screenshot service returned a transient response.',
$retryAfter === false ? null : min(max($retryAfter, 1), 900),
);
}
if ($response->failed()) {
throw new PermanentCaptureException(
'Screenshot request rejected with HTTP '.$response->status().'.'
);
}
$contentType = strtolower($response->header('Content-Type', ''));
if (! str_starts_with($contentType, 'image/png')
|| ! str_starts_with($response->body(), "\x89PNG\r\n\x1a\n")) {
throw new RetryableCaptureException('Response was not a valid PNG.');
}
$headers = [];
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') {
$headers[$name] = implode(', ', $values);
}
}
return new CaptureImage($response->body(), $response->status(), $headers);
}
}
Only operational headers are retained. The token, response body, and potentially sensitive page content never enter logs.
Capture in a retry-aware queue job
The job writes to a deterministic private path. Re-execution overwrites the same object instead of creating duplicates.
<?php
namespace App\Jobs;
use App\Models\PageSnapshot;
use App\Services\PermanentCaptureException;
use App\Services\RetryableCaptureException;
use App\Services\ScreenshotClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class CapturePageSnapshot implements ShouldQueue
{
use Queueable;
public int $tries = 4;
public int $timeout = 60;
public function __construct(public int $snapshotId) {}
public function handle(ScreenshotClient $client): void
{
$snapshot = PageSnapshot::findOrFail($this->snapshotId);
try {
$image = $client->capture($snapshot->url);
} catch (PermanentCaptureException $e) {
$this->recordFailure($snapshot, $e);
$this->fail($e);
return;
} catch (RetryableCaptureException $e) {
Log::warning('Weekly screenshot will be retried', [
'snapshot_id' => $snapshot->id,
'page_key' => $snapshot->page_key,
'attempt' => $this->attempts(),
]);
if ($this->attempts() >= $this->tries) {
$this->recordFailure($snapshot, $e);
$this->fail($e);
return;
}
$fallback = [60, 300, 900][$this->attempts() - 1] ?? 900;
$this->release($e->retryAfter ?? $fallback);
return;
}
$disk = config('visual-history.disk');
$path = 'screenshots/'.$snapshot->period_start->format('Y/m/d')
.'/'.$snapshot->id.'.png';
if (! Storage::disk($disk)->put($path, $image->bytes)) {
throw new \RuntimeException('Could not store captured PNG.');
}
$snapshot->update([
'status' => 'succeeded',
'captured_at' => now(),
'disk' => $disk,
'path' => $path,
'http_status' => $image->status,
'response_headers' => $image->operationalHeaders,
'error' => null,
]);
}
private function recordFailure(PageSnapshot $snapshot, Throwable $e): void
{
$snapshot->update([
'status' => 'failed',
'error' => mb_substr($e->getMessage(), 0, 1000),
]);
Log::error('Weekly screenshot failed', [
'snapshot_id' => $snapshot->id,
'page_key' => $snapshot->page_key,
'exception' => $e::class,
]);
}
}
Dispatch and schedule the weekly run
In the command’s handle() method, create each period record atomically and dispatch only newly created work:
public function handle(): int
{
$period = now()->startOfWeek()->toDateString();
foreach (config('visual-history.pages', []) as $page) {
$snapshot = PageSnapshot::firstOrCreate(
['page_key' => $page['key'], 'period_start' => $period],
['url' => $page['url'], 'status' => 'pending'],
);
if ($snapshot->wasRecentlyCreated) {
CapturePageSnapshot::dispatch($snapshot->id);
}
}
return self::SUCCESS;
}
Set the command signature to visual-history:capture. Then schedule it in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('visual-history:capture')
->weeklyOn(1, '06:00')
->withoutOverlapping();
Test without contacting the service
Laravel’s Http::fake() makes the boundary deterministic. One test should prove storage and metadata behavior; another should prove that authentication failures are not retried.
public function test_job_stores_a_valid_png(): void
{
Storage::fake('local');
config()->set('visual-history.disk', 'local');
config()->set('services.screenshot.token', 'test-token');
Http::fake([
'*' => Http::response(
"\x89PNG\r\n\x1a\nfake-payload",
200,
['Content-Type' => 'image/png', 'X-Cache' => 'HIT']
),
]);
$snapshot = PageSnapshot::create([
'page_key' => 'home',
'url' => 'https://example.com/',
'period_start' => now()->startOfWeek()->toDateString(),
'status' => 'pending',
]);
(new CapturePageSnapshot($snapshot->id))
->handle(app(ScreenshotClient::class));
$snapshot->refresh();
$this->assertSame('succeeded', $snapshot->status);
$this->assertSame('HIT', $snapshot->response_headers['X-Cache']);
Storage::disk('local')->assertExists($snapshot->path);
}
public function test_authentication_error_is_permanent(): void
{
config()->set('services.screenshot.token', 'invalid-test-token');
Http::fake(['*' => Http::response('', 401)]);
try {
app(ScreenshotClient::class)->capture('https://example.com/');
$this->fail('Expected a permanent failure.');
} catch (PermanentCaptureException) {
Http::assertSentCount(1);
}
}
Deploy, observe, and troubleshoot
Run php artisan config:cache after injecting production environment values. Keep storage/app private, or use a private object-storage disk. If screenshots are exposed in an admin interface, authorize every download and issue short-lived links rather than publishing the storage directory.
The server needs one cron entry that runs php artisan schedule:run every minute, plus a supervised php artisan queue:work process. Alert on failed jobs and on snapshot records left in pending beyond the expected capture window. Logs should identify the snapshot and page key, never the token or PNG body.
Common failures have distinct remedies:
- 401 or 403: verify the active service-scoped token and redeploy after rotation.
- 429: inspect stored quota headers, reduce the monitored set, or revisit the selected plan.
- Timeouts or 5xx responses: let the bounded queue backoff run; do not create an aggressive retry loop.
- Invalid PNG: inspect status and operational headers, but do not save an HTML error page as a screenshot.
- No weekly records: confirm the scheduler cron, application timezone, cached configuration, and command registration.
- Pending records never finish: verify that the queue worker uses the same environment and database as the web application.
Final verification checklist
- Run
php artisan testand confirm no test reaches the external API. - Run
php artisan visual-history:capture. - Start a worker with
php artisan queue:work --stop-when-empty. - Confirm one successful record per configured page and weekly period.
- Open the stored files and verify that they are valid PNG screenshots.
- Confirm cache and quota-related headers are present when supplied by the service.
- Run the command again and verify that the unique period constraint prevents duplicates.
- Test a deliberately invalid token outside production and confirm the request fails once without blind retries.
The result is intentionally modest: a private row of weekly visual evidence for the pages that matter. That small archive answers questions uptime checks cannot—when a layout changed, whether a promotion appeared correctly, and how long a broken page remained visible. Good production integrations are often like this: narrow in scope, defensive at their boundaries, and quietly useful every week.