Tutorials

Symfony: Safely Visualize Bookmarks with Production-Ready Screenshot API Integrations

Symfony: Safely Visualize Bookmarks with Production-Ready Screenshot API Integrations

A bookmark list becomes dramatically easier to scan when each link has a visual preview. The implementation sounds simple until production concerns arrive: slow captures, malicious URLs, expired credentials, oversized responses, quota exhaustion, and an upstream outage that should not take down the bookmarks page.

This tutorial builds a small Symfony application that serves PNG previews through a controlled, application-owned route. The Screenshot API performs the browser work and caches desktop or mobile captures, so the application does not need to operate Chromium, manage browser processes, or expose its service token to visitors.

Get access and create a service token

Start by registering at https://ai.mihajlo.mk/register. If you already have an account, sign in at https://ai.mihajlo.mk/login.

Open the Screenshot API service page, choose an available Free, Plus, or Pro plan, and complete its activation. Then visit the official Screenshot API documentation. In the Service token panel, copy the 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 the Bearer form because it keeps the credential out of URLs, access logs, browser history, and intermediary caches.

Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: install the replacement in every running environment, restart or redeploy those instances, verify captures, and only then remove obsolete configuration.

Confirm the exact API contract

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

Make one minimal request before writing application code:

export SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
  --data-urlencode 'url=https://example.com/' \
  --dump-header response-headers.txt \
  --output preview.png

file preview.png

Inspect response-headers.txt rather than assuming particular cache or quota header names. The application below preserves recognized standard cache headers and records quota-related headers defensively at the API boundary.

Store the token locally in .env.local, which should remain outside version control:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN

In production, inject the same variable through the hosting platform’s secret manager. Do not place the real value in .env, container images, fixtures, exception messages, or deployment manifests committed to Git.

Create the Symfony project

You need PHP 8.3 or newer, Composer, and the usual PHP extensions required by Symfony. The project uses Symfony’s first-party HTTP client, Twig, Monolog, and test tooling:

composer create-project symfony/skeleton bookmark-previews
cd bookmark-previews

composer require \
  symfony/framework-bundle \
  symfony/http-client \
  symfony/twig-bundle \
  symfony/monolog-bundle

composer require --dev symfony/test-pack

The relevant project structure is deliberately small:

bookmark-previews/
├── config/services.yaml
├── src/Bookmark/BookmarkCatalog.php
├── src/Controller/BookmarkController.php
├── src/Screenshot/PreviewCapture.php
├── src/Screenshot/ScreenshotException.php
├── src/Screenshot/ScreenshotClient.php
├── templates/bookmarks/index.html.twig
└── tests/Screenshot/ScreenshotClientTest.php

The browser requests /bookmarks/{id}/preview, never the external API directly. The controller resolves the identifier to a stored, validated URL and calls a dedicated client. This prevents visitors from supplying arbitrary capture targets and keeps the token server-side.

Messenger would be worthwhile if captures were generated eagerly for large collections. It is unnecessary here: previews load independently, and the upstream service already provides cached captures. Keeping the request path synchronous avoids queue infrastructure while still isolating a failed image from the page itself.

Configure dependency injection

Add the endpoint and environment-backed token to config/services.yaml:

parameters:
    screenshot_api.endpoint: 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

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

Centralizing the endpoint makes the API boundary explicit and allows tests to inject a harmless base URL. The token remains an environment concern rather than application data.

Map responses into domain objects

Do not let framework response objects leak into the rest of the application. A successful capture has three useful parts: PNG bytes, safe browser-cache headers, and operational metadata. Failures use a stable application-level category rather than exposing an upstream response body.

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

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

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

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

The exception deliberately excludes the token, response body, and target URL. Error pages and centralized logging systems routinely retain exception text, so secrecy must be designed into the type.

Build a bounded, defensive API client

The client uses connection and overall response limits, validates status and content type, caps the buffered image size at eight MiB, and retries only transient gateway failures or transport errors. Authentication, validation, and quota failures are not blindly retried.

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

use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ScreenshotClient
{
    private \Closure $sleep;

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly string $endpoint,
        private readonly string $apiToken,
        ?\Closure $sleep = null,
    ) {
        $this->sleep = $sleep ?? static fn (int $microseconds)
            => usleep($microseconds);
    }

    public function capture(string $url): PreviewCapture
    {
        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = $this->http->request('GET', $this->endpoint, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->apiToken,
                        'Accept' => 'image/png',
                    ],
                    'query' => ['url' => $url],
                    'timeout' => 5.0,
                    'max_duration' => 12.0,
                ]);

                $status = $response->getStatusCode();
                $headers = $response->getHeaders(false);
            } catch (TransportExceptionInterface $error) {
                if ($attempt === 1) {
                    ($this->sleep)(250_000);
                    continue;
                }

                throw new ScreenshotException('transport');
            }

            $quota = $this->quotaHeaders($headers);

            if (in_array($status, [502, 503, 504], true) && $attempt === 1) {
                $response->cancel();
                ($this->sleep)(250_000);
                continue;
            }

            if ($status === 401 || $status === 403) {
                throw new ScreenshotException('authentication', $status);
            }

            if ($status === 400 || $status === 422) {
                throw new ScreenshotException('rejected_url', $status);
            }

            if ($status === 429) {
                throw new ScreenshotException('quota_or_rate_limit', $status, $quota);
            }

            if ($status < 200 || $status >= 300) {
                throw new ScreenshotException('upstream', $status, $quota);
            }

            $contentType = strtolower($headers['content-type'][0] ?? '');
            if (!str_starts_with($contentType, 'image/png')) {
                throw new ScreenshotException('unexpected_content_type', $status);
            }

            $png = $response->getContent(false);
            if (strlen($png) > 8 * 1024 * 1024) {
                throw new ScreenshotException('image_too_large', $status);
            }

            return new PreviewCapture(
                $png,
                $this->cacheHeaders($headers),
                $quota,
            );
        }

        throw new ScreenshotException('transport');
    }

    private function cacheHeaders(array $headers): array
    {
        $allowed = ['cache-control', 'etag', 'expires', 'last-modified', 'age'];
        $result = [];

        foreach ($allowed as $name) {
            if (isset($headers[$name][0])) {
                $result[$name] = $headers[$name][0];
            }
        }

        return $result;
    }

    private function quotaHeaders(array $headers): array
    {
        $result = [];

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

            if (
                str_contains($normalized, 'quota')
                || str_contains($normalized, 'rate')
                || $normalized === 'retry-after'
            ) {
                $result[$normalized] = implode(', ', $values);
            }
        }

        return $result;
    }
}

Only explicitly approved cache headers reach the user. Hop-by-hop headers, cookies, and unknown upstream metadata are discarded. Quota headers remain operational metadata, while HTTP 429 becomes a structured quota_or_rate_limit failure. A longer retry loop would amplify outages and consume more quota without improving the user experience.

Expose only stored bookmarks

A database-backed application should validate URLs when bookmarks are created. This compact catalog demonstrates the same trust boundary without distracting persistence code:

<?php
// src/Bookmark/BookmarkCatalog.php
namespace App\Bookmark;

final class BookmarkCatalog
{
    private const ITEMS = [
        'example' => [
            'title' => 'Example Domain',
            'url' => 'https://example.com/',
        ],
        'symfony' => [
            'title' => 'Symfony',
            'url' => 'https://symfony.com/',
        ],
    ];

    public function all(): array
    {
        return self::ITEMS;
    }

    public function find(string $id): ?array
    {
        return self::ITEMS[$id] ?? null;
    }
}

For user-created records, require an absolute https URL, reject credentials embedded in the authority, reject localhost and literal private or reserved IP addresses, and impose URL-length limits. If untrusted users can create bookmarks, an approved-host allowlist is the strongest defense against DNS rebinding and capture abuse. Never accept a raw URL on the preview route merely because the screenshot is fetched by another service.

Add the controller and view

The controller logs failure categories and upstream status codes, but not full URLs or credentials. A failed preview returns an empty, non-cacheable response while the bookmarks page remains usable.

<?php
// src/Controller/BookmarkController.php
namespace App\Controller;

use App\Bookmark\BookmarkCatalog;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class BookmarkController extends AbstractController
{
    #[Route('/bookmarks', name: 'bookmarks_index', methods: ['GET'])]
    public function index(BookmarkCatalog $catalog): Response
    {
        return $this->render('bookmarks/index.html.twig', [
            'bookmarks' => $catalog->all(),
        ]);
    }

    #[Route(
        '/bookmarks/{id}/preview',
        name: 'bookmark_preview',
        methods: ['GET']
    )]
    public function preview(
        string $id,
        BookmarkCatalog $catalog,
        ScreenshotClient $screenshots,
        LoggerInterface $logger,
    ): Response {
        $bookmark = $catalog->find($id);

        if ($bookmark === null) {
            return new Response('', Response::HTTP_NOT_FOUND);
        }

        try {
            $capture = $screenshots->capture($bookmark['url']);

            $logger->info('Bookmark preview captured', [
                'bookmark_id' => $id,
                'quota' => $capture->quotaHeaders,
            ]);

            return new Response($capture->png, Response::HTTP_OK, [
                ...$capture->cacheHeaders,
                'Content-Type' => 'image/png',
                'Content-Disposition' => 'inline',
                'X-Content-Type-Options' => 'nosniff',
            ]);
        } catch (ScreenshotException $error) {
            $logger->warning('Bookmark preview unavailable', [
                'bookmark_id' => $id,
                'failure' => $error->kind,
                'upstream_status' => $error->upstreamStatus,
                'quota' => $error->metadata,
            ]);

            return new Response('', Response::HTTP_BAD_GATEWAY, [
                'Cache-Control' => 'no-store',
            ]);
        }
    }
}
{# templates/bookmarks/index.html.twig #}
<h2>Bookmarks</h2>

<ul>
{% for id, bookmark in bookmarks %}
    <li>
        <a href="{{ bookmark.url }}" rel="noopener noreferrer">
            {{ bookmark.title }}
        </a>
        <img
            src="{{ path('bookmark_preview', {id: id}) }}"
            alt="Preview of {{ bookmark.title }}"
            loading="lazy"
            width="480"
            height="300"
        >
    </li>
{% endfor %}
</ul>

Twig escapes titles and URLs by default. Lazy loading avoids requesting every screenshot before it is near the viewport, while fixed dimensions reduce layout movement.

Test without contacting the real service

MockHttpClient makes the boundary deterministic. These tests verify successful 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\ScreenshotException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ScreenshotClientTest extends TestCase
{
    public function testMapsPngAndCacheHeaders(): void
    {
        $http = new MockHttpClient(new MockResponse("\x89PNG\r\n", [
            'http_code' => 200,
            'response_headers' => [
                'content-type: image/png',
                'cache-control: public, max-age=300',
                'etag: "capture-1"',
            ],
        ]));

        $client = new ScreenshotClient(
            $http,
            'https://service.test/v1/capture',
            'test-token',
            static fn (int $microseconds) => null,
        );

        $capture = $client->capture('https://example.com/');

        self::assertSame("\x89PNG\r\n", $capture->png);
        self::assertSame(
            'public, max-age=300',
            $capture->cacheHeaders['cache-control']
        );
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $requests = 0;
        $http = new MockHttpClient(
            function () use (&$requests): MockResponse {
                $requests++;

                return new MockResponse('', ['http_code' => 401]);
            }
        );

        $client = new ScreenshotClient(
            $http,
            'https://service.test/v1/capture',
            'bad-token',
            static fn (int $microseconds) => null,
        );

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

Add equivalent cases for HTTP 429, a non-PNG content type, an oversized body, and a 503 followed by success. Run the suite with php bin/phpunit.

Deployment, observability, and common failures

Deploy with APP_ENV=prod, APP_DEBUG=0, and the service token supplied by the platform. Warm Symfony’s cache after installing production dependencies. Ensure outbound HTTPS access to ai.mihajlo.mk is permitted, and never expose .env.local through the web server.

Track capture latency, outcomes grouped by failure category, HTTP 429 occurrences, transient retries, and unexpected content types. Cache indicators and quota metadata are valuable for capacity planning, but avoid attaching complete bookmark URLs because query strings may contain private information.

  • HTTP 401 or 403: confirm the token belongs to the Screenshot API service. If it was regenerated, update every deployment because the previous token is revoked.
  • HTTP 429: treat it as quota or rate-limit pressure, inspect the returned quota-related headers, and reduce unnecessary requests. Do not create a rapid retry loop.
  • HTTP 400 or 422: verify that the required url query parameter contains an absolute, supported URL.
  • HTML instead of PNG: retain the content-type check. An error page must never be served as a trusted image.
  • Intermittent 502, 503, or 504: the single bounded retry absorbs a brief interruption without tying up PHP workers for an unbounded period.
  • Broken images on the page: inspect the application’s structured failure log first; the bookmarks index is intentionally independent of preview availability.

Final verification checklist

  1. The token exists only in environment-backed secret configuration.
  2. The minimal request returns an image/png body and response headers.
  3. /bookmarks loads even when the screenshot service is unavailable.
  4. Preview routes accept bookmark identifiers, not arbitrary target URLs.
  5. Only safe cache headers are forwarded to browsers.
  6. Authentication, validation, and quota failures are not blindly retried.
  7. Transport and selected gateway failures receive one bounded retry.
  8. Tests run with MockHttpClient and never spend real quota.
  9. Logs contain operational categories without tokens or sensitive URLs.

The durable pattern is larger than this particular feature: keep credentials and arbitrary input away from the browser, translate external responses at one narrow boundary, and let optional media fail independently of the core page. With those constraints in place, a useful visual bookmark preview remains a small feature instead of quietly becoming browser infrastructure, a security proxy, and an outage multiplier.

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.