Dynamic OG images in Laravel: three ways, with code

Generate a per-post og:image in Laravel with Browsershot, Intervention Image or an HTTP API — queued, cached, re-rendered only when the title changes.

Published October 24, 2026 · 6 min read

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.

{{-- 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.

FAQ

What size should a Laravel og:image be?

1200×630 pixels (1.91:1). It is what Facebook and LinkedIn recommend and X crops summary_large_image cards to 2:1 with little loss. Render at 2× (2400×1260) only if you also serve it as an in-page image; crawlers do not need it.

Should I generate the image on request or ahead of time?

Ahead of time, in a queued job after the post is saved, and store the URL. A crawler that waits several seconds for a cold headless Chrome may give up and cache a preview without an image. A signed URL that renders on the first hit and is cached afterwards is the other safe option.

Can I make OG images in Laravel without Node.js or Chrome on the server?

Yes. Intervention Image draws with GD or Imagick, which most PHP hosts already have, but you write the layout in pixel coordinates. An HTTP API like Shareglint renders an HTML template in its own Chrome, so your server only sends JSON.

How do I stop the image from being regenerated on every save?

Re-render only when a field that appears on the image changes — check $post->wasChanged(['title', 'author_id']) in the model's saved event — or hash those fields and skip the job when the hash is unchanged.