Tutorials

Laravel Bookmarks: Secure Visual Link Previews with API Screenshot Integration

Laravel Bookmarks: Secure Visual Link Previews with API Screenshot Integration

A bookmark is more useful when you can recognize it at a glance. A title and URL force you to remember what lies behind the link; a screenshot turns the same list into a visual workspace.

The difficult part is not displaying an image. It is capturing arbitrary web pages safely, without running browsers in your application containers, blocking requests while Chromium starts, or turning your server into an unrestricted URL-fetching proxy. This Laravel implementation delegates capture to a dedicated Screenshot API, processes the PNG asynchronously, validates the response at the application boundary, and preserves cache and quota metadata for operations.

Get access to the Screenshot API

Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.

  1. Open the Screenshot API service page.
  2. Choose the available Free, Plus, or Pro plan and complete activation.
  3. Open the official Screenshot API documentation.
  4. Find the Service token panel and copy its service-scoped token.

Regenerating the service token revokes the previously active token, so treat rotation as a deployment change: update every environment using it, restart workers, and then revoke or regenerate deliberately.

This is not a no-token service. Every capture request must authenticate using a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer header because query credentials can appear in access logs, browser history, and monitoring URLs.

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 headers.

Run one minimal test before writing integration code:

SCREENSHOT_TOKEN=YOUR_SERVICE_TOKEN

curl --fail-with-body \
  --header "Authorization: Bearer ${SCREENSHOT_TOKEN}" \
  --header "Accept: image/png" \
  --get \
  --data-urlencode "url=https://example.com" \
  --output example.png \
  https://ai.mihajlo.mk/api/screenshot-api/v1/capture

Now store the credential in Laravel's environment configuration. Never commit the populated .env file.

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_API_ENDPOINT=https://ai.mihajlo.mk/api/screenshot-api/v1/capture
SCREENSHOT_MAX_BYTES=10485760
QUEUE_CONNECTION=database

Add the service boundary to config/services.php:

'screenshot' => [
    'token' => env('SCREENSHOT_API_TOKEN'),
    'endpoint' => env(
        'SCREENSHOT_API_ENDPOINT',
        'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
    ),
    'max_bytes' => (int) env('SCREENSHOT_MAX_BYTES', 10 * 1024 * 1024),
],

Architecture: keep capture off the request path

The application accepts a bookmark, commits it immediately, and dispatches a queue job. The job calls the API, verifies the PNG, stores it on Laravel's private local disk, and updates the bookmark. A protected controller serves the file later.

This small asynchronous boundary matters. Screenshot generation involves another network and a remote renderer; making users wait would couple form latency to both. A queue also gives transient failures somewhere controlled to retry.

The relevant project structure is:

app/
  Data/ScreenshotResult.php
  Exceptions/ScreenshotFailure.php
  Http/Controllers/BookmarkController.php
  Http/Requests/StoreBookmarkRequest.php
  Jobs/GenerateBookmarkPreview.php
  Models/Bookmark.php
  Services/ScreenshotClient.php
database/migrations/..._create_bookmarks_table.php
tests/Unit/ScreenshotClientTest.php

This tutorial assumes a Laravel application running PHP 8.3 or newer with authentication already enabled. For a database-backed queue, create the queue table if the project does not already have one, then run the migrations:

php artisan make:model Bookmark -m
php artisan make:request StoreBookmarkRequest
php artisan make:controller BookmarkController
php artisan make:job GenerateBookmarkPreview
php artisan make:test ScreenshotClientTest --unit
php artisan make:queue-table
php artisan migrate

Model the preview as state, not a nullable image

A missing path can mean queued, failed, or never requested. Make those states explicit, and retain operational response metadata without pretending undocumented header names are stable fields.

<?php
// database/migrations/..._create_bookmarks_table.php

Schema::create('bookmarks', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('url');
    $table->string('preview_status')->default('queued');
    $table->string('preview_path')->nullable();
    $table->string('preview_error')->nullable();
    $table->json('preview_meta')->nullable();
    $table->timestamp('preview_generated_at')->nullable();
    $table->timestamps();
});

// app/Models/Bookmark.php

final class Bookmark extends Model
{
    protected $fillable = [
        'title', 'url', 'preview_status', 'preview_path',
        'preview_error', 'preview_meta', 'preview_generated_at',
    ];

    protected function casts(): array
    {
        return [
            'preview_meta' => 'array',
            'preview_generated_at' => 'immutable_datetime',
        ];
    }
}

Build a defensive API boundary

The client returns a domain object instead of leaking Laravel's HTTP response throughout the application. It uses bounded timeouts, retries connection failures, HTTP 408, and server errors with short backoff, but does not blindly retry invalid requests or failed authentication.

<?php
// app/Data/ScreenshotResult.php
namespace App\Data;

final readonly class ScreenshotResult
{
    public function __construct(
        public string $png,
        public array $operationalHeaders,
    ) {}
}

// app/Exceptions/ScreenshotFailure.php
namespace App\Exceptions;

use RuntimeException;

final class ScreenshotFailure extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        public readonly ?int $retryAfter = null,
        string $message = 'Screenshot capture failed',
    ) {
        parent::__construct($message);
    }
}
<?php
// app/Services/ScreenshotClient.php
namespace App\Services;

use App\Data\ScreenshotResult;
use App\Exceptions\ScreenshotFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

final class ScreenshotClient
{
    public function capture(string $url): ScreenshotResult
    {
        $token = (string) config('services.screenshot.token');

        if ($token === '') {
            throw new ScreenshotFailure(
                'configuration', false, null, 'Screenshot token is missing'
            );
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($token)
                    ->accept('image/png')
                    ->connectTimeout(3)
                    ->timeout(20)
                    ->get(config('services.screenshot.endpoint'), [
                        'url' => $url,
                    ]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotFailure(
                        'connection', true, null, 'Screenshot connection failed'
                    );
                }

                $this->backoff($attempt);
                continue;
            }

            if ($response->successful()) {
                return $this->mapPng($response);
            }

            if ($response->status() === 429) {
                throw new ScreenshotFailure(
                    'quota', true, $this->retryAfter($response),
                    'Screenshot quota is temporarily unavailable'
                );
            }

            if (in_array($response->status(), [401, 403], true)) {
                throw new ScreenshotFailure(
                    'authentication', false, null,
                    'Screenshot credentials were rejected'
                );
            }

            if (in_array($response->status(), [400, 422], true)) {
                throw new ScreenshotFailure(
                    'request_rejected', false, null,
                    'Screenshot request was rejected'
                );
            }

            if ($response->status() === 408 || $response->serverError()) {
                if ($attempt < 3) {
                    $this->backoff($attempt);
                    continue;
                }

                throw new ScreenshotFailure(
                    'upstream_transient', true, null,
                    'Screenshot service remained unavailable'
                );
            }

            throw new ScreenshotFailure(
                'upstream_rejected', false, null,
                'Screenshot service returned an unexpected status'
            );
        }

        throw new ScreenshotFailure('unknown', true);
    }

    private function mapPng(Response $response): ScreenshotResult
    {
        $body = $response->body();
        $contentType = strtolower((string) $response->header('Content-Type'));
        $limit = (int) config('services.screenshot.max_bytes');

        if (! str_starts_with($contentType, 'image/png')
            || ! str_starts_with($body, "\x89PNG\r\n\x1a\n")
            || strlen($body) > $limit) {
            throw new ScreenshotFailure(
                'invalid_image', true, null,
                'Screenshot response was not an acceptable PNG'
            );
        }

        return new ScreenshotResult($body, $this->operationalHeaders($response));
    }

    private function operationalHeaders(Response $response): array
    {
        $result = [];

        foreach ($response->headers() as $name => $values) {
            $normalized = strtolower($name);

            if (in_array($normalized, [
                'cache-control', 'age', 'etag', 'expires', 'retry-after',
            ], true) || str_contains($normalized, 'quota')
                || str_contains($normalized, 'ratelimit')) {
                $result[$normalized] = implode(', ', $values);
            }
        }

        return $result;
    }

    private function retryAfter(Response $response): int
    {
        $value = $response->header('Retry-After');

        return is_numeric($value)
            ? min(max((int) $value, 60), 3600)
            : 300;
    }

    private function backoff(int $attempt): void
    {
        usleep($attempt === 1 ? 250_000 : 750_000);
    }
}

The header mapper deliberately recognizes standard cache headers and headers explicitly labelled as quota or rate-limit metadata. This handles operational information without storing every upstream header or inventing undocumented response fields. Pin exact names from the official documentation if your monitoring system requires fixed metrics.

Validate URLs before dispatch

The API will fetch a URL on the application's behalf, so users should not be able to submit credentials, local hostnames, or literal IP addresses. Restricting the feature to HTTPS public hostnames also reduces accidental leakage of sensitive internal URLs.

<?php
// app/Http/Requests/StoreBookmarkRequest.php
namespace App\Http\Requests;

use Closure;
use Illuminate\Foundation\Http\FormRequest;

final class StoreBookmarkRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:200'],
            'url' => [
                'required', 'string', 'max:2048',
                function (string $attribute, mixed $value, Closure $fail): void {
                    $parts = is_string($value) ? parse_url($value) : false;
                    $host = is_array($parts) ? ($parts['host'] ?? '') : '';

                    $invalid = $parts === false
                        || strtolower($parts['scheme'] ?? '') !== 'https'
                        || $host === ''
                        || isset($parts['user'])
                        || isset($parts['pass'])
                        || filter_var($host, FILTER_VALIDATE_IP) !== false
                        || $host === 'localhost'
                        || str_ends_with(strtolower($host), '.local');

                    if ($invalid) {
                        $fail('Use an HTTPS URL with a public hostname.');
                    }
                },
            ],
        ];
    }
}

This is application-side abuse prevention, not proof that a hostname always resolves publicly. Avoid synchronous DNS checks in the request path; DNS can change between validation and capture. If your product accepts untrusted URLs broadly, confirm the service's current network-access safeguards in its official documentation and consider a domain allowlist.

Generate and serve previews

<?php
// app/Jobs/GenerateBookmarkPreview.php
namespace App\Jobs;

use App\Exceptions\ScreenshotFailure;
use App\Models\Bookmark;
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 GenerateBookmarkPreview implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public int $timeout = 75;

    public function __construct(public readonly int $bookmarkId) {}

    public function backoff(): array
    {
        return [30, 120, 300];
    }

    public function handle(ScreenshotClient $client): void
    {
        $bookmark = Bookmark::find($this->bookmarkId);

        if ($bookmark === null) {
            return;
        }

        try {
            $result = $client->capture($bookmark->url);
        } catch (ScreenshotFailure $failure) {
            Log::log(
                $failure->kind === 'authentication' ? 'critical' : 'warning',
                'Bookmark preview capture failed',
                ['bookmark_id' => $this->bookmarkId, 'kind' => $failure->kind]
            );

            if ($failure->kind === 'quota') {
                $bookmark->update(['preview_error' => 'quota_deferred']);
                $this->release($failure->retryAfter ?? 300);
                return;
            }

            if ($failure->retryable) {
                throw $failure;
            }

            $bookmark->update([
                'preview_status' => 'failed',
                'preview_error' => $failure->kind,
            ]);
            return;
        }

        $path = "bookmarks/previews/{$bookmark->id}.png";
        Storage::disk('local')->put($path, $result->png);

        $bookmark->update([
            'preview_status' => 'ready',
            'preview_path' => $path,
            'preview_error' => null,
            'preview_meta' => $result->operationalHeaders,
            'preview_generated_at' => now(),
        ]);
    }

    public function failed(?Throwable $exception): void
    {
        Bookmark::whereKey($this->bookmarkId)->update([
            'preview_status' => 'failed',
            'preview_error' => 'retries_exhausted',
        ]);
    }
}

The deterministic filename makes duplicate jobs harmless: a later successful capture replaces the same preview. Logs contain the bookmark ID and structured failure kind, never the submitted URL, token, PNG, or upstream response body.

<?php
// routes/web.php
Route::middleware('auth')->group(function () {
    Route::post('/bookmarks', [BookmarkController::class, 'store']);
    Route::get('/bookmarks/{bookmark}/preview', [
        BookmarkController::class, 'preview',
    ])->name('bookmarks.preview');
});

// app/Http/Controllers/BookmarkController.php
final class BookmarkController extends Controller
{
    public function store(StoreBookmarkRequest $request): RedirectResponse
    {
        $bookmark = Bookmark::create([
            ...$request->validated(),
            'preview_status' => 'queued',
        ]);

        GenerateBookmarkPreview::dispatch($bookmark->id);

        return back()->with('status', 'Bookmark saved; preview queued.');
    }

    public function preview(Bookmark $bookmark): BinaryFileResponse
    {
        abort_unless(
            $bookmark->preview_status === 'ready'
            && $bookmark->preview_path !== null
            && Storage::disk('local')->exists($bookmark->preview_path),
            404
        );

        return response()->file(
            Storage::disk('local')->path($bookmark->preview_path),
            [
                'Content-Type' => 'image/png',
                'Cache-Control' => 'private, max-age=3600',
                'X-Content-Type-Options' => 'nosniff',
            ]
        );
    }
}

Keep these routes authenticated. In a multi-user bookmark application, add an owner identifier and authorize both the bookmark and preview through a Laravel policy; authentication alone is not tenant isolation.

Test the external boundary without the network

Laravel's Http::fake() makes success and quota behavior deterministic.

<?php
namespace Tests\Unit;

use App\Exceptions\ScreenshotFailure;
use App\Services\ScreenshotClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class ScreenshotClientTest extends TestCase
{
    public function test_it_maps_a_valid_png_and_cache_headers(): void
    {
        config()->set('services.screenshot.token', 'test-token');
        config()->set('services.screenshot.max_bytes', 1024);
        config()->set(
            'services.screenshot.endpoint',
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
        );

        $png = "\x89PNG\r\n\x1a\nfake";
        Http::fake([
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
                Http::response($png, 200, [
                    'Content-Type' => 'image/png',
                    'Cache-Control' => 'public, max-age=60',
                ]),
        ]);

        $result = app(ScreenshotClient::class)->capture(
            'https://example.com'
        );

        $this->assertSame($png, $result->png);
        $this->assertSame(
            'public, max-age=60',
            $result->operationalHeaders['cache-control']
        );

        Http::assertSent(fn ($request) =>
            $request->hasHeader('Authorization', 'Bearer test-token')
            && $request['url'] === 'https://example.com'
        );
    }

    public function test_it_defers_instead_of_retrying_a_quota_response(): void
    {
        config()->set('services.screenshot.token', 'test-token');

        Http::fake([
            '*' => Http::response('', 429, ['Retry-After' => '120']),
        ]);

        try {
            app(ScreenshotClient::class)->capture('https://example.com');
            $this->fail('Expected ScreenshotFailure');
        } catch (ScreenshotFailure $failure) {
            $this->assertSame('quota', $failure->kind);
            $this->assertSame(120, $failure->retryAfter);
        }

        Http::assertSentCount(1);
    }
}

Deployment, monitoring, and common failures

Run tests, cache production configuration, and start a queue worker whose process timeout exceeds the job timeout:

php artisan test
php artisan config:cache
php artisan queue:work --tries=5 --timeout=90

After changing or rotating the token, rebuild the configuration cache and restart long-running workers with php artisan queue:restart. Ensure storage is writable and persistent across deployments, or replace the local disk with an appropriately configured shared Laravel filesystem disk.

Monitor counts by structured failure kind, queue age, exhausted jobs, capture latency, and quota-related deferrals. Alert on authentication failures because retries cannot repair a revoked or incorrectly deployed token.

  • 401 or 403: verify the service-scoped token, plan activation, environment injection, and configuration cache.
  • 429 or repeated quota deferrals: inspect the returned quota metadata, reduce unnecessary recaptures, or review the active plan.
  • Invalid image: inspect status and content type in a controlled environment, but do not log the binary body.
  • Jobs remain queued: confirm the worker uses the same queue connection as the web process.
  • Preview returns 404: check the bookmark state, storage persistence, file permissions, and failed-job records.

Final verification checklist

  • The token exists only in environment-backed configuration.
  • The request uses the exact GET capture endpoint and required url parameter.
  • Only acceptable HTTPS public hostnames reach the queue.
  • The client enforces timeouts, bounded retries, PNG validation, and a local size cap.
  • Authentication and validation failures are not retried blindly.
  • Quota responses defer work instead of producing a retry storm.
  • Cache and quota metadata cross the API boundary without exposing arbitrary headers.
  • Preview files remain private and are served through an authenticated route.
  • Tests use Http::fake() and never consume live quota.

The finished feature is intentionally modest: save a bookmark, let a worker produce its visual preview, and serve the result safely. Its production quality comes from the boundaries around that simple flow. The remote renderer stays out of your infrastructure, credentials stay out of code, failure states stay visible, and a convenient thumbnail never becomes an excuse to weaken the application around it.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.