DEV Community

Cover image for Generate Beautiful Open Graph Images for Your Laravel App with One Spatie Package
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Generate Beautiful Open Graph Images for Your Laravel App with One Spatie Package

Originally published at hafiz.dev


When someone shares a link to your Laravel app on Twitter, LinkedIn, or Slack, the platform shows a preview image. That image is the Open Graph image. Most Laravel apps either ship without one, ship with the same generic image on every page, or rely on an external service like Cloudinary or a separate Node.js renderer.

Spatie released laravel-og-image to solve this in a way that feels native to Laravel: define your OG image as HTML right inside your Blade views, let the package screenshot it, cache it, and serve it automatically. No external API. No separate CSS pipeline. No extra app.

This is the practical walkthrough I wish I had when I first looked at it. Real-world setup, the gotchas, and the Cloudflare alternative for Forge users without Chromium.

Why this package matters

Most Laravel developers I know fall into one of three buckets when it comes to OG images. They have a single static image used across every page. Or they generate images server-side using something like Browsershot directly, which works but means rebuilding the wheel every project. Or they use an external service which adds latency, cost, and another dependency to monitor.

laravel-og-image is the Laravel-native solution. The killer feature: your OG image template lives on the actual page, so it inherits your existing Tailwind classes, fonts, and Vite assets. No separate stylesheet. No design system duplication. Whatever your site looks like, your OG images can match without effort.

The pattern is borrowed from OGKit by Peter Suhm, but where OGKit is a hosted service, laravel-og-image runs entirely on your own server. Spatie also built it on top of their laravel-screenshot package, which means you can swap drivers between Browsershot (local Chromium) and Cloudflare Browser Rendering depending on your infrastructure.

How it actually works

The mental model is worth getting straight before you install anything. Here's the flow when a social platform crawls one of your pages:

View the interactive diagram on hafiz.dev

Six steps that matter:

  1. You drop an <x-og-image> Blade component into your view with whatever HTML you want.
  2. The package renders that HTML inside a hidden <template data-og-image> tag on the page. It's invisible to humans.
  3. Middleware automatically injects og:image, twitter:image, and twitter:card meta tags into your <head>.
  4. The image URL contains an md5 hash of your HTML content. Change the content, hash changes, crawlers pick up the new image.
  5. When the image URL is first requested, the package visits your page with ?ogimage appended. This renders only the template content at 1200×630 with your full CSS available.
  6. The screenshot is saved to your public disk and served with Cache-Control headers. Cloudflare or your CDN caches it from there.

That last point matters more than it sounds. Image generation only happens once per unique HTML content. After that you're serving a static JPEG with proper cache headers.

Setting it up on a Laravel app

You need PHP 8.3+, Laravel 12+, and either Node.js with Chromium installed (for the default Browsershot driver) or a Cloudflare account with Browser Rendering enabled.

Install the package (full docs are on Spatie's documentation site):

composer require spatie/laravel-og-image
Enter fullscreen mode Exit fullscreen mode

The package depends on spatie/laravel-screenshot, which depends on Browsershot, which needs Node.js and Chrome/Chromium on the server. If you're on Laravel Forge with a standard Ubuntu droplet, you'll need to install these. On Laravel Cloud, the Browsershot driver isn't an option and you'll need the Cloudflare driver instead (covered below).

Optionally publish the config file if you need to customize defaults:

php artisan vendor:publish --tag="og-image-config"
Enter fullscreen mode Exit fullscreen mode

That's the entire setup. The middleware that injects meta tags registers automatically.

Your first OG image

Open any Blade view that you want to add an OG image to. For a Laravel blog, that's typically resources/views/blog/show.blade.php, the single-post view. Drop in the component:

<x-og-image>
    <div class="w-full h-full bg-slate-900 text-white flex flex-col justify-between p-16">
        <div class="flex items-center gap-4">
            <img src="{{ asset('logo.svg') }}" class="w-16 h-16" alt="hafiz.dev">
            <span class="text-2xl font-semibold">hafiz.dev</span>
        </div>

        <h1 class="text-7xl font-bold leading-tight">
            {{ $post->title }}
        </h1>

        <div class="flex items-center justify-between text-2xl text-slate-400">
            <span>By Hafiz Riaz</span>
            <span>{{ $post->published_at->format('M j, Y') }}</span>
        </div>
    </div>
</x-og-image>
Enter fullscreen mode Exit fullscreen mode

That's it. Refresh the page in your browser and view source. You'll see the package has injected meta tags into your head:

<meta property="og:image" content="https://hafiz.dev/og-image/a3f8c2d1e9b4.jpeg">
<meta property="twitter:image" content="https://hafiz.dev/og-image/a3f8c2d1e9b4.jpeg">
<meta property="twitter:card" content="summary_large_image">
Enter fullscreen mode Exit fullscreen mode

If your page already had OG meta tags from a layout file, remove the og:image, twitter:image, and twitter:card ones. The package handles those automatically. Keep your og:title, og:description, og:type, and any other OG tags. The package only manages the image-related ones.

Previewing without sharing the link 100 times

The most useful debugging tool in this package is the ?ogimage query parameter. Append it to any page URL and you'll see exactly what gets screenshotted, at the configured dimensions, with the page's full CSS:

https://hafiz.dev/blog/your-post-slug?ogimage
Enter fullscreen mode Exit fullscreen mode

This loads in your browser as a 1200×630 viewport showing only your template content. You can iterate on the design directly here, watching it update as you tweak the Blade template. No need to actually fire the screenshot or share the URL on Twitter to see what it looks like.

Design tips that took me too long to learn

A few things I wasted time on that you can skip:

Use w-full h-full on your root element. The template renders inside a 1200×630 viewport. If you don't fill it, you'll get whitespace around your design. This is the most common mistake.

Keep text huge. OG images are viewed as thumbnails on most platforms, often around 500px wide on a phone. Your 7xl Tailwind text becomes legible. Anything smaller than 4xl is hard to read. Test at the actual rendered size before shipping.

Stick to your existing brand. Because the template inherits all your CSS, you can use your existing color tokens, fonts, and components. Don't redesign. Use what's already there.

Avoid background images that load externally. Browsershot waits for network idle by default, but external images add latency. Use solid colors, gradients, or assets served from the same domain. SVG inline is best.

Test in the LinkedIn Post Inspector and Twitter Card Validator before publishing widely. Both have rate limits but they're free. Cache busting on social platforms is a separate problem if you ship a bad image.

Using a Blade view instead of inline HTML

If you want the same OG layout across many pages, or if the template is getting complex, reference a Blade view instead:

<x-og-image view="og-image.post" :data="['title' => $post->title, 'author' => $post->author->name]" />
Enter fullscreen mode Exit fullscreen mode

Then in resources/views/og-image/post.blade.php:

<div class="w-full h-full bg-slate-900 text-white flex flex-col justify-between p-16">
    <h1 class="text-7xl font-bold">{{ $title }}</h1>
    <div class="text-2xl text-slate-400">by {{ $author }}</div>
</div>
Enter fullscreen mode Exit fullscreen mode

The data array becomes the variables available in the view. This pattern is what I'd reach for if you have multiple post types or you want OG images on a documentation site with consistent branding.

Fallback images for pages without templates

What about pages that don't have an explicit <x-og-image> component? Blog index pages, tag listings, your homepage. By default, those pages get no OG image at all. The package lets you define a fallback in your AppServiceProvider:

use Illuminate\Http\Request;
use Spatie\OgImage\Facades\OgImage;

public function boot(): void
{
    OgImage::fallbackUsing(function (Request $request) {
        return view('og-image.fallback', [
            'title' => config('app.name'),
            'tagline' => 'Laravel, Claude Code, and shipping fast',
        ]);
    });
}
Enter fullscreen mode Exit fullscreen mode

The closure receives the full Request, so you can use route parameters or model bindings to customize the fallback per URL. Return null to skip the fallback for specific requests. Pages that have an explicit component are never affected.

The Cloudflare driver: when you can't run Chromium

If you're on Laravel Cloud, a serverless platform, or you just don't want to install Chromium on your server, the Cloudflare driver is the answer. It uses Cloudflare's Browser Rendering API to take the screenshot remotely.

To switch to it, configure the screenshot driver in config/screenshot.php after publishing the screenshot config:

'default' => env('SCREENSHOT_DRIVER', 'cloudflare'),

'drivers' => [
    'cloudflare' => [
        'account_id' => env('CLOUDFLARE_ACCOUNT_ID'),
        'api_token' => env('CLOUDFLARE_API_TOKEN'),
    ],
],
Enter fullscreen mode Exit fullscreen mode

Then add the Cloudflare credentials to your .env:

SCREENSHOT_DRIVER=cloudflare
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_API_TOKEN=your-api-token
Enter fullscreen mode Exit fullscreen mode

Cloudflare's Browser Rendering API has a free tier that's generous enough for most blogs and small SaaS apps. The latency is slightly higher than local Chromium because of the round-trip, but the trade-off is no Chromium dependency on your server.

If you're already on Cloudflare for DNS or CDN, this driver is the path of least resistance.

Pre-generating images so the first share never lags

The first time the OG image URL is hit, the package generates the screenshot. That can take a few seconds, especially with the Cloudflare driver. If you tweet a link to a brand new post, the first crawler might time out before the image is ready.

The fix is to pre-generate the image when the page is published:

use Spatie\OgImage\Facades\OgImage;

class PublishPostAction
{
    public function execute(Post $post): void
    {
        $post->update(['published_at' => now()]);

        dispatch(function () use ($post) {
            OgImage::generateForUrl($post->url);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

This dispatches a job that generates the image after publishing. By the time anyone shares the URL, the image is already cached on disk. If you're using Laravel Queue Jobs at scale, this slots into your existing queue infrastructure cleanly.

Caching, storage, and clearing old images

By default, generated images are stored on the public disk, served from /og-image/{hash}.jpeg. The hash changes when the underlying HTML changes, so updates work automatically. But that means old images stay on disk forever unless you clean them up.

The package includes an artisan command to clear them:

php artisan og-image:clear
Enter fullscreen mode Exit fullscreen mode

You can find this and the other Artisan commands the package adds in your standard php artisan list output. I run the clear command monthly via the scheduler. The cost of stale images is minimal for a small blog, but if you're running a SaaS with thousands of dynamic pages, regular cleanup keeps your disk under control.

For storage on S3 or another disk, configure it via the facade in AppServiceProvider:

use Spatie\OgImage\Facades\OgImage;

OgImage::format('webp')
    ->size(1200, 630)
    ->disk('s3', 'og-images');
Enter fullscreen mode Exit fullscreen mode

WebP gives smaller file sizes if your CDN supports it. JPEG is the safer default for older crawlers.

When this package isn't the right tool

A few cases where I'd reach for something else:

Static images suffice. If your app has a single OG image used everywhere and it never needs to change, Spatie's package is overkill. Just use a static asset and reference it in your layout.

You need pixel-perfect control over fonts and rendering. Browsershot uses headless Chromium, which is great but not identical to Photoshop. If your design team wants exact rendering parity, generate images in Figma or use a service like Bannerbear.

You're on a free Laravel Cloud tier with strict timeouts. The first generation can be slow. Use the Cloudflare driver and pre-generate aggressively, or fall back to a static image.

You don't have control over the page layout. The package needs to inject meta tags into your <head> and a template into the body. If you're working inside an iframe or a constrained CMS where you can't control these, this won't work.

For everyone else building a Laravel app where shareable URLs matter, this is the right tool.

FAQ

Does this work with Laravel Cloud?

Yes, but only with the Cloudflare driver. Laravel Cloud doesn't include Chromium, so the default Browsershot driver won't work out of the box. Set up Cloudflare Browser Rendering, point the screenshot driver at it, and you're good. Pre-generation via queue jobs is also fine on Laravel Cloud.

How do I make sure social platforms pick up the new image when I update a post?

The image URL contains a hash of your template HTML. When you change the HTML (like updating a post title), the hash changes, so the URL changes, and crawlers fetch the new image automatically. The catch is that platforms like Facebook and LinkedIn cache aggressively. Use their respective debug tools to force a refresh: Facebook Sharing Debugger and LinkedIn Post Inspector.

Can I have different OG images for different page sections without writing custom logic?

Yes. Just place a different <x-og-image> component in each Blade view. Each one generates its own image based on its HTML content. For pages without an explicit component, use the fallback closure to define page-specific defaults based on the request URL or route name.

What happens if Browsershot fails to generate the image?

The package logs the error and the meta tag URL still points to the path it would have been served from. The crawler gets a 404 or 500 response on the image URL. The page itself still loads fine. To handle this gracefully, monitor the og-image queue and alert on failures. If you're using a Laravel monitoring tool like Pulse or Nightwatch, watch for failed jobs related to image generation.

Does this affect page load performance?

No. The component renders an empty hidden <template> tag in your HTML, which adds a few hundred bytes at most. The actual image generation happens out-of-band when the OG URL is requested by a crawler, not when a user loads your page. Your page weight is essentially unchanged.

Conclusion

The whole setup takes about 30 minutes from composer require to a working OG image, including some design iteration. The package does exactly what it advertises, the documentation is solid, and the Cloudflare driver makes it usable on platforms where Chromium isn't an option.

If you're shipping a Laravel app where shareable URLs matter, blog posts, product pages, documentation, this is one of those packages that pays for itself the first time someone retweets your link. Set it up once, design the template once, never think about OG images again.

Building something in Laravel where the marketing layer needs to actually work? Let's talk.

Top comments (0)