Туториали

Laravel Link Previews: Safely Capture and Display Website Thumbnails in Your Bookmarks

Laravel прегледи на врски: Безбедно снимајте и прикажувајте сликички од веб-страници во вашите обележувачи

A bookmark list becomes far more useful when each saved URL has a recognizable visual preview. The awkward part is producing those previews reliably: running Chromium consumes memory, requires browser patching, and turns ordinary page rendering into an infrastructure responsibility.

This tutorial builds a Laravel bookmark application that captures PNG previews through a Screenshot API, processes them in the queue, validates the returned image, and stores it on Laravel’s public disk. The result is a responsive application that never exposes its service token or makes visitors wait for a browser capture.

Get access and copy the service token

Complete the service onboarding before writing integration code:

  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 service documentation.
  5. Find the Service token panel and copy the service-scoped token.

Regenerating this token revokes the previously active token, so token rotation must include updating the application environment and restarting long-lived workers. This service requires authentication; there is no unauthenticated mode. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because it keeps the credential out of URLs and access logs.

Confirm the exact endpoint

The capture operation is an HTTP GET request to https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Its required url query parameter identifies the page to capture, and a successful response contains an image/png body plus cache and quota response headers.

Make one minimal request from a trusted terminal. The output is binary, so write it to a file while retaining headers for inspection:

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/' \
  --dump-header /tmp/screenshot-headers.txt \
  --output /tmp/example-preview.png

Do not paste the token into source control or shell history on a shared machine. Put it in Laravel’s local environment configuration instead:

# .env
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_CONNECT_TIMEOUT=3
SCREENSHOT_RESPONSE_TIMEOUT=20

# Local development only; use a managed queue in production.
QUEUE_CONNECTION=database

Architecture and trade-offs

The web request should validate and save a bookmark quickly. A queued job then calls the screenshot service, checks the response boundary, and writes a locally controlled PNG. The bookmarks page displays that stored file rather than embedding an arbitrary remote image.

This separation gives the application bounded HTTP latency, queue-level retries, and a stable preview even if the destination later changes. It also consumes storage and means previews are eventually consistent: a new bookmark briefly shows a pending state.

The relevant project structure is deliberately small:

  • app/Http/Controllers/BookmarkController.php validates and creates bookmarks.
  • app/Services/ScreenshotClient.php owns the external HTTP contract.
  • app/Services/ScreenshotResult.php maps transport responses into domain outcomes.
  • app/Jobs/CaptureBookmarkPreview.php controls persistence and retry behavior.
  • resources/views/bookmarks/index.blade.php renders previews safely.

Create the bookmark storage

This implementation assumes PHP 8.3 or newer, a supported Laravel application, a configured database, and a working queue backend. Generate the application pieces and the database queue table:

php artisan make:model Bookmark -m
php artisan make:controller BookmarkController
php artisan make:job CaptureBookmarkPreview
php artisan make:queue-table
php artisan migrate
php artisan storage:link

Define the bookmark fields in the generated migration:

<?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('bookmarks', function (Blueprint $table): void {
            $table->id();
            $table->string('title');
            $table->text('url');
            $table->string('preview_status')->default('pending');
            $table->string('preview_path')->nullable();
            $table->string('preview_error')->nullable();
            $table->json('preview_meta')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('bookmarks');
    }
};

Make those attributes assignable and cast the operational metadata:

<?php
// app/Models/Bookmark.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

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

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

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' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
    'token' => env('SCREENSHOT_API_TOKEN'),
    'connect_timeout' => (int) env('SCREENSHOT_CONNECT_TIMEOUT', 3),
    'timeout' => (int) env('SCREENSHOT_RESPONSE_TIMEOUT', 20),
],

Map every remote response into a small domain object. Header names beyond the documented cache-and-quota categories should not be guessed, so the mapper retains headers whose names identify cache, quota, or rate-limit information. It also records the standard Retry-After header when present.

<?php
// app/Services/ScreenshotResult.php

namespace App\Services;

final readonly class ScreenshotResult
{
    public function __construct(
        public bool $successful,
        public ?string $png,
        public string $failure,
        public bool $retryable,
        public ?int $retryAfter,
        public array $operationalHeaders,
    ) {}
}

The client enforces timeouts, checks status and content type, caps the accepted body size, and verifies the PNG signature. Connection failures and server errors receive two bounded, short retries. Authentication, validation, and quota responses are not blindly retried.

<?php
// app/Services/ScreenshotClient.php

namespace App\Services;

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

final class ScreenshotClient
{
    private const MAX_BYTES = 5_242_880;

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

        if (! is_string($token) || $token === '') {
            throw new RuntimeException('Screenshot API token is not configured.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($token)
                    ->accept('image/png')
                    ->connectTimeout(config('services.screenshot.connect_timeout'))
                    ->timeout(config('services.screenshot.timeout'))
                    ->get(config('services.screenshot.endpoint'), ['url' => $url]);
            } catch (ConnectionException $exception) {
                Log::warning('Screenshot transport failure', [
                    'attempt' => $attempt,
                    'exception' => $exception::class,
                ]);

                if ($attempt < 3) {
                    usleep($attempt === 1 ? 250_000 : 750_000);
                    continue;
                }

                return new ScreenshotResult(
                    false, null, 'transport_failure', true, null, []
                );
            }

            if ($response->serverError() && $attempt < 3) {
                Log::warning('Screenshot upstream server error', [
                    'attempt' => $attempt,
                    'status' => $response->status(),
                ]);
                usleep($attempt === 1 ? 250_000 : 750_000);
                continue;
            }

            return $this->map($response);
        }

        throw new RuntimeException('Unreachable screenshot retry state.');
    }

    private function map(Response $response): ScreenshotResult
    {
        $headers = [];

        foreach ($response->headers() as $name => $values) {
            if (preg_match('/cache|quota|rate.?limit|retry-after/i', $name)) {
                $headers[$name] = array_values((array) $values);
            }
        }

        $retryAfter = filter_var(
            $response->header('Retry-After'),
            FILTER_VALIDATE_INT,
            ['options' => ['min_range' => 0]]
        );

        if ($response->status() === 429) {
            return new ScreenshotResult(
                false, null, 'quota_limited', true,
                $retryAfter === false ? null : $retryAfter,
                $headers
            );
        }

        if (in_array($response->status(), [401, 403], true)) {
            return new ScreenshotResult(
                false, null, 'authentication_failed', false, null, $headers
            );
        }

        if ($response->serverError()) {
            return new ScreenshotResult(
                false, null, 'upstream_failure', true, null, $headers
            );
        }

        if (! $response->successful()) {
            return new ScreenshotResult(
                false, null, 'request_rejected', false, null, $headers
            );
        }

        $body = $response->body();
        $isPng = str_starts_with(
            strtolower((string) $response->header('Content-Type')),
            'image/png'
        ) && str_starts_with($body, "\x89PNG\r\n\x1a\n");

        if (! $isPng || strlen($body) > self::MAX_BYTES) {
            return new ScreenshotResult(
                false, null, 'invalid_image_response', false, null, $headers
            );
        }

        return new ScreenshotResult(true, $body, '', false, null, $headers);
    }
}

Validate URLs and dispatch the capture

URL validation is both a security boundary and quota control. Accept only HTTP and HTTPS, reject credentials embedded in the URL, and refuse obvious local targets. Provider-side destination controls remain essential because application validation cannot eliminate DNS rebinding at the remote fetch boundary.

<?php
// app/Http/Controllers/BookmarkController.php

namespace App\Http\Controllers;

use App\Jobs\CaptureBookmarkPreview;
use App\Models\Bookmark;
use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

final class BookmarkController
{
    public function index(): View
    {
        return view('bookmarks.index', [
            'bookmarks' => Bookmark::latest()->get(),
        ]);
    }

    public function store(Request $request): RedirectResponse
    {
        $data = $request->validate([
            'title' => ['required', 'string', 'max:120'],
            'url' => [
                'required',
                'url:http,https',
                'max:2048',
                function (string $attribute, mixed $value, Closure $fail): void {
                    $parts = parse_url((string) $value);
                    $host = strtolower((string) ($parts['host'] ?? ''));

                    if (isset($parts['user']) || isset($parts['pass'])) {
                        $fail('URLs containing credentials are not allowed.');
                        return;
                    }

                    if ($host === 'localhost' || str_ends_with($host, '.local')) {
                        $fail('Local destinations are not allowed.');
                        return;
                    }

                    if (filter_var($host, FILTER_VALIDATE_IP)
                        && ! filter_var(
                            $host,
                            FILTER_VALIDATE_IP,
                            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
                        )) {
                        $fail('Private or reserved IP addresses are not allowed.');
                    }
                },
            ],
        ]);

        $bookmark = Bookmark::create($data + ['preview_status' => 'pending']);

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

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

The job stores only verified PNG bytes. Quota responses use a bounded delay based on Retry-After when available; transport and server failures use the job’s longer backoff schedule. Permanent failures are recorded without throwing, preventing pointless queue attempts.

<?php
// app/Jobs/CaptureBookmarkPreview.php

namespace App\Jobs;

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 RuntimeException;
use Throwable;

final class CaptureBookmarkPreview implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;

    public function __construct(public Bookmark $bookmark) {}

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

    public function handle(ScreenshotClient $client): void
    {
        $result = $client->capture($this->bookmark->url);

        if ($result->successful) {
            $path = "bookmark-previews/{$this->bookmark->id}.png";

            if (! Storage::disk('public')->put($path, $result->png)) {
                throw new RuntimeException('Preview storage failed.');
            }

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

        Log::warning('Bookmark preview capture failed', [
            'bookmark_id' => $this->bookmark->id,
            'host' => parse_url($this->bookmark->url, PHP_URL_HOST),
            'failure' => $result->failure,
        ]);

        if ($result->failure === 'quota_limited'
            && $this->attempts() < $this->tries) {
            $this->release(max(30, min(900, $result->retryAfter ?? 60)));
            return;
        }

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

        $this->bookmark->update([
            'preview_status' => 'failed',
            'preview_error' => $result->failure,
            'preview_meta' => $result->operationalHeaders,
        ]);
    }

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

Render only controlled previews

Register the routes and render escaped bookmark data. Authorization should be added if bookmarks belong to individual users.

// routes/web.php
use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;

Route::get('/bookmarks', [BookmarkController::class, 'index'])
    ->name('bookmarks.index');
Route::post('/bookmarks', [BookmarkController::class, 'store'])
    ->middleware('throttle:20,1')
    ->name('bookmarks.store');
<!-- resources/views/bookmarks/index.blade.php -->
<form method="post" action="{{ route('bookmarks.store') }}">
    @csrf
    <input name="title" required maxlength="120">
    <input name="url" type="url" required maxlength="2048">
    <button type="submit">Save bookmark</button>
</form>

@foreach ($bookmarks as $bookmark)
    <article>
        @if ($bookmark->preview_status === 'ready')
            <img
                src="{{ Storage::disk('public')->url($bookmark->preview_path) }}"
                alt=""
                loading="lazy"
                width="640"
                height="400"
            >
        @else
            <p>Preview: {{ $bookmark->preview_status }}</p>
        @endif

        <a href="{{ $bookmark->url }}"
           rel="noopener noreferrer"
           target="_blank">{{ $bookmark->title }}</a>
    </article>
@endforeach

Test without calling the live service

Laravel’s HTTP fake makes the API boundary deterministic. These tests verify authentication, query encoding, PNG validation, header preservation, and storage:

<?php
// tests/Feature/ScreenshotIntegrationTest.php

namespace Tests\Feature;

use App\Jobs\CaptureBookmarkPreview;
use App\Models\Bookmark;
use App\Services\ScreenshotClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

final class ScreenshotIntegrationTest extends TestCase
{
    use RefreshDatabase;

    public function test_client_maps_a_png_response(): void
    {
        config(['services.screenshot.token' => 'test-token']);

        Http::fake([
            'ai.mihajlo.mk/*' => Http::response(
                "\x89PNG\r\n\x1a\nfake",
                200,
                ['Content-Type' => 'image/png', 'X-Cache' => 'HIT']
            ),
        ]);

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

        $this->assertTrue($result->successful);
        $this->assertArrayHasKey('X-Cache', $result->operationalHeaders);

        Http::assertSent(function (Request $request): bool {
            parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);

            return $request->method() === 'GET'
                && $request->hasHeader(
                    'Authorization',
                    'Bearer test-token'
                )
                && ($query['url'] ?? null)
                    === 'https://example.com/path?a=1';
        });
    }

    public function test_job_stores_a_verified_preview(): void
    {
        Storage::fake('public');
        config(['services.screenshot.token' => 'test-token']);

        Http::fake([
            '*' => Http::response(
                "\x89PNG\r\n\x1a\nfake",
                200,
                ['Content-Type' => 'image/png']
            ),
        ]);

        $bookmark = Bookmark::create([
            'title' => 'Example',
            'url' => 'https://example.com/',
            'preview_status' => 'pending',
        ]);

        app(CaptureBookmarkPreview::class, ['bookmark' => $bookmark])
            ->handle(app(ScreenshotClient::class));

        Storage::disk('public')
            ->assertExists("bookmark-previews/{$bookmark->id}.png");
        $this->assertSame('ready', $bookmark->fresh()->preview_status);
    }
}

Deployment, observability, and common failures

In production, inject the token through the platform’s secret manager, run php artisan config:cache, apply migrations, ensure the public disk is durable, and keep a queue worker under a process supervisor. After rotating the service token or deploying code, restart workers with php artisan queue:restart.

Monitor counts of ready, failed, and long-lived pending bookmarks. Logs deliberately contain the bookmark ID, host, status, and failure category, but not the token, full URL query string, or response body.

  • Authentication failures: confirm activation and the service-scoped token, then rebuild the configuration cache and restart workers.
  • Quota-limited captures: inspect the preserved quota metadata, reduce recapture frequency, and verify that the selected plan fits demand.
  • Invalid image responses: retain the rejection. Never save an unexpected content type as a browser-renderable file.
  • Persistent pending rows: check that a worker is consuming the configured queue and that failed jobs are being monitored.
  • Missing images: confirm storage:link, disk permissions, and durable shared storage when multiple application instances are used.

Final verification checklist

  1. Save a public HTTPS URL and confirm the web request returns immediately.
  2. Run php artisan queue:work --tries=4 and observe the bookmark become ready.
  3. Verify that the stored file begins with the PNG signature and is served from the public disk.
  4. Run php artisan test with no live API traffic.
  5. Test an invalid local URL, an invalid token, a simulated 429, and a simulated server failure.
  6. Confirm that application logs and stored metadata contain no credential or complete sensitive URL.

The screenshot itself is only the visible result. The production feature is the boundary around it: controlled inputs, a secret kept out of URLs, deterministic response mapping, bounded retries, safe storage, and enough operational context to diagnose failures. Once those pieces are in place, link previews stop being browser infrastructure and become an ordinary, dependable part of the bookmark domain.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.