How to Generate Open Graph Images in Symfony
Build dynamic Open Graph images in Symfony with a secure renderer, public metadata URLs, caching, troubleshooting, and a ScreenshotNeo shortcut.
Direct answer: Generate a page-specific image in a Symfony controller or image endpoint, then publish its absolute public URL in the page’s Open Graph metadata. For URL-driven generation, validate the input and restrict allowed domains. Use a template-based renderer when the card needs text and layout; use LiipImagineBundle for transformations such as resizing, cropping, and watermarking.
This guide builds a small Symfony implementation that renders a card from route data, serves it as an image, adds the image URL to og:image, and covers URL validation, caching, deployment, failures, and alternatives.
1. Choose an Open Graph image architecture
There are three common designs:
| Design | Use it when | Trade-offs |
|---|---|---|
| Static asset | Every page can share a designed image. | Simple and cacheable, but cannot include per-page data. |
| On-demand renderer | Each page needs its title, author, category, or other record data. | Always reflects current data, but the image route must render reliably for crawlers. |
| Pre-generated files | You prefer to generate an image when content changes. | Reads quickly at request time, but requires storage and an update workflow. |
The public Kocal Open Graph image generator demonstrates a Symfony application with a GET /generate endpoint, a required url, a format of html or image, and an allowed-domain setting. Treat those as that project’s behavior, not universal Symfony requirements.
2. Install the Symfony pieces
Start with a normal Symfony application and Twig. If you need image manipulation after rendering, install LiipImagineBundle:
composer require symfony/twig-bundle
composer require liip/imagine-bundle
Symfony bundles are reusable features that a Flex application enables or disables when installed or removed. See the Symfony bundle documentation and LiipImagineBundle documentation.
3. Create a card template
Use a fixed canvas and explicit CSS so the renderer does not depend on the requesting browser. The exact dimensions are a design decision; keep them consistent across your site.
<!-- templates/og/card.html.twig -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
width: 1200px;
height: 630px;
background: #101827;
color: #f8fafc;
font-family: Arial, sans-serif;
}
.card {
width: 100%;
height: 100%;
padding: 72px;
display: flex;
flex-direction: column;
justify-content: space-between;
background: linear-gradient(135deg, #172554, #0f172a);
}
.eyebrow { color: #93c5fd; font-size: 24px; }
h1 { max-width: 1000px; margin: 24px 0 0; font-size: 64px; line-height: 1.08; }
.footer { color: #cbd5e1; font-size: 24px; }
</style>
</head>
<body>
<main class="card">
<div>
<div class="eyebrow">{{ category|default('Article')|e }}</div>
<h1>{{ title|e }}</h1>
</div>
<div class="footer">{{ site_name|default('Example.com')|e }}</div>
</main>
</body>
</html>
Escape every value inserted into HTML. If you later add remote fonts or images, make their loading behavior explicit and keep the assets available to the renderer.
4. Render the image from a Symfony controller
The following controller returns HTML for a browser-based renderer. A renderer such as your chosen headless-browser integration can load this route and capture it. The code deliberately keeps the card data server-side instead of accepting arbitrary HTML.
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class OgCardController extends AbstractController
{
#[Route('/og/card/{slug}', name: 'og_card', methods: ['GET'])]
public function __invoke(string $slug): Response
{
// Replace this lookup with your repository and return a 404 when absent.
$article = [
'title' => 'How to Generate Open Graph Images in Symfony',
'category' => 'Symfony guide',
'site_name' => 'Example.com',
];
return $this->render('og/card.html.twig', [
'title' => $article['title'],
'category' => $article['category'],
'site_name' => $article['site_name'],
]);
}
}
If you need an image response directly, place your rendering library behind a service and return its bytes:
use Symfony\Component\HttpFoundation\Response;
$imageBytes = $ogRenderer->render('og/card.html.twig', $data);
return new Response($imageBytes, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'public, max-age=3600',
]);
The renderer service is intentionally an application choice: the reviewed Symfony sources document an on-demand endpoint example but do not establish that one rendering library or request-time strategy is best for every workload.
5. Add Open Graph metadata to the page
The image URL in metadata must be absolute and publicly retrievable by the intended crawlers. Generate it with Symfony’s URL generator:
{# templates/base.html.twig or your article layout #}
{% set og_image = absolute_url(path('og_card', {slug: article.slug})) %}
<meta property="og:title" content="{{ article.title|e }}">
<meta property="og:type" content="article">
<meta property="og:url" content="{{ absolute_url(path('article_show', {slug: article.slug})) }}">
<meta property="og:image" content="{{ og_image|e }}">
<meta property="og:image:alt" content="{{ article.title|e }}">
Keep the image route accessible without an authenticated session. If your application blocks unknown user agents, add an explicit rule for the crawlers you intend to support. The reviewed sources do not establish platform-specific crawler cache rules, so verify those separately for each platform you target.
6. Generate cards from a supplied page URL safely
If your endpoint accepts a URL, do not fetch any caller-supplied host by default. Use an allowlist, parse the URL, require HTTPS where appropriate, and reject credentials, unsupported ports, localhost, private address ranges, and unexpected schemes. The example project’s explicit allowed-domain configuration is a useful signal that URL scope needs deliberate control.
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
final class GenerateController
{
private const ALLOWED_HOSTS = ['example.com', 'www.example.com'];
#[Route('/generate', name: 'og_generate', methods: ['GET'])]
public function __invoke(Request $request, OgRenderer $renderer): Response
{
$rawUrl = $request->query->get('url');
$format = $request->query->get('format', 'image');
if (!is_string($rawUrl) || !filter_var($rawUrl, FILTER_VALIDATE_URL)) {
throw new BadRequestHttpException('A valid url parameter is required.');
}
if (!in_array($format, ['html', 'image'], true)) {
throw new BadRequestHttpException('format must be html or image.');
}
$parts = parse_url($rawUrl);
$host = strtolower($parts['host'] ?? '');
$scheme = strtolower($parts['scheme'] ?? '');
if ($scheme !== 'https' || !in_array($host, self::ALLOWED_HOSTS, true)) {
throw new BadRequestHttpException('The URL is not allowed.');
}
return $renderer->renderUrl($rawUrl, $format);
}
}
For production, resolve the hostname and check the resulting IP before making an outbound request, disable redirects to untrusted hosts, set connection and total timeouts, and limit response size. Keep the allowlist in configuration rather than hard-coding it when operators need to change it.
7. Use LiipImagineBundle for transformations
LiipImagineBundle provides filter sets for image manipulation. Its documented filters include thumbnail, scale, crop, strip, and watermark, and it supports custom filters and post-processors. It is useful after you have an image, for example to create a smaller card variant or crop a background. It is not documented as a complete text-and-layout Open Graph card composition engine.
# config/packages/liip_imagine.yaml
liip_imagine:
filter_sets:
og_preview:
filters:
thumbnail:
size: [1200, 630]
mode: outbound
strip: ~
Apply the filter to a stored source image or to an image URL according to your bundle setup. Keep text composition in your Twig/browser renderer and image transformations in the filter pipeline.
8. Cache and version generated images
Cache by a stable key derived from the content identity and an explicit design version. A useful key includes the article ID, a content revision, and a template version. When title or author data changes, invalidate the key or generate a new filename.
$cacheKey = sprintf('og:%s:%s:v2', $article->getId(), $article->getUpdatedAt()->getTimestamp());
$image = $cache->get($cacheKey, function () use ($renderer, $data) {
return $renderer->render('og/card.html.twig', $data);
});
Symfony asset configuration supports base_path, base_urls, and a version query parameter emitted by the Twig asset() helper. Increment that version when using it for cache busting. The version option cannot be combined with version_strategy or json_manifest_path. This helper versioning applies to asset-helper output; it does not automatically change an arbitrary metadata string.
9. Or skip the browser setup
ScreenshotNeo is a website screenshot API with a Symfony-friendly HTTP interface. One request returns PNG, JPEG, WebP, or PDF. It can capture a page or element, wait for a selector, delay, or network idle, apply custom CSS and JavaScript, use device presets or a custom viewport, and set headers, cookies, user agent, authorization, timezone, and geolocation. See the ScreenshotNeo API documentation for the current parameter reference.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/article -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com/article"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/article' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
await fs.promises.writeFile('shot.webp', bytes);
For Open Graph cards, publish the resulting file at a stable public URL or use a signed link for a public <img> tag. ScreenshotNeo accepts the cookie or consent banner like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be disabled. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response reports the result through X-Page-Verdict and X-Billed headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
The free plan includes 1,000 screenshots per month without a card. Paid plans start at $5 for 3,000 shots; yearly billing gives two months free, and every feature is available on every plan.
Create a free ScreenshotNeo account and start with the 1,000 free monthly screenshots.
10. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
og:image is ignored |
The URL is relative, private, blocked, or returns HTML. | Use absolute_url(), make the route public, and verify the response content type and status. |
| Image is blank | Fonts, images, or JavaScript were not available when capture occurred. | Use stable local assets, wait for a selector or network idle, and set an explicit timeout. |
| Text is cut off | Long titles exceed the template’s layout. | Set a maximum width, add line wrapping, reduce font size for long values, or provide a shorter card title. |
| Generation fetches an unsafe host | URL input is accepted without scope checks or redirects are followed. | Allowlist hosts, require the expected scheme, validate resolved IPs, and re-check every redirect. |
| Old image remains after editing | Caches use the same URL and key. | Include a content revision or template version in the generated path and cache key. |
| LiipImagine filter fails | The source is missing or the filter set is not configured. | Confirm the source path, filter-set name, and bundle configuration before requesting the filtered URL. |
| Renderer times out | The page waits on third-party requests or never reaches the chosen readiness condition. | Remove unnecessary dependencies, set a finite timeout, and wait for a specific selector when possible. |
11. Performance, reliability, and cost notes
- Rendering work: On-demand browser rendering consumes more CPU and memory than serving a stored file. Cache successful outputs and regenerate when content changes.
- Failure handling: Return a clear 4xx for invalid input and a controlled 5xx for renderer failures. Log the URL, content identifier, duration, and failure class without logging secrets.
- Outbound requests: Limit hosts, redirects, response size, and total time. Remote fonts and analytics add failure points and rarely belong in a social card.
- Deployment: Verify that the generated URL works from outside your network and that TLS certificates, proxy rules, and cache headers are correct.
- Hosted capture cost: With ScreenshotNeo, only clean shots are billed; failed loads, blank pages, bot checks, timeouts, and cache hits are not billed. Use the response headers to record the verdict and billing result.
12. Deployment checklist
- Render a card for a real article and inspect the image bytes.
- Confirm
og:imageis an absolute public URL. - Check that the image endpoint returns the intended image content type.
- Test long titles, missing authors, non-ASCII text, and absent background images.
- Configure an allowed-domain list for any URL-driven endpoint.
- Set finite network, rendering, and response-size limits.
- Choose cache keys that change when content or card design changes.
- Monitor renderer errors and image-generation latency.
FAQ
Should I generate the image during the page request?
You can, but the reviewed Symfony example only establishes that an on-demand endpoint is a viable pattern. For predictable page responses, generate on content changes or cache the result.
Can LiipImagineBundle create the whole card?
Its documentation covers image transformations such as crop, scale, thumbnail, strip, and watermark. Use a template or renderer for text and layout composition, then apply LiipImagine transformations if needed.
Does Symfony asset versioning update og:image automatically?
No. The version option affects paths emitted through the asset helper. Build the complete absolute metadata URL yourself and version it when your image URL needs cache invalidation.
What must be public?
The final image URL, and any page or assets required by your renderer, must be reachable by the systems that retrieve them. Do not expose private application data through a URL-generation endpoint.


