# 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 2026-10-24 · https://shareglint.com/en/guides/dynamic-og-images-laravel

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](https://github.com/spatie/laravel-og-image) gives you an `<x-og-image>` Blade component on top of Browsershot, [backstage/laravel-og-image](https://packagist.org/packages/backstage/laravel-og-image) does the same with chrome-php, and [simonhamp/the-og](https://packagist.org/packages/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](https://github.com/spatie/browsershot) drives Chrome through Puppeteer, so it needs Node.js (22+ for version 5), the `puppeteer` npm package and a Chrome binary ([requirements](https://spatie.be/docs/browsershot/v5/requirements)) on every machine that runs the job — usually only the queue workers.

```php
// 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](https://image.intervention.io/v3) 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.

```php
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):

```php
// 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:

```php
// 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

```blade
{{-- 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](https://ogp.me/#structured) 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:

```php
// 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](https://shareglint.com/en/docs#signed).

## Testing without the network

```php
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](https://developers.facebook.com/tools/debug/) and [LinkedIn's Post Inspector](https://www.linkedin.com/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.
