Laravel: Safely Preview Bookmarks with AI-Powered Screenshot Generation
A bookmark is more useful when you can recognize it at a glance. Titles help, but a visual preview often conveys the page’s identity much faster. The difficult part is generating that preview reliably without turning your Laravel server into a browser automation host.
This tutorial builds a small Laravel bookmarks API that accepts public web URLs, generates PNG previews in a background queue, stores them locally, and exposes their status through a polling endpoint. The screenshot service supplies the browser infrastructure; Laravel remains responsible for validation, persistence, failure handling, and safe delivery.
Get access to the Screenshot API
Register through the account registration page, or use the sign-in page if you already have an account.
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 the service-scoped token shown there.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer scheme so the credential never appears in a URL. Regenerating the service token revokes the previously active token, so token rotation must update every deployed application that uses it.
The exact request is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, with the target address in the required url query parameter. Before writing Laravel code, make a minimal request with a placeholder token:
curl --get \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Accept: image/png" \
--data-urlencode "url=https://example.com" \
--dump-header preview.headers \
--output preview.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
A successful response has an image/png body. Inspect preview.headers as well: cache and quota response headers are operational data, not decoration. They help distinguish a cache hit from fresh work and an exhausted allowance from a broken integration.
Now place the credential in the project’s environment file. Never commit the real value:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_API_ENDPOINT=https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Architecture and project shape
The application creates a bookmark immediately with a pending preview status. A queued job calls the Screenshot API, validates the returned PNG, stores it on Laravel’s public disk, and changes the status to ready. The browser can poll the bookmark resource instead of waiting through an external HTTP request.
This background boundary matters. Screenshot generation has network latency, may encounter quota limits, and should not hold open the request that creates a bookmark. The resulting structure is intentionally small:
SafePublicUrlrejects unsuitable destinations.ScreenshotClientowns the external HTTP contract.GenerateBookmarkPreviewcoordinates persistence and failure states.BookmarkControllerhandles creation, polling, and PNG delivery.
Start with a Laravel application running PHP 8.3 or newer, a configured database, and a real queue driver for production. Generate the main components and create the queue tables if your application uses Laravel’s database queue:
php artisan make:model Bookmark -m
php artisan make:controller BookmarkController
php artisan make:job GenerateBookmarkPreview
php artisan make:rule SafePublicUrl
php artisan make:queue-table
php artisan migrate
Configure the API boundary
Add the service to config/services.php. Reading env() only from configuration keeps the application compatible with Laravel’s configuration cache.
'screenshot' => [
'token' => env('SCREENSHOT_API_TOKEN'),
'endpoint' => env(
'SCREENSHOT_API_ENDPOINT',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
),
],
Create app/Services/Screenshots/CaptureResult.php and ScreenshotException.php:
<?php
namespace App\Services\Screenshots;
final readonly class CaptureResult
{
public function __construct(
public string $png,
public array $cacheHeaders,
public array $quotaHeaders,
) {}
}
final class ScreenshotException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly array $context = [],
) {
parent::__construct($message);
}
}
The response contract is binary, so the boundary should not attempt JSON decoding. It must verify both the media type and PNG signature. The service contract promises cache and quota headers, but application code should not depend on undocumented spelling. This mapper preserves headers whose names identify cache, quota, or rate-limit semantics.
Create app/Services/Screenshots/ScreenshotClient.php:
<?php
namespace App\Services\Screenshots;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
final class ScreenshotClient
{
public function capture(string $url): CaptureResult
{
$token = config('services.screenshot.token');
$endpoint = config('services.screenshot.endpoint');
if (! is_string($token) || $token === '') {
throw new ScreenshotException(
'configuration',
'Screenshot API token is not configured.'
);
}
$response = null;
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::withToken($token)
->accept('image/png')
->connectTimeout(3)
->timeout(20)
->get($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new ScreenshotException(
'unavailable',
'Screenshot service could not be reached.'
);
}
usleep(200_000 * $attempt);
continue;
}
if (in_array($response->status(), [500, 502, 503, 504], true)
&& $attempt < 3) {
usleep(200_000 * $attempt);
continue;
}
break;
}
$context = $this->operationalHeaders($response);
if (in_array($response->status(), [401, 403], true)) {
throw new ScreenshotException(
'authentication',
'Screenshot service rejected its token.',
$context
);
}
if (in_array($response->status(), [400, 422], true)) {
throw new ScreenshotException(
'invalid_request',
'Screenshot service rejected the target URL.',
$context
);
}
if ($response->status() === 429) {
throw new ScreenshotException(
'quota_limited',
'Screenshot service quota or rate limit was reached.',
$context
);
}
if (! $response->successful()) {
throw new ScreenshotException(
'unavailable',
'Screenshot service returned HTTP '.$response->status().'.',
$context
);
}
$body = $response->body();
$type = strtolower($response->header('Content-Type', ''));
if (! str_starts_with($type, 'image/png')
|| ! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotException(
'invalid_response',
'Screenshot service did not return a valid PNG.',
$context
);
}
return new CaptureResult(
$body,
$context['cache_headers'],
$context['quota_headers']
);
}
private function operationalHeaders(Response $response): array
{
$cache = [];
$quota = [];
foreach ($response->headers() as $name => $values) {
$normalized = strtolower($name);
$value = implode(', ', $values);
if (str_contains($normalized, 'cache')) {
$cache[$name] = $value;
}
if (str_contains($normalized, 'quota')
|| str_contains($normalized, 'ratelimit')
|| str_contains($normalized, 'rate-limit')) {
$quota[$name] = $value;
}
}
return ['cache_headers' => $cache, 'quota_headers' => $quota];
}
}
Only connection failures and selected server failures receive bounded retries. Authentication, validation, and quota responses are returned immediately because retrying them cannot repair the request and can consume more allowance.
Accept only suitable bookmark URLs
Although Laravel does not fetch the page itself, accepting local addresses, embedded credentials, or private IPs is unnecessary risk. The following rule permits only public HTTP and HTTPS destinations. DNS can change after validation, so this remains one layer rather than an absolute SSRF guarantee.
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
final class SafePublicUrl implements ValidationRule
{
public function validate(
string $attribute,
mixed $value,
Closure $fail
): void {
if (! is_string($value) || ! filter_var($value, FILTER_VALIDATE_URL)) {
$fail('The URL must be valid.');
return;
}
$parts = parse_url($value);
$scheme = strtolower($parts['scheme'] ?? '');
$host = trim($parts['host'] ?? '', '[]');
if (! in_array($scheme, ['http', 'https'], true)
|| $host === ''
|| isset($parts['user'])
|| isset($parts['pass'])) {
$fail('The URL must be a public HTTP or HTTPS address.');
return;
}
$addresses = filter_var($host, FILTER_VALIDATE_IP)
? [$host]
: array_values(array_filter(array_map(
fn (array $record) => $record['ip'] ?? $record['ipv6'] ?? null,
dns_get_record($host, DNS_A | DNS_AAAA) ?: []
)));
if ($addresses === []) {
$fail('The URL host could not be resolved.');
return;
}
foreach ($addresses as $address) {
if (! filter_var(
$address,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)) {
$fail('Private and reserved destinations are not allowed.');
return;
}
}
}
}
Persist bookmarks and generate previews
In the bookmark migration, add the domain state explicitly:
$table->id();
$table->string('title', 160);
$table->text('url');
$table->string('preview_status', 32)->default('pending');
$table->string('preview_path')->nullable();
$table->string('preview_failure', 64)->nullable();
$table->timestamps();
Make those fields fillable on App\Models\Bookmark. Then create the job:
<?php
namespace App\Jobs;
use App\Models\Bookmark;
use App\Services\Screenshots\ScreenshotClient;
use App\Services\Screenshots\ScreenshotException;
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 = 1;
public int $timeout = 70;
public function __construct(public readonly int $bookmarkId) {}
public function handle(ScreenshotClient $client): void
{
$bookmark = Bookmark::find($this->bookmarkId);
if (! $bookmark) {
return;
}
try {
$capture = $client->capture($bookmark->url);
$path = "bookmark-previews/{$bookmark->id}.png";
if (! Storage::disk('public')->put($path, $capture->png)) {
throw new ScreenshotException(
'storage',
'Preview could not be stored.'
);
}
$bookmark->update([
'preview_status' => 'ready',
'preview_path' => $path,
'preview_failure' => null,
]);
Log::info('Bookmark preview generated.', [
'bookmark_id' => $bookmark->id,
'cache_headers' => $capture->cacheHeaders,
'quota_headers' => $capture->quotaHeaders,
]);
} catch (ScreenshotException $exception) {
$bookmark->update([
'preview_status' => $exception->kind === 'quota_limited'
? 'quota_limited'
: 'failed',
'preview_failure' => $exception->kind,
]);
Log::warning('Bookmark preview generation failed.', [
'bookmark_id' => $bookmark->id,
'failure' => $exception->kind,
'service_headers' => $exception->context,
]);
}
}
public function failed(?Throwable $exception): void
{
Bookmark::whereKey($this->bookmarkId)->update([
'preview_status' => 'failed',
'preview_failure' => 'job_failed',
]);
}
}
The logs deliberately exclude the token, response body, and complete destination URL. Bookmark identifiers and operational headers are usually enough to investigate failures without leaking credentials or browsing data.
Expose creation, polling, and image delivery
The controller returns 202 Accepted on creation. Clients poll the show route until preview_status becomes ready, failed, or quota_limited.
<?php
namespace App\Http\Controllers;
use App\Jobs\GenerateBookmarkPreview;
use App\Models\Bookmark;
use App\Rules\SafePublicUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
final class BookmarkController extends Controller
{
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'title' => ['required', 'string', 'max:160'],
'url' => ['required', new SafePublicUrl()],
]);
$bookmark = Bookmark::create($data);
GenerateBookmarkPreview::dispatch($bookmark->id);
return response()->json($this->payload($bookmark), 202);
}
public function show(Bookmark $bookmark): JsonResponse
{
return response()->json($this->payload($bookmark));
}
public function preview(Bookmark $bookmark)
{
abort_unless(
$bookmark->preview_status === 'ready'
&& $bookmark->preview_path
&& Storage::disk('public')->exists($bookmark->preview_path),
404
);
return Storage::disk('public')->response(
$bookmark->preview_path,
null,
[
'Content-Type' => 'image/png',
'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'public, max-age=3600',
]
);
}
private function payload(Bookmark $bookmark): array
{
return [
'id' => $bookmark->id,
'title' => $bookmark->title,
'url' => $bookmark->url,
'preview_status' => $bookmark->preview_status,
'preview_failure' => $bookmark->preview_failure,
'preview_url' => $bookmark->preview_status === 'ready'
? route('bookmarks.preview', $bookmark)
: null,
];
}
}
Register the routes in routes/web.php, adding normal authentication and authorization middleware if bookmarks belong to individual users:
use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;
Route::post('/bookmarks', [BookmarkController::class, 'store']);
Route::get('/bookmarks/{bookmark}', [BookmarkController::class, 'show']);
Route::get('/bookmarks/{bookmark}/preview', [BookmarkController::class, 'preview'])
->name('bookmarks.preview');
Test without calling the real service
Http::fake() makes binary responses deterministic and prevents tests from spending quota. The sentinel headers below exist only in the fake to verify boundary mapping; they do not assert particular production header names.
<?php
namespace Tests\Feature;
use App\Jobs\GenerateBookmarkPreview;
use App\Models\Bookmark;
use App\Services\Screenshots\ScreenshotClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class GenerateBookmarkPreviewTest extends TestCase
{
use RefreshDatabase;
public function test_it_stores_a_valid_png_preview(): void
{
config(['services.screenshot.token' => 'test-token']);
Storage::fake('public');
$png = "\x89PNG\r\n\x1a\nfake-test-payload";
Http::fake([
'*' => Http::response($png, 200, [
'Content-Type' => 'image/png',
'X-Test-Cache' => 'hit',
'X-Test-Quota' => '9',
]),
]);
$bookmark = Bookmark::create([
'title' => 'Example',
'url' => 'https://example.com',
]);
(new GenerateBookmarkPreview($bookmark->id))
->handle(app(ScreenshotClient::class));
$bookmark->refresh();
$this->assertSame('ready', $bookmark->preview_status);
Storage::disk('public')->assertExists(
"bookmark-previews/{$bookmark->id}.png"
);
Http::assertSentCount(1);
Http::assertSent(fn ($request) =>
$request->hasHeader('Authorization', 'Bearer test-token')
&& str_contains($request->url(), 'url=https%3A%2F%2Fexample.com')
);
}
public function test_authentication_failure_is_not_retried(): void
{
config(['services.screenshot.token' => 'expired-token']);
Storage::fake('public');
Http::fake(['*' => Http::response('', 401)]);
$bookmark = Bookmark::create([
'title' => 'Example',
'url' => 'https://example.com',
]);
(new GenerateBookmarkPreview($bookmark->id))
->handle(app(ScreenshotClient::class));
$this->assertSame(
'authentication',
$bookmark->refresh()->preview_failure
);
Http::assertSentCount(1);
}
}
Deploy and operate it
Run migrations, cache configuration after installing the environment token, and start a supervised queue worker. The worker timeout must exceed the job’s 70-second limit:
php artisan migrate --force
php artisan config:cache
php artisan queue:work --tries=1 --timeout=80 --max-time=3600
Ensure the process manager restarts workers after deployments and that the configured public disk is writable. Because the controller streams the file, storage:link is not required for this implementation. At higher volume, an object-storage-backed Laravel disk is a natural replacement without changing the API client.
A bookmark stuck at pending usually indicates that no worker is consuming the queue. An authentication failure usually means the token is missing, stale, or was revoked by regeneration. An invalid_request failure points to a target rejected at the service boundary. A quota_limited state should remain visible rather than being hammered with automatic retries; retry it later according to the plan and the returned quota information. An invalid_response failure means a nominally successful response was not actually a PNG.
Final verification checklist
- The real token exists only in environment-backed configuration.
- A public HTTPS bookmark returns
202and enterspending. - The queue worker produces a valid PNG and changes the status to
ready. - The preview route returns
image/pngwithnosniff. - Private, reserved, credential-bearing, and non-HTTP URLs are rejected.
- Authentication, validation, quota, malformed-image, network, and storage failures become structured states.
- Logs contain bookmark IDs and useful operational headers, but no token or image body.
- Automated tests make no external requests and consume no service quota.
The important result is not merely a screenshot on a bookmark card. It is a clean operational boundary: browser work stays outside your application, binary data is treated defensively, slow work runs asynchronously, and every predictable failure has a state developers and users can understand. That is what turns an attractive preview into a feature you can safely keep in production.