Vodiči

Laravel Bookmarks: Safely Preview Links with AI Screenshot API Integration

Laravel Bookmarks: Siguran pregled poveznica uz integraciju AI Screenshot API-ja

A bookmark is more useful when you can recognize it at a glance. Unfortunately, rendering an arbitrary website inside your application is a poor preview strategy: iframes expose users to active third-party content, browser automation is expensive to operate, and fetching untrusted URLs can create security problems.

A screenshot is a cleaner boundary. This tutorial builds a small Laravel bookmarks application that validates public URLs, captures previews asynchronously through a Screenshot API, verifies the returned PNG, stores it privately, and displays it through an authorized route. The application never renders the bookmarked page itself and never needs to maintain Chromium infrastructure.

Get access to the Screenshot API

The service requires authentication; there is no tokenless mode for this API.

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Screenshot API service page.
  3. Choose the available Free, Plus, or Pro plan and complete its activation.
  4. Open the official Screenshot API documentation.
  5. Find the Service token panel and copy the service-scoped token.

Regenerating the service token revokes the previous active token. Treat rotation as a deployment operation: update the secret in every environment before retiring the old deployment, and be prepared for in-flight requests using the revoked value to fail.

Confirm the endpoint before writing application 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.

This project uses the Bearer form. Query-string credentials are best avoided because URLs are commonly retained in access logs and diagnostic systems. Run this minimal check with a placeholder token:

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://example.com' \
  --dump-header preview.headers \
  --output preview.png

A successful response contains an image/png body. Inspect preview.headers as well: cache and quota information arrives in response headers, not in a JSON response object.

Put the credential in the project environment, never in committed PHP code:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_API_ENDPOINT=https://ai.mihajlo.mk/api/screenshot-api/v1/capture
SCREENSHOT_MAX_BYTES=8388608
SCREENSHOT_DISK=local

QUEUE_CONNECTION=database

Architecture and prerequisites

You need PHP 8.3 or newer, Composer, a Laravel application with working authentication, a configured database, a queue backend, and a writable local filesystem disk. Ensure the standard queue jobs table exists when using Laravel’s database queue driver.

The request path stays deliberately short: the controller validates and saves a bookmark, then dispatches a job. A dedicated API client performs bounded retries and maps the binary response into a domain object. The job stores the verified PNG and marks the bookmark ready. An authenticated controller streams the private image.

Background execution matters here. Screenshot capture is remote, comparatively slow work, and can be delayed by upstream throttling. Keeping it outside the web request prevents a slow capture from turning into a slow bookmark form.

composer create-project laravel/laravel bookmark-previews
cd bookmark-previews

php artisan make:model Bookmark -m
php artisan make:controller BookmarkController
php artisan make:job CaptureBookmarkPreview
php artisan make:rule PublicWebUrl
php artisan make:test ScreenshotClientTest

Configure the API boundary

Add a dedicated entry to config/services.php. Reading env() only from configuration files keeps the application compatible with Laravel’s configuration cache.

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

Create app/Services/Screenshot/Screenshot.php and CaptureException.php. The result object carries the bytes and normalized response headers, so cache and quota headers remain available without guessing undocumented header names.

<?php

namespace App\Services\Screenshot;

final readonly class Screenshot
{
    public function __construct(
        public string $png,
        public array $responseHeaders,
    ) {}
}

final class CaptureException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        public readonly ?int $status = null,
    ) {
        parent::__construct("Screenshot capture failed: {$kind}");
    }
}

Now create app/Services/Screenshot/ScreenshotClient.php. The client retries connection failures, HTTP 429, and server errors. It does not retry authentication or validation failures. Three attempts, short backoff, and a capped Retry-After delay keep failure time predictable.

<?php

namespace App\Services\Screenshot;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

final class ScreenshotClient
{
    public function capture(string $url): Screenshot
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken(
                        (string) config('services.screenshot.token')
                    )
                    ->accept('image/png')
                    ->connectTimeout(3)
                    ->timeout(20)
                    ->get(
                        (string) config('services.screenshot.endpoint'),
                        ['url' => $url]
                    );
            } catch (ConnectionException) {
                if ($attempt === 3) {
                    throw new CaptureException('transport', true);
                }

                $this->pause($attempt, null);
                continue;
            }

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

            $status = $response->status();
            $retryable = $status === 429 || $status >= 500;

            if ($retryable && $attempt < 3) {
                $this->pause($attempt, $response->header('Retry-After'));
                continue;
            }

            $kind = match (true) {
                in_array($status, [401, 403], true) => 'authentication',
                in_array($status, [400, 422], true) => 'invalid_request',
                $status === 429 => 'quota',
                $status >= 500 => 'upstream',
                default => 'http_error',
            };

            throw new CaptureException($kind, $retryable, $status);
        }

        throw new CaptureException('unexpected', false);
    }

    private function mapSuccess(Response $response): Screenshot
    {
        $body = $response->body();
        $contentType = strtolower(trim(explode(
            ';',
            (string) $response->header('Content-Type')
        )[0]));

        if ($contentType !== 'image/png') {
            throw new CaptureException('invalid_content_type', false);
        }

        if (strlen($body) > (int) config('services.screenshot.max_bytes')) {
            throw new CaptureException('image_too_large', false);
        }

        $image = @getimagesizefromstring($body);

        if ($image === false || ($image[2] ?? null) !== IMAGETYPE_PNG) {
            throw new CaptureException('invalid_png', false);
        }

        $headers = [];

        foreach ($response->headers() as $name => $values) {
            if (strtolower($name) === 'set-cookie') {
                continue;
            }

            $headers[strtolower($name)] = substr(
                implode(', ', (array) $values),
                0,
                512
            );
        }

        return new Screenshot($body, $headers);
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $milliseconds = $attempt === 1 ? 250 : 1000;

        if ($retryAfter !== null) {
            $value = trim($retryAfter);

            if (ctype_digit($value)) {
                $milliseconds = min((int) $value * 1000, 5000);
            } elseif (($time = strtotime($value)) !== false) {
                $milliseconds = min(max(0, $time - time()) * 1000, 5000);
            }
        }

        usleep($milliseconds * 1000);
    }
}

Model the bookmark lifecycle

The migration needs explicit states rather than a nullable screenshot alone. That distinction lets the interface say whether a capture is pending, running, ready, or failed.

Schema::create('bookmarks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->text('url');
    $table->string('preview_status')->default('pending');
    $table->string('screenshot_path')->nullable();
    $table->json('screenshot_headers')->nullable();
    $table->text('preview_error')->nullable();
    $table->timestamps();
});

In Bookmark, make the application-owned fields fillable and cast the headers:

protected $fillable = [
    'user_id',
    'title',
    'url',
    'preview_status',
    'screenshot_path',
    'screenshot_headers',
    'preview_error',
];

protected function casts(): array
{
    return ['screenshot_headers' => 'array'];
}

Reject dangerous URL shapes

Create a PublicWebUrl validation rule that accepts only HTTP and HTTPS, rejects credentials, IP literals, unusual ports, localhost, missing DNS records, and any hostname resolving to a private or reserved address.

public function validate(string $attribute, mixed $value, Closure $fail): void
{
    $parts = is_string($value) ? parse_url($value) : false;

    if ($parts === false
        || ! in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
        || empty($parts['host'])
        || isset($parts['user'])
        || isset($parts['pass'])
        || (isset($parts['port']) && ! in_array($parts['port'], [80, 443], true))) {
        $fail('Enter a public HTTP or HTTPS URL.');
        return;
    }

    $host = strtolower(rtrim($parts['host'], '.'));

    if ($host === 'localhost'
        || str_ends_with($host, '.localhost')
        || filter_var($host, FILTER_VALIDATE_IP) !== false) {
        $fail('IP addresses and local hosts are not allowed.');
        return;
    }

    $records = dns_get_record($host, DNS_A | DNS_AAAA);

    if ($records === false || $records === []) {
        $fail('The hostname could not be resolved.');
        return;
    }

    foreach ($records as $record) {
        $ip = $record['ip'] ?? $record['ipv6'] ?? null;

        if ($ip === null || filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        ) === false) {
            $fail('The hostname must resolve only to public addresses.');
            return;
        }
    }
}

This is an application-level safeguard, not a complete defense against DNS rebinding. The screenshot provider must also enforce its own network egress policy. For sensitive deployments, an approved-domain allowlist is stronger than accepting arbitrary public hosts.

Queue, store, and serve the preview

The job uses an atomic state transition to prevent duplicate workers from processing the same bookmark. It stores content-addressed filenames on a private disk and exposes only safe error categories to the database.

<?php

namespace App\Jobs;

use App\Models\Bookmark;
use App\Services\Screenshot\CaptureException;
use App\Services\Screenshot\ScreenshotClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;

final class CaptureBookmarkPreview implements ShouldQueue
{
    use Queueable;

    public int $tries = 1;
    public int $timeout = 75;
    public bool $failOnTimeout = true;

    public function __construct(public int $bookmarkId) {}

    public function handle(ScreenshotClient $client): void
    {
        $claimed = Bookmark::query()
            ->whereKey($this->bookmarkId)
            ->where('preview_status', 'pending')
            ->update(['preview_status' => 'processing']);

        if ($claimed !== 1) {
            return;
        }

        $bookmark = Bookmark::findOrFail($this->bookmarkId);

        try {
            $shot = $client->capture($bookmark->url);
            $path = 'bookmark-previews/'.$bookmark->id.'/'
                .hash('sha256', $shot->png).'.png';

            if (! Storage::disk(config('services.screenshot.disk'))
                ->put($path, $shot->png)) {
                throw new RuntimeException('Screenshot storage failed.');
            }

            $bookmark->update([
                'preview_status' => 'ready',
                'screenshot_path' => $path,
                'screenshot_headers' => $shot->responseHeaders,
                'preview_error' => null,
            ]);
        } catch (CaptureException $exception) {
            $bookmark->update([
                'preview_status' => 'failed',
                'preview_error' => $exception->kind,
            ]);

            Log::warning('Bookmark screenshot failed', [
                'bookmark_id' => $bookmark->id,
                'host' => parse_url($bookmark->url, PHP_URL_HOST),
                'kind' => $exception->kind,
                'status' => $exception->status,
            ]);
        }
    }

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

The controller saves only validated input, dispatches after commit, scopes every read to the authenticated owner, and streams the image with a fixed content type:

public function store(Request $request)
{
    $data = $request->validate([
        'title' => ['required', 'string', 'max:200'],
        'url' => ['required', 'string', 'max:2048', new PublicWebUrl],
    ]);

    $bookmark = Bookmark::create([
        'user_id' => $request->user()->id,
        'title' => $data['title'],
        'url' => $data['url'],
        'preview_status' => 'pending',
    ]);

    CaptureBookmarkPreview::dispatch($bookmark->id)->afterCommit();

    return redirect()->route('bookmarks.index');
}

public function preview(Request $request, Bookmark $bookmark)
{
    abort_unless($bookmark->user_id === $request->user()->id, 404);
    abort_unless(
        $bookmark->preview_status === 'ready' && $bookmark->screenshot_path,
        404
    );

    return Storage::disk(config('services.screenshot.disk'))->response(
        $bookmark->screenshot_path,
        null,
        [
            'Content-Type' => 'image/png',
            'X-Content-Type-Options' => 'nosniff',
            'Cache-Control' => 'private, max-age=3600',
        ]
    );
}

Register authenticated routes, including the index and store actions implemented by the controller:

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

In the Blade view, render an image only when the state is ready. Keep the original site behind an ordinary escaped link with opener isolation; never place it in an iframe.

@if ($bookmark->preview_status === 'ready')
    <img
        src="{{ route('bookmarks.preview', $bookmark) }}"
        alt="Preview of {{ $bookmark->title }}"
        loading="lazy"
    >
@elseif ($bookmark->preview_status === 'failed')
    <p>Preview unavailable.</p>
@else
    <p>Generating preview…</p>
@endif

<a href="{{ $bookmark->url }}"
   target="_blank"
   rel="noopener noreferrer nofollow">
    Visit bookmark
</a>

Test the external boundary

Laravel’s HTTP fake makes the tests deterministic and guarantees that no real token or network request is used. The header names below are deliberately synthetic; the test verifies generic preservation rather than asserting an undocumented service header name.

public function test_it_maps_a_png_and_response_headers(): void
{
    config()->set('services.screenshot.endpoint', 'https://service.test/capture');
    config()->set('services.screenshot.token', 'test-token');
    config()->set('services.screenshot.max_bytes', 1024 * 1024);

    $png = base64_decode(
        'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwC'
        .'AAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
    );

    Http::preventStrayRequests();
    Http::fake([
        'https://service.test/capture*' => Http::response($png, 200, [
            'Content-Type' => 'image/png',
            'Cache-Test' => 'hit',
            'Quota-Test' => 'remaining',
        ]),
    ]);

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

    $this->assertSame($png, $result->png);
    $this->assertSame('hit', $result->responseHeaders['cache-test']);
    $this->assertSame('remaining', $result->responseHeaders['quota-test']);

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

public function test_it_does_not_retry_authentication_failures(): void
{
    config()->set('services.screenshot.endpoint', 'https://service.test/capture');
    config()->set('services.screenshot.token', 'bad-token');

    Http::preventStrayRequests();
    Http::fake([
        'https://service.test/capture*' => Http::response('', 401),
    ]);

    try {
        app(ScreenshotClient::class)->capture('https://example.com');
        $this->fail('Expected CaptureException.');
    } catch (CaptureException $exception) {
        $this->assertSame('authentication', $exception->kind);
        $this->assertFalse($exception->retryable);
    }

    Http::assertSentCount(1);
}

Operate it in production

Run migrations, cache production configuration, and start a supervised queue worker:

php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan queue:work --queue=default --timeout=75 --tries=1

The process supervisor should restart workers after deployment. Monitor queue age, failed jobs, capture latency, response status categories, and the service’s cache and quota headers. Never log the Bearer token, full response body, or complete bookmarked URL: URLs often contain private query parameters. Logging the normalized hostname and internal bookmark ID is usually sufficient.

Common failures are recognizable. A 401 or 403 points to a missing, revoked, or incorrectly deployed token. A 400 or 422 indicates that the submitted URL is unacceptable and should not be retried. A 429 means quota pressure; the client honors a bounded Retry-After, then records a quota failure. Repeated 5xx or connection errors indicate an upstream or network problem. A successful status with HTML or malformed bytes is rejected before storage, preventing an error page from masquerading as an image.

Final verification checklist

  • The service plan is active and the service-scoped token is stored only in environment-backed configuration.
  • The minimal request returns a PNG plus the expected cache and quota response headers.
  • Private, reserved, local, credential-bearing, and non-HTTP URLs are rejected.
  • Saving a bookmark returns promptly and places one capture job on the queue.
  • The worker transitions the record from pending to processing, then ready or failed.
  • The stored file passes content-type, size, and PNG validation.
  • Only the bookmark owner can retrieve the privately stored preview.
  • Authentication and validation failures are not retried; transient failures have bounded retries.
  • Logs contain operational context but no tokens, image bodies, or complete sensitive URLs.
  • Automated tests use Http::fake() and prohibit stray external requests.

The important design decision is not merely calling a screenshot endpoint. It is treating remote content as untrusted from the moment a URL enters the form until verified PNG bytes leave an authorized response. With that boundary in place, visual bookmarks remain convenient without turning your Laravel application into a browser farm—or a window into somebody else’s network.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.