Get started with 33% off your first certification using code: 33OFFNEW

How to generate dynamic Open Graph images in Laravel

7 min read
Published on 13th May 2026

Open Graph images are the small social cards that show up when someone pastes your URL into Slack, X, LinkedIn, WhatsApp, or iMessage. Get them right and your link looks like a polished product. Get them wrong, or leave them off entirely, and your link looks like a stripped-down placeholder next to every competitor's branded preview.

The problem is that a static og:image baked into every page gets repetitive fast. Once you have hundreds of articles, products, or user profiles, you really want each one to have its own bespoke image showing the relevant title, author, price, or stats. Doing that by hand in Figma is not an option, and Laravel does not ship a built-in way to render an image from a Blade view.

This article walks through three practical approaches to generating dynamic Open Graph images in Laravel, with the trade-offs of each, working code, and a sensible caching pattern at the end.

Why dynamic Open Graph images matter

Before the implementation, a quick sanity check on the value:

  1. Click-through rate. A custom image with the article title and your branding consistently outperforms a default site image. The effect is most pronounced on link-heavy platforms like Slack and X.
  2. Brand consistency. A templated card means every shared link looks like it came from your site, even when someone screenshots it.
  3. Information density. You can embed price, rating, author, or category data directly into the image, which is content that Twitter or Slack would otherwise truncate.

The technical bar is also low. All you need is a way to take some HTML, hand it to a headless browser, and get a PNG back. The differences between approaches come down to where the headless browser runs and who maintains it.

The three approaches

There are essentially three places you can do the HTML to image conversion:

  1. A pure Blade template served as HTML. Cheap, but the social platforms cache it badly and you cannot send PNG bytes from Laravel without a renderer.
  2. A headless Chromium running on your own server. Full control, but you are now responsible for keeping Chromium alive in production.
  3. A managed HTML to image API. Someone else runs the browser farm. You POST HTML and get a PNG URL back. Less to maintain, predictable cost per render.

Each has a real use case. Let's walk through them.

Approach 1: Pre-rendered Blade templates

If your site is small and the cards rarely change, you can bypass image generation entirely and just ship a Blade view that renders the card as HTML. You then take a screenshot of it once and save it as a static asset.

// routes/web.php
Route::get('/og-card/{post:slug}', function (Post $post) {
    return view('og-card', ['post' => $post]);
});
{{-- resources/views/og-card.blade.php --}}
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <style>
        body {
            margin: 0; width: 1200px; height: 630px;
            display: flex; align-items: center; justify-content: center;
            background: linear-gradient(135deg, #4e46e5, #14b8a6);
            color: #fff; font-family: Inter, sans-serif; text-align: center;
        }
        h1 { font-size: 64px; font-weight: 800; padding: 0 80px; }
    </style>
</head>
<body>
    <h1>{{ $post->title }}</h1>
</body>
</html>

This is genuinely useful as a starting point. You can preview the design in your browser, iterate fast, and treat the Blade view as the source of truth. The catch is that this view is HTML, not an image, and the social platforms will not render arbitrary HTML in a preview card. You still need to convert it to a PNG before referencing it in your og:image meta tag.

So this approach is rarely the final answer on its own. It tends to become the input to either approach 2 or approach 3 below.

Approach 2: Self-hosted with Browsershot

Spatie's Browsershot is the standard way to render HTML to PDF or PNG in a Laravel app. Under the hood it shells out to a Node script that drives Puppeteer, which controls a headless Chromium.

Install it:

composer require spatie/browsershot
npm install puppeteer

Then a controller that takes the Blade view above and returns a PNG:

use Spatie\Browsershot\Browsershot;
use Illuminate\Support\Facades\View;

public function image(Post $post)
{
    $html = View::make('og-card', ['post' => $post])->render();

    $png = Browsershot::html($html)
        ->windowSize(1200, 630)
        ->setScreenshotType('png')
        ->screenshot();

    return response($png, 200, [
        'Content-Type'  => 'image/png',
        'Cache-Control' => 'public, max-age=31536000, immutable',
    ]);
}

This works. It is also the approach most likely to bite you in production. The issues are well-documented across the Laravel community:

  • Chromium is heavy. A cold render can take 2 to 4 seconds. A warm render in the same PHP process is faster, but PHP-FPM workers do not stay warm long enough to benefit.
  • Memory leaks. Long-running queue workers that render images can drift upwards in memory until the OOM killer steps in. You either restart workers aggressively or run renders one-shot.
  • Lambda or serverless deployments. Bundling Chromium inside a Lambda layer is doable but fiddly, and cold starts are slow. There are mature recipes online but it is not pleasant infrastructure work.
  • Font rendering. Custom fonts need to be installed inside the container running Chromium. If they are not, your card silently falls back to a system font and the design breaks.

If you have a small site and can tolerate a 2-second render on first hit followed by a cached result, Browsershot is fine. If you are rendering hundreds of cards a day across multiple servers, the operational overhead is not trivial.

Approach 3: A managed HTML to image API

The third option is to keep your Blade template as the design and send the rendered HTML to a hosted rendering service. Services like HTML to Image take a POST request with your HTML, run it through their own browser farm, and return a permanent PNG URL on a CDN.

Your Laravel code becomes a one-liner around Http::post:

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\View;

public function image(Post $post)
{
    $html = View::make('og-card', ['post' => $post])->render();

    $response = Http::withToken(config('services.html2img.key'))
        ->post('https://app.html2img.com/api/html', [
            'html'   => $html,
            'width'  => 1200,
            'height' => 630,
        ])
        ->throw()
        ->json();

    return redirect()->away($response['url']);
}

The trade-offs flip:

  • You do not own any Chromium infrastructure. No memory leaks, no fonts to install, no Lambda layers.
  • Renders happen on warm browser instances, so first-hit latency is usually under a second.
  • You pay per render. Cheap at low volume, but worth modelling if you expect millions of cards a month.
  • The image is hosted on the provider's CDN by default. If you want it on your own domain, you download the bytes and store them yourself (which we cover in the next section).

For most Laravel apps that are not pushing huge render volume, the managed approach removes a class of production bugs that you would otherwise have to babysit.

Caching: render once, serve forever

Whichever approach you pick, you absolutely do not want to re-render the same card on every request. Two things drive the cost: render time and per-call API cost (in the hosted case). Both go to zero if you store the result.

The cleanest pattern is a content-addressed cache key. Hash whatever inputs change the image, and use that hash as the storage key:

use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\View;

public function image(Post $post)
{
    $hash = hash('sha256', json_encode([
        $post->id,
        $post->title,
        $post->updated_at->timestamp,
        'v1', // bump this to invalidate every card
    ]));

    $path = "og/{$hash}.png";

    if (! Storage::disk('s3')->exists($path)) {
        $html = View::make('og-card', ['post' => $post])->render();

        $url = Http::withToken(config('services.html2img.key'))
            ->post('https://app.html2img.com/api/html', [
                'html'   => $html,
                'width'  => 1200,
                'height' => 630,
            ])
            ->throw()
            ->json('url');

        $bytes = Http::get($url)->throw()->body();

        Storage::disk('s3')->put($path, $bytes, [
            'ContentType'  => 'image/png',
            'CacheControl' => 'public, max-age=31536000, immutable',
        ]);
    }

    return redirect(Storage::disk('s3')->url($path));
}

A few things are doing real work here:

  • The hash includes updated_at, so if the post title or any other relevant field changes, the cache key changes and a new image is generated automatically. Old images stay in S3 but stop being referenced.
  • The 'v1' segment lets you globally bust every card when you change the design.
  • Cache-Control: public, max-age=31536000, immutable means once a browser or CDN sees this file, it never asks for it again. The filename is the cache key, so this is safe.

Now your og:image meta tag points at the controller route, which 302-redirects to the S3 URL. The first share of a new post triggers one render. Every subsequent share, scrape, or preview is a CDN hit.

Linking it up

Once you have a URL that returns the image, add it to your layout:

<meta property="og:image" content="{{ route('post.og-image', $post) }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{{ route('post.og-image', $post) }}">

Test by pasting a URL into Slack or using the LinkedIn Post Inspector and X's Card Validator. Both will flush their cache for that URL, which is essential when you are iterating on the design.

Picking an approach

The honest summary:

  • Browsershot if you already run Node alongside your Laravel app, have low volume, and are happy maintaining Chromium yourself.
  • A managed API like HTML to Image if you would rather not run a browser farm and your volume is in the thousands rather than millions of renders per month.
  • Pre-rendered static images only if your card design is truly identical across pages, in which case you do not need any of this and can just ship a PNG in your asset pipeline.

Whatever you pick, the meaningful productivity gain is treating your card as a Blade template. Designs stay in source control, you can preview them in the browser, and changing the design is a normal pull request. The renderer underneath is mostly a swappable detail.

Get your caching layer right and the renderer cost stops mattering. Each post gets one render in its lifetime, and every share after that is served from your CDN at zero marginal cost.