Tutorials

Symfony Bookmarks: Secure Link Previews with Production-Ready Screenshot API Integration

Symfony Bookmarks: Secure Link Previews with Production-Ready Screenshot API Integration

A bookmark list becomes much more useful when links are recognizable at a glance. Unfortunately, generating thumbnails by running a browser beside your PHP application creates an awkward operational burden: Chromium packages, sandbox permissions, memory spikes, navigation timeouts, and another process to patch.

This tutorial builds the practical alternative: a Symfony application that requests cached PNG captures from a Screenshot API, stores them locally for repeat views, and serves them through a same-origin preview route. The integration treats screenshots as untrusted binary input, validates bookmark URLs, respects upstream cache guidance, records quota metadata, and distinguishes failures that should be retried from those that should not.

Get access before writing integration code

  1. Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
  2. Open the Screenshot API service page. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Visit the official Screenshot API documentation.
  4. Find the Service token panel and copy its service-scoped token. This service requires authentication: it is not a token-free API.
  5. Store the token in environment-backed configuration. If you regenerate it, the previously active token is revoked, so deployment environments must be updated promptly.

The exact request is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. It requires the url query parameter and accepts a Bearer token, an X-API-Token header, or a token query parameter. Prefer a header: query-string credentials are more likely to appear in access logs and diagnostics.

Before touching Symfony, verify the account and token with one minimal request:

curl --fail-with-body \
  --get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header 'X-API-Token: YOUR_SERVICE_TOKEN' \
  --data-urlencode 'url=https://example.com/' \
  --output preview.png

A successful response body is image/png. Inspect the response headers during manual diagnostics with --dump-header response-headers.txt; the integration below retains cache and quota metadata without assuming undocumented vendor-specific header names.

Prepare the Symfony project

You need PHP 8.3 or newer, Composer, and a Symfony application with a Bookmark entity containing an integer ID and URL. A small application can use SQLite locally and the database already chosen for production.

composer create-project symfony/skeleton bookmark-preview
cd bookmark-preview
composer require symfony/http-client symfony/cache symfony/orm-pack \
  symfony/twig-bundle symfony/monolog-bundle
composer require --dev symfony/test-pack symfony/maker-bundle

php bin/console make:entity Bookmark
# Add: url, string, length 2048
php bin/console make:migration
php bin/console doctrine:migrations:migrate

The resulting feature has four deliberate boundaries:

  • The bookmark entity owns the submitted URL.
  • A URL policy rejects unsupported or clearly dangerous targets.
  • A dedicated client owns authentication, retries, response validation, and upstream metadata.
  • A preview store caches the domain result, while a controller serves only validated PNG bytes.

This design stays synchronous because a cached screenshot endpoint is naturally requested by an image element and the service removes browser infrastructure from the application. If capture latency must never affect an HTTP request, the same client can later sit behind Symfony Messenger; do not introduce a queue until that operational trade-off is worthwhile.

Configure secrets and the cache pool

Put the development credential in .env.local, which should not be committed. Keep only a harmless placeholder in shared examples.

# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
# config/services.yaml
parameters:
    screenshot_api.endpoint: 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'

services:
    App\Screenshot\ScreenshotClient:
        arguments:
            $token: '%env(string:SCREENSHOT_API_TOKEN)%'
            $endpoint: '%screenshot_api.endpoint%'

    App\Screenshot\PreviewStore:
        arguments:
            $cache: '@cache.preview'
# config/packages/cache.yaml
framework:
    cache:
        pools:
            cache.preview:
                adapter: cache.adapter.filesystem

Reject unsuitable bookmark targets

The remote service performs the navigation, but your application still needs an abuse boundary. An arbitrary capture form can become a probing tool or generate unwanted quota costs. For a general bookmark application, require HTTPS, prohibit credentials and nonstandard ports, reject local names, and reject private or reserved literal IP addresses.

<?php
// src/Screenshot/BookmarkUrlPolicy.php
namespace App\Screenshot;

final class BookmarkUrlPolicy
{
    public function assertAllowed(string $url): void
    {
        if (strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
            throw new \InvalidArgumentException('The bookmark URL is invalid.');
        }

        $parts = parse_url($url);
        $scheme = strtolower($parts['scheme'] ?? '');
        $host = strtolower(rtrim($parts['host'] ?? '', '.'));

        if ($scheme !== 'https' || $host === '') {
            throw new \InvalidArgumentException('Only absolute HTTPS URLs are allowed.');
        }

        if (isset($parts['user']) || isset($parts['pass']) ||
            (isset($parts['port']) && $parts['port'] !== 443)) {
            throw new \InvalidArgumentException('Credentials and nonstandard ports are forbidden.');
        }

        if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
            throw new \InvalidArgumentException('Local targets are forbidden.');
        }

        if (filter_var($host, FILTER_VALIDATE_IP) !== false &&
            filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            ) === false) {
            throw new \InvalidArgumentException('Private or reserved targets are forbidden.');
        }
    }
}

This is a useful application-level filter, not a complete defense against DNS rebinding. The screenshot provider must enforce its own network isolation. For private team bookmark collections, an explicit hostname allowlist is stronger than accepting the entire public web.

Build a defensive Screenshot API client

Map the HTTP response into a small domain object instead of leaking Symfony response objects through the application. That keeps controllers simple and makes failure behavior testable.

<?php
// src/Screenshot/CapturedScreenshot.php
namespace App\Screenshot;

final readonly class CapturedScreenshot
{
    public function __construct(
        public string $png,
        public array $cacheHeaders,
        public array $quotaHeaders,
    ) {}
}

// src/Screenshot/ScreenshotFailure.php
namespace App\Screenshot;

final class ScreenshotFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        public readonly ?int $status = null,
        public readonly array $metadata = [],
    ) {
        parent::__construct('Screenshot capture failed: '.$kind);
    }
}

The client retries only transport failures and server errors. Validation, authentication, and quota failures need human action or time; blindly replaying them wastes capacity. Backoff is short, exponential, jittered, and bounded to two retries.

<?php
// src/Screenshot/ScreenshotClient.php
namespace App\Screenshot;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class ScreenshotClient
{
    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $token,
        private string $endpoint,
    ) {}

    public function capture(string $url): CapturedScreenshot
    {
        $started = microtime(true);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('GET', $this->endpoint, [
                    'headers' => ['X-API-Token' => $this->token],
                    'query' => ['url' => $url],
                    'timeout' => 3.0,
                    'max_duration' => 12.0,
                ]);

                $status = $response->getStatusCode();
                $headers = $response->getHeaders(false);
                [$cache, $quota] = $this->metadata($headers);

                if ($status === 401 || $status === 403) {
                    throw new ScreenshotFailure('authentication', false, $status, $quota);
                }
                if ($status === 400 || $status === 422) {
                    throw new ScreenshotFailure('request_rejected', false, $status, $quota);
                }
                if ($status === 429) {
                    throw new ScreenshotFailure('quota_or_rate_limit', false, $status, $quota);
                }
                if ($status >= 500 && $attempt < 3) {
                    $this->backoff($attempt);
                    continue;
                }
                if ($status !== 200) {
                    throw new ScreenshotFailure('upstream_http', $status >= 500, $status, $quota);
                }

                $contentType = strtolower($headers['content-type'][0] ?? '');
                $png = $response->getContent(false);

                if (!str_starts_with($contentType, 'image/png') ||
                    !str_starts_with($png, "\x89PNG\r\n\x1a\n") ||
                    strlen($png) > 10_000_000) {
                    throw new ScreenshotFailure('invalid_image', false, $status, $quota);
                }

                $this->logger->info('Screenshot capture completed', [
                    'attempt' => $attempt,
                    'duration_ms' => (int) ((microtime(true) - $started) * 1000),
                    'target_hash' => hash('sha256', $url),
                    'quota' => $quota,
                ]);

                return new CapturedScreenshot($png, $cache, $quota);
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new ScreenshotFailure('transport', true, metadata: [
                        'exception' => $e::class,
                    ]);
                }
                $this->backoff($attempt);
            }
        }

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

    private function metadata(array $headers): array
    {
        $cache = [];
        $quota = [];

        foreach ($headers as $name => $values) {
            $lower = strtolower($name);

            if (in_array($lower, ['cache-control', 'age', 'etag', 'expires'], true)) {
                $cache[$lower] = $values;
            }
            if (str_contains($lower, 'quota') ||
                str_contains($lower, 'ratelimit') ||
                str_contains($lower, 'rate-limit')) {
                $quota[$lower] = $values;
            }
        }

        return [$cache, $quota];
    }

    private function backoff(int $attempt): void
    {
        $milliseconds = 100 * (2 ** ($attempt - 1)) + random_int(0, 50);
        usleep($milliseconds * 1000);
    }
}

Header names used for quotas can evolve or differ by plan. Capturing returned quota-related headers by normalized name avoids fabricating a contract. Keep these values in structured telemetry; do not expose account capacity to public clients.

Cache captures and serve a same-origin image

Symfony Cache provides callback locking, which reduces a cold-cache stampede when several page loads request the same bookmark. The store uses upstream Cache-Control: max-age when present, clamps it to a local range, and avoids durable storage when no-store appears.

<?php
// src/Screenshot/PreviewStore.php
namespace App\Screenshot;

use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final readonly class PreviewStore
{
    public function __construct(
        private CacheInterface $cache,
        private ScreenshotClient $client,
        private BookmarkUrlPolicy $policy,
    ) {}

    public function get(string $url): CapturedScreenshot
    {
        $this->policy->assertAllowed($url);

        return $this->cache->get('bookmark_preview.'.hash('sha256', $url),
            function (ItemInterface $item) use ($url): CapturedScreenshot {
                $capture = $this->client->capture($url);
                $control = implode(',', $capture->cacheHeaders['cache-control'] ?? []);

                $ttl = 600;
                if (preg_match('/(?:^|,)\s*max-age=(\d+)/i', $control, $match)) {
                    $ttl = max(60, min(3600, (int) $match[1]));
                }

                $item->expiresAfter(str_contains(strtolower($control), 'no-store') ? 0 : $ttl);
                return $capture;
            }
        );
    }
}
<?php
// src/Controller/BookmarkPreviewController.php
namespace App\Controller;

use App\Repository\BookmarkRepository;
use App\Screenshot\PreviewStore;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final readonly class BookmarkPreviewController
{
    public function __construct(
        private BookmarkRepository $bookmarks,
        private PreviewStore $previews,
    ) {}

    #[Route('/bookmarks/{id<\d+>}/preview', name: 'bookmark_preview', methods: ['GET'])]
    public function __invoke(int $id, Request $request): Response
    {
        $bookmark = $this->bookmarks->find($id);
        if ($bookmark === null) {
            return new Response('', Response::HTTP_NOT_FOUND);
        }

        try {
            $capture = $this->previews->get($bookmark->getUrl());
        } catch (\InvalidArgumentException) {
            return new Response('', Response::HTTP_UNPROCESSABLE_ENTITY);
        } catch (\Throwable) {
            return new Response('', Response::HTTP_BAD_GATEWAY);
        }

        $response = new Response($capture->png, Response::HTTP_OK, [
            'Content-Type' => 'image/png',
            'X-Content-Type-Options' => 'nosniff',
            'Cache-Control' => 'private, max-age=300',
        ]);
        $response->setEtag(hash('sha256', $capture->png));

        return $response->isNotModified($request) ? $response : $response;
    }
}

A Twig page can now render <img src="{{ path('bookmark_preview', {id: bookmark.id}) }}" alt="" loading="lazy">. Authorization must match the bookmark page: if bookmarks are private, apply the same voter or access-control rule to the preview route.

Test the boundary without using quota

MockHttpClient makes the transport deterministic. Test successful binary mapping and prove that authentication failures are not retried.

<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;

use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotFailure;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ScreenshotClientTest extends TestCase
{
    public function testMapsPngAndCacheHeaders(): void
    {
        $http = new MockHttpClient(function (string $method, string $url): MockResponse {
            self::assertSame('GET', $method);
            parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
            self::assertSame('https://example.com/', $query['url']);

            return new MockResponse("\x89PNG\r\n\x1a\npayload", [
                'http_code' => 200,
                'response_headers' => [
                    'content-type: image/png',
                    'cache-control: max-age=120',
                ],
            ]);
        });

        $client = new ScreenshotClient($http, new NullLogger(), 'test-token',
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture');

        $result = $client->capture('https://example.com/');
        self::assertStringStartsWith("\x89PNG", $result->png);
        self::assertSame(['max-age=120'], $result->cacheHeaders['cache-control']);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $calls = 0;
        $http = new MockHttpClient(function () use (&$calls): MockResponse {
            $calls++;
            return new MockResponse('', ['http_code' => 401]);
        });

        $client = new ScreenshotClient($http, new NullLogger(), 'bad-token',
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture');

        try {
            $client->capture('https://example.com/');
            self::fail('Expected ScreenshotFailure');
        } catch (ScreenshotFailure $failure) {
            self::assertSame('authentication', $failure->kind);
            self::assertSame(1, $calls);
        }
    }
}

Production failures, observability, and deployment

Track capture count, latency, cache hit ratio, failure kind, upstream status, and observed quota metadata. Hash target URLs in logs because URLs can contain private paths or user-controlled query data. Never log the token, request headers, or PNG body.

  • 401 or 403: verify activation and secret injection. A regenerated token invalidates the former one.
  • 400 or 422: inspect URL validation and the documented request contract. Do not retry unchanged input.
  • 429: preserve quota headers in telemetry, serve an existing stale preview if your cache policy permits it, and wait rather than retrying immediately.
  • 5xx or transport failure: bounded retries are appropriate. Persistent failures should return a neutral placeholder in the bookmark UI.
  • Unexpected content: reject it. A 200 response alone is insufficient; the content type, PNG signature, and size limit all matter.

In production, inject SCREENSHOT_API_TOKEN through the hosting platform’s secret manager, warm Symfony’s production container, run migrations, and ensure the cache directory is durable and writable. Multiple application instances should share a cache adapter if avoiding duplicate captures matters. Rotate the token by updating every instance immediately after regeneration, then restart or redeploy processes that retain environment configuration.

Final verification checklist

  • The service plan is active and the current service-scoped token is present only in environment-backed configuration.
  • The client calls the exact GET endpoint with the required url query parameter and X-API-Token header.
  • Only acceptable HTTPS bookmark URLs reach the API.
  • The application accepts only bounded image/png responses with a valid PNG signature.
  • Cache and quota headers are retained as structured metadata without exposing them publicly.
  • Authentication, validation, and quota failures are not blindly retried.
  • Tests pass without contacting the external service, and the preview route shares the bookmark’s authorization policy.

The most valuable part of this integration is not the HTTP request. It is the boundary around it. Once credentials, URL policy, retries, binary validation, caching, authorization, and telemetry are explicit, visual bookmarks stop being a fragile browser-automation experiment and become an ordinary, supportable Symfony feature.

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.