Symfony Bookmarks: Secure Link Previews with AI Screenshot Generation
A bookmark list becomes far more useful when each saved URL has a recognizable visual preview. It also becomes more operationally complicated: capturing pages requires a browser, untrusted URLs need careful handling, and slow rendering does not belong in a web request.
This tutorial builds a Symfony bookmarks application that queues preview generation, calls a managed Screenshot API, validates the returned PNG, and serves it through a controlled application route. The result avoids maintaining Chromium workers while preserving clear security and failure boundaries.
Get access to the Screenshot API
Start by creating an account at the registration page, or use the sign-in page if you already have one.
- Open the Screenshot API service page.
- Choose an available Free, Plus, or Pro plan and complete its activation.
- Open the official Screenshot API 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 Bearer authentication because it keeps the credential out of URLs, browser history, proxy query logs, and analytics systems.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment event: update the application secret, restart workers, verify one capture, and only then consider the rotation complete.
Confirm the exact endpoint
The request is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, with the target address in the required url query parameter. A successful response has an image/png body. Preserve its cache and quota-related response headers rather than expecting an undocumented JSON envelope.
read -rsp "Service token: " SCREENSHOT_API_TOKEN
curl --silent --show-error \
--dump-header screenshot.headers \
--output screenshot.png \
--get \
--data-urlencode "url=https://example.com" \
--header "Accept: image/png" \
--header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
file screenshot.png
unset SCREENSHOT_API_TOKEN
Inspect the status and headers before trusting the file. Unlike --fail-with-body, the command above preserves an error response for diagnosis; a file named screenshot.png is not proof that its contents are a PNG.
Store the real token in Symfony’s uncommitted .env.local during local development. In production, inject the same variable through the hosting platform’s secret manager.
# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=async
Create the Symfony project
The implementation assumes PHP 8.3 or newer, Composer, a Symfony application with Doctrine, and a database supported by your Doctrine configuration. Messenger is worthwhile here because page capture is external work with variable latency; bookmark creation should remain quick even when the provider is busy.
composer create-project symfony/skeleton bookmark-previews
cd bookmark-previews
composer require symfony/framework-bundle symfony/http-client \
symfony/orm-pack symfony/messenger symfony/validator
composer require --dev symfony/test-pack doctrine/doctrine-fixtures-bundle
The application has four deliberate boundaries:
- The controller validates and stores a bookmark, then dispatches a message.
- A message handler performs capture outside the HTTP request.
- A dedicated client owns authentication, retries, PNG validation, and response mapping.
- Generated files stay under
var/and are exposed only through a Symfony response.
Keeping previews outside the public directory prevents direct access from bypassing future authorization rules. The trade-off is that Symfony must serve each image; for heavier traffic, replace that controller with signed object-storage URLs while retaining the same domain boundary.
Model bookmark and preview state
A preview is not merely present or absent. It can be pending, ready, or failed, with a structured failure code suitable for retry controls and support diagnostics.
<?php
// src/Entity/Bookmark.php
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Bookmark
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 2048)]
private string $url;
#[ORM\Column(length: 16)]
private string $previewState = 'pending';
#[ORM\Column(length: 255, nullable: true)]
private ?string $previewFile = null;
#[ORM\Column(length: 40, nullable: true)]
private ?string $previewError = null;
#[ORM\Column(type: Types::JSON)]
private array $previewHeaders = [];
public function __construct(string $url)
{
$this->url = $url;
}
public function getId(): ?int { return $this->id; }
public function getUrl(): string { return $this->url; }
public function getPreviewState(): string { return $this->previewState; }
public function getPreviewFile(): ?string { return $this->previewFile; }
public function previewReady(string $file, array $headers): void
{
$this->previewState = 'ready';
$this->previewFile = $file;
$this->previewError = null;
$this->previewHeaders = $headers;
}
public function previewFailed(string $code, array $headers = []): void
{
$this->previewState = 'failed';
$this->previewFile = null;
$this->previewError = $code;
$this->previewHeaders = $headers;
}
}
Generate and apply the migration after adding the entity:
php bin/console doctrine:database:create --if-not-exists
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
Build a defensive Screenshot API client
The boundary below uses bounded connection and total durations, disables endpoint redirects, retries only transport failures and server errors, limits the body to eight MiB, and checks both media type and PNG signature. Authentication failures, validation failures, and quota responses are never retried blindly.
<?php
// src/Screenshot/ScreenshotResult.php
namespace App\Screenshot;
final readonly class ScreenshotResult
{
public function __construct(
public bool $successful,
public ?string $body,
public ?string $failureCode,
public array $cacheHeaders = [],
public array $quotaHeaders = [],
) {}
}
// src/Screenshot/ScreenshotClient.php
namespace App\Screenshot;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class ScreenshotClient
{
public function __construct(
private HttpClientInterface $http,
private string $token,
private string $endpoint,
private int $maxBytes = 8_388_608,
) {}
public function capture(string $url): ScreenshotResult
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('GET', $this->endpoint, [
'auth_bearer' => $this->token,
'headers' => ['Accept' => 'image/png'],
'query' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 30.0,
'max_redirects' => 0,
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
$cache = $this->selectHeaders(
$headers,
'/^(cache-control|age|etag|expires)$/i'
);
$quota = $this->selectHeaders(
$headers,
'/quota|rate.?limit|remaining|reset|retry-after/i'
);
if ($status === 401 || $status === 403) {
return new ScreenshotResult(false, null, 'authentication', $cache, $quota);
}
if ($status === 429) {
return new ScreenshotResult(false, null, 'quota', $cache, $quota);
}
if ($status >= 400 && $status < 500) {
return new ScreenshotResult(false, null, 'invalid_request', $cache, $quota);
}
if ($status >= 500) {
$response->cancel();
if ($attempt < 3) {
$this->backoff($attempt);
continue;
}
return new ScreenshotResult(false, null, 'upstream', $cache, $quota);
}
if ($status < 200 || $status >= 300) {
return new ScreenshotResult(false, null, 'unexpected_status', $cache, $quota);
}
$type = strtolower($headers['content-type'][0] ?? '');
if (!str_starts_with($type, 'image/png')) {
$response->cancel();
return new ScreenshotResult(false, null, 'invalid_content_type', $cache, $quota);
}
$body = '';
foreach ($this->http->stream($response) as $chunk) {
if ($chunk->isTimeout()) {
throw new \RuntimeException('Screenshot response timed out.');
}
$body .= $chunk->getContent();
if (strlen($body) > $this->maxBytes) {
$response->cancel();
return new ScreenshotResult(false, null, 'image_too_large', $cache, $quota);
}
}
if (!str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
return new ScreenshotResult(false, null, 'invalid_png', $cache, $quota);
}
return new ScreenshotResult(true, $body, null, $cache, $quota);
} catch (TransportExceptionInterface|\RuntimeException $exception) {
if ($attempt < 3) {
$this->backoff($attempt);
continue;
}
return new ScreenshotResult(false, null, 'transport');
}
}
return new ScreenshotResult(false, null, 'transport');
}
private function backoff(int $attempt): void
{
$milliseconds = 200 * (2 ** ($attempt - 1)) + random_int(0, 100);
usleep($milliseconds * 1000);
}
private function selectHeaders(array $headers, string $pattern): array
{
return array_filter(
$headers,
static fn (string $name): bool => preg_match($pattern, $name) === 1,
ARRAY_FILTER_USE_KEY
);
}
}
The quota matcher is intentionally defensive: the service contract requires handling quota headers but does not justify hard-coding an undocumented header name. The client retains matching headers exactly as returned. A standard Retry-After value can guide a later user-initiated or scheduled retry, but a worker should not sleep indefinitely while holding a queue message.
Wire the client through environment-backed configuration:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Screenshot\ScreenshotClient:
arguments:
$token: '%env(string:SCREENSHOT_API_TOKEN)%'
$endpoint: 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
$maxBytes: 8388608
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
max_delay: 10000
failed: 'doctrine://default?queue_name=failed'
routing:
App\Message\GenerateBookmarkPreview: async
Capture previews in a Messenger handler
The API client’s short retries cover transient connection and server failures. Messenger’s retry policy is reserved for unexpected handler failures such as a temporary filesystem or database problem.
<?php
// src/Message/GenerateBookmarkPreview.php
namespace App\Message;
final readonly class GenerateBookmarkPreview
{
public function __construct(public int $bookmarkId) {}
}
// src/MessageHandler/GenerateBookmarkPreviewHandler.php
namespace App\MessageHandler;
use App\Entity\Bookmark;
use App\Message\GenerateBookmarkPreview;
use App\Screenshot\ScreenshotClient;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class GenerateBookmarkPreviewHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
private ScreenshotClient $screenshots,
private LoggerInterface $logger,
private string $projectDir,
) {}
public function __invoke(GenerateBookmarkPreview $message): void
{
$bookmark = $this->entityManager->find(Bookmark::class, $message->bookmarkId);
if (!$bookmark || $bookmark->getPreviewState() === 'ready') {
return;
}
$result = $this->screenshots->capture($bookmark->getUrl());
$headers = $result->cacheHeaders + $result->quotaHeaders;
if (!$result->successful) {
$bookmark->previewFailed($result->failureCode ?? 'unknown', $headers);
$this->entityManager->flush();
$this->logger->warning('Bookmark preview capture failed.', [
'bookmark_id' => $bookmark->getId(),
'target_host' => parse_url($bookmark->getUrl(), PHP_URL_HOST),
'failure_code' => $result->failureCode,
'response_headers' => $headers,
]);
return;
}
$directory = $this->projectDir.'/var/previews';
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
throw new \RuntimeException('Cannot create the preview directory.');
}
$name = $bookmark->getId().'.png';
$temporary = $directory.'/'.$name.'.'.bin2hex(random_bytes(6)).'.tmp';
if (file_put_contents($temporary, $result->body, LOCK_EX) === false) {
throw new \RuntimeException('Cannot write the preview file.');
}
if (!rename($temporary, $directory.'/'.$name)) {
throw new \RuntimeException('Cannot publish the preview file.');
}
$bookmark->previewReady($name, $headers);
$this->entityManager->flush();
$this->logger->info('Bookmark preview is ready.', [
'bookmark_id' => $bookmark->getId(),
'bytes' => strlen($result->body),
]);
}
}
Atomic rename prevents a web request from reading a partially written image. The logs contain a bookmark identifier and hostname, not the token or full URL. Full URLs may contain sensitive paths and query values.
Validate URLs and expose controlled routes
Accept only absolute HTTP or HTTPS URLs, reject embedded credentials, nonstandard ports, localhost names, and IP addresses that are private or reserved. Resolve hostnames and reject the URL if any returned address is non-public. DNS validation is defense in depth, not a complete answer to rebinding or redirect attacks; a strict hostname allowlist is the strongest option when the product does not need arbitrary public sites.
<?php
// src/Security/BookmarkUrlGuard.php
namespace App\Security;
final class BookmarkUrlGuard
{
public function validate(string $value): string
{
$url = trim($value);
$parts = parse_url($url);
if (!filter_var($url, FILTER_VALIDATE_URL)
|| !is_array($parts)
|| !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
|| isset($parts['user'])
|| isset($parts['pass'])
|| (isset($parts['port']) && !in_array($parts['port'], [80, 443], true))) {
throw new \InvalidArgumentException('A public HTTP or HTTPS URL is required.');
}
$host = strtolower(rtrim($parts['host'] ?? '', '.'));
if ($host === '' || $host === 'localhost') {
throw new \InvalidArgumentException('The hostname is not allowed.');
}
$addresses = filter_var($host, FILTER_VALIDATE_IP)
? [$host]
: array_values(array_filter(array_map(
static fn (array $record): ?string => $record['ip'] ?? $record['ipv6'] ?? null,
dns_get_record($host, DNS_A | DNS_AAAA) ?: []
)));
if ($addresses === []) {
throw new \InvalidArgumentException('The hostname could not be resolved.');
}
foreach ($addresses as $address) {
if (!filter_var(
$address,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)) {
throw new \InvalidArgumentException('Private or reserved targets are not allowed.');
}
}
return $url;
}
}
<?php
// src/Controller/BookmarkController.php
namespace App\Controller;
use App\Entity\Bookmark;
use App\Message\GenerateBookmarkPreview;
use App\Security\BookmarkUrlGuard;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Messenger\MessageBusInterface;
final class BookmarkController extends AbstractController
{
#[Route('/bookmarks', methods: ['POST'])]
public function create(
Request $request,
BookmarkUrlGuard $guard,
EntityManagerInterface $entityManager,
MessageBusInterface $bus,
): JsonResponse {
try {
$url = $guard->validate((string) $request->request->get('url'));
} catch (\InvalidArgumentException $exception) {
return $this->json(['error' => $exception->getMessage()], 422);
}
$bookmark = new Bookmark($url);
$entityManager->persist($bookmark);
$entityManager->flush();
$bus->dispatch(new GenerateBookmarkPreview($bookmark->getId()));
return $this->json([
'id' => $bookmark->getId(),
'preview_state' => 'pending',
], 202);
}
#[Route('/bookmarks/{id}/preview', methods: ['GET'])]
public function preview(Bookmark $bookmark, string $projectDir): BinaryFileResponse
{
if ($bookmark->getPreviewState() !== 'ready' || !$bookmark->getPreviewFile()) {
throw $this->createNotFoundException('Preview is not ready.');
}
$file = $projectDir.'/var/previews/'.$bookmark->getPreviewFile();
if (!is_file($file)) {
throw $this->createNotFoundException('Preview file is missing.');
}
$response = new BinaryFileResponse($file);
$response->headers->set('Content-Type', 'image/png');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
'bookmark-preview.png'
);
$response->setPrivate();
$response->setMaxAge(3600);
return $response;
}
}
Add CSRF protection when the creation route is called from a browser form. If bookmarks belong to users or teams, protect both routes with Symfony Security and call an authorization voter before returning the preview.
Test the external boundary deterministically
MockHttpClient exercises the real mapping logic without making network calls or placing credentials in fixtures.
<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;
use App\Screenshot\ScreenshotClient;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class ScreenshotClientTest extends TestCase
{
public function testItAcceptsAValidPngAndPreservesCacheHeaders(): void
{
$png = "\x89PNG\r\n\x1a\nfixture";
$http = new MockHttpClient(new MockResponse($png, [
'http_code' => 200,
'response_headers' => [
'content-type: image/png',
'cache-control: public, max-age=3600',
],
]));
$client = new ScreenshotClient($http, 'test-token', 'https://service.test/capture');
$result = $client->capture('https://example.com');
self::assertTrue($result->successful);
self::assertSame($png, $result->body);
self::assertArrayHasKey('cache-control', $result->cacheHeaders);
}
public function testQuotaResponseIsNotRetried(): void
{
$requests = 0;
$http = new MockHttpClient(function () use (&$requests): MockResponse {
$requests++;
return new MockResponse('', [
'http_code' => 429,
'response_headers' => ['retry-after: 60'],
]);
});
$client = new ScreenshotClient($http, 'test-token', 'https://service.test/capture');
$result = $client->capture('https://example.com');
self::assertFalse($result->successful);
self::assertSame('quota', $result->failureCode);
self::assertSame(1, $requests);
self::assertArrayHasKey('retry-after', $result->quotaHeaders);
}
public function testItRejectsAFalsePng(): void
{
$http = new MockHttpClient(new MockResponse('<html>error</html>', [
'http_code' => 200,
'response_headers' => ['content-type: image/png'],
]));
$client = new ScreenshotClient($http, 'test-token', 'https://service.test/capture');
self::assertSame(
'invalid_png',
$client->capture('https://example.com')->failureCode
);
}
}
Deploy, observe, and troubleshoot
Run migrations before releasing application code, create a worker-writable preview directory, and supervise the Messenger worker with systemd, Supervisor, or the process manager supplied by your platform.
APP_ENV=prod php bin/console doctrine:migrations:migrate --no-interaction
mkdir -p var/previews
php bin/console cache:clear --env=prod
php bin/console messenger:consume async \
--time-limit=3600 \
--memory-limit=128M \
--no-interaction
Monitor capture counts by outcome, queue age, worker failures, latency, and PNG size. Alert on sustained authentication failures because they commonly indicate an expired, revoked, or inconsistently deployed token. Track quota failures separately from upstream errors; increasing generic retries cannot repair an exhausted plan.
Common failure patterns
- Every capture returns authentication: confirm the token belongs to the Screenshot API service, check whitespace in the secret, and restart all workers after rotation.
- Bookmarks remain pending: verify that a Messenger worker is consuming
asyncand inspectmessenger:failed:show. - The body is not a PNG: retain status and safe headers in logs, but never publish or render the body as HTML.
- Quota failures repeat: inspect returned quota or
Retry-Afterheaders and the active plan. Requeue only when capacity is expected to be available. - Previews disappear after deployment:
var/may be ephemeral on the hosting platform. Use a persistent volume or private object storage. - A URL fails validation: verify its scheme, port, DNS records, and whether any resolved address is private or reserved.
Final verification checklist
- The token comes from environment-backed secret configuration and never appears in source control or logs.
- Bookmark creation returns
202without waiting for capture. - The worker calls the exact GET endpoint with the required
urlparameter and Bearer authentication. - Only validated PNG responses below the size limit reach persistent storage.
- Cache and quota-related headers cross the API boundary into structured application state.
- Authentication, validation, quota, transport, and image failures remain distinguishable.
- Private and reserved target addresses are rejected, with stricter allowlisting applied where practical.
- Preview responses declare
image/png, disable content sniffing, and pass through application authorization. - Automated tests run without network access, and a supervised Messenger worker runs in production.
A screenshot may look like a decorative enhancement, but the production feature is really a chain of trust decisions. The bookmark accepts a constrained URL, the queue isolates latency, the client distrusts every response, and the delivery route exposes only a verified image. When each boundary has one clear responsibility, visual previews stay useful without turning a small bookmarks application into a browser-infrastructure project.