A dynamic Open Graph image in Laravel is one PNG or JPEG per post, built from the post's own title and metadata, whose URL you print in <meta property="og:image">. There are three realistic ways to make it: run a headless Chrome yourself (Browsershot), draw it in PHP (Intervention Image on GD), or call an image API. All three end the same way — a queued job writes the URL to a column, and your layout prints it.
This guide shows each one with working code, then the parts people get wrong: when to render, how to avoid re-rendering on every save, and how to test it without hitting the network.
Which approach fits
| Browsershot | Intervention Image | HTTP API | |
|---|---|---|---|
| Layout written in | HTML and CSS | PHP pixel coordinates | HTML and CSS |
| Needs on your server | Node.js, Puppeteer, Chrome | GD or Imagick | Nothing |
| Text wrapping, emoji, RTL | Handled by Chrome | Manual | Handled by Chrome |
| Typical render | 1–3 s, ~200 MB RAM per Chrome | Tens of ms | About a second, off your server |
Packages wrap the first two: spatie/laravel-og-image gives you an <x-og-image> Blade component on top of Browsershot, backstage/laravel-og-image does the same with chrome-php, and simonhamp/the-og draws cards in pure PHP. If you already run Chrome for PDFs, Browsershot is the obvious choice. If your card is a logo and one line of text, Intervention Image is fast and dependency-free. If you want HTML templates without operating Chrome in production, use an API. The rest of this guide keeps the same Eloquent Post model throughout, so you can swap approaches without touching the view.
Option 1: Browsershot (your own headless Chrome)
spatie/browsershot drives Chrome through Puppeteer, so it needs Node.js (22+ for version 5), the puppeteer npm package and a Chrome binary (requirements) on every machine that runs the job — usually only the queue workers.
// app/Jobs/RenderOgImage.php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Storage;
use Spatie\Browsershot\Browsershot;
final class RenderOgImage implements ShouldQueue
{
use Queueable;
public function __construct(private readonly int $postId) {}
public function handle(): void
{
$post = Post::findOrFail($this->postId);
$html = view('og.post', ['post' => $post])->render();
$path = "og/{$post->id}-".substr(sha1($post->title), 0, 8).'.png';
Storage::disk('public')->put($path, Browsershot::html($html)
->windowSize(1200, 630)
->waitUntilNetworkIdle()
->screenshot());
$post->updateQuietly(['og_image' => Storage::disk('public')->url($path)]);
}
}
The file name carries a hash of the title: when the title changes, the URL changes, and every platform that cached the old image has to fetch the new one. Reusing one file name is the most common reason a "fixed" preview keeps showing the old picture.
Fonts are the second trap: Chrome on a slim Docker image has almost none, so text falls back to whatever is installed. Embed the font in the view with @font-face and a data: URI or a local file, and install fonts-noto-color-emoji if titles can contain emoji.
Option 2: Intervention Image (no Chrome at all)
Intervention Image draws on a GD or Imagick canvas. It is the fastest option and needs nothing beyond a PHP extension, but every position is a pixel coordinate you choose.
use Intervention\Image\ImageManager;
use Intervention\Image\Typography\FontFactory;
$image = ImageManager::gd()->create(1200, 630)->fill('#111827');
$image->text($post->title, 80, 120, function (FontFactory $font) {
$font->filename(resource_path('fonts/Inter-Bold.ttf'));
$font->size(64);
$font->color('#f9fafb');
$font->lineHeight(1.6);
$font->wrap(1040); // wrap at 1040 px
$font->valign('top');
});
$image->text(config('app.name'), 80, 560, function (FontFactory $font) {
$font->filename(resource_path('fonts/Inter-Regular.ttf'));
$font->size(28);
$font->color('#9ca3af');
});
Storage::disk('public')->put($path, (string) $image->toPng());
What you give up: a title that wraps to five lines will run off the bottom unless you measure it and shrink the font yourself, and GD renders no colour emoji or complex scripts (Arabic shaping, Devanagari) correctly. For English titles of a known length it is perfectly fine.
Option 3: an image API from a queued job
With an API the layout is still HTML and CSS, but Chrome runs somewhere else. Here is the same job against Shareglint, using the built-in blog-og template (or a slug of your own template):
// app/Jobs/RenderOgImage.php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
final class RenderOgImage implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public array $backoff = [10, 60];
public function __construct(private readonly int $postId) {}
public function handle(): void
{
$post = Post::with('author')->findOrFail($this->postId);
$url = Http::withToken(config('services.og_api.key'))
->timeout(30)
->post('https://shareglint.com/v1/images?wait=true', [
'template' => 'blog-og',
'modifications' => [
'title' => $post->title,
'author' => $post->author->name,
'date' => $post->published_at->toDateString(),
'site' => parse_url(config('app.url'), PHP_URL_HOST),
],
])
->throw()
->json('url');
$post->updateQuietly(['og_image' => $url]);
}
}
?wait=true holds the connection until the PNG exists and returns its URL. The same fields sent twice return the existing file without using quota, so a retried job costs nothing extra. If the render takes too long you get a 503 with the render id; the $tries and $backoff above simply run the job again.
Dispatch it only when the image would change
Hook the job to the model, after the transaction commits, and only when a field that is actually drawn on the card changes:
// app/Models/Post.php
protected static function booted(): void
{
static::saved(function (Post $post) {
if ($post->published_at !== null && $post->wasChanged(['title', 'author_id', 'published_at'])) {
RenderOgImage::dispatch($post->id)->afterCommit();
}
});
}
wasChanged() is true on create as well, so new posts get an image immediately. updateQuietly() in the job saves the URL without firing saved again — otherwise the job would dispatch itself forever.
Print it in the layout
{{-- resources/views/layouts/app.blade.php, inside <head> --}}
@isset($post)
<meta property="og:title" content="{{ $post->title }}">
<meta property="og:image" content="{{ $post->og_image ?? asset('img/og-default.png') }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{ $post->title }}">
<meta name="twitter:card" content="summary_large_image">
@endisset
Keep a static fallback image: the job may not have run yet when the first person shares the link, and a page with no og:image gets cached without one. The width and height properties let Facebook lay out the preview before it has downloaded the file, as the Open Graph protocol describes.
No queue at all: a signed image URL
If you would rather not store anything, sign a URL and print it straight into the meta tag. The first crawler that requests it triggers the render; later ones get a redirect to the cached file. The secret never leaves your server, so it is safe in a Blade view:
// app/Support/OgImage.php
final class OgImage
{
public static function url(string $template, array $fields): string
{
$path = '/i/'.config('services.og_api.account').'/'.$template.'.png';
ksort($fields, SORT_STRING);
$query = implode('&', array_map(
fn ($k, $v) => rawurlencode((string) $k).'='.rawurlencode((string) $v),
array_keys($fields),
$fields,
));
$s = substr(hash_hmac('sha256', $path.'?'.$query, config('services.og_api.signing_secret')), 0, 32);
return 'https://shareglint.com'.$path.'?'.$query.'&s='.$s;
}
}
// In the view:
// <meta property="og:image" content="{{ OgImage::url('blog-og', ['title' => $post->title]) }}">
The canonical string is the path, ?, and the parameters sorted by name and encoded per RFC 3986 — rawurlencode() does exactly that. The full algorithm and a test vector are in the API docs.
Testing without the network
use Illuminate\Support\Facades\Http;
it('stores the rendered image url', function () {
Http::fake(['*/v1/images*' => Http::response(['status' => 'completed', 'url' => 'https://cdn.test/og.png'])]);
$post = Post::factory()->create(['title' => 'Hello']);
(new RenderOgImage($post->id))->handle();
expect($post->fresh()->og_image)->toBe('https://cdn.test/og.png');
Http::assertSent(fn ($request) => $request['modifications']['title'] === 'Hello');
});
Then check the real page once with a preview tool before you trust it: Facebook's Sharing Debugger and LinkedIn's Post Inspector both show which image they picked and why.