TUTORIAL · MAR 2026

Dynamic OG Images in Laravel with the OGPeek API

Laravel gives you Blade templates, middleware, queues, and a cache layer—everything you need to add dynamic Open Graph images to every route in your application. This guide walks through the full implementation: a Blade component for OG tags, a middleware that injects them automatically, Redis caching to minimize API calls, and queue-based batch generation for large sites. No Intervention Image, no GD extension, no headless Chrome. Just a URL.

OG image preview for this article generated by OGPeek

Why Most Laravel Apps Have Broken Social Previews

Laravel ships with an opinionated structure for routes, controllers, and views. But it has no opinion on meta tags. There is no built-in mechanism that forces you to set og:image on every page, and most starter kits do not include one.

The result is what you see on most Laravel projects: a single static image defined in the layout file, shared across every route. Blog posts, product pages, user profiles—they all show the same generic preview when shared on Twitter, LinkedIn, Slack, or Discord. Every share is a missed branding opportunity.

The traditional PHP approach to fixing this involves image generation libraries:

  1. Intervention Image + GD/Imagick: Create a canvas, draw text, composite layers, export PNG. Requires the GD or Imagick PHP extension on your server, font files, and 50–100 lines of drawing code per template.
  2. Headless Chrome via Browsershot: Render an HTML template to PNG using Puppeteer. Adds a 150–300 MB Chromium binary to your deployment and takes 500ms+ per render.
  3. Static images generated at deploy time: Works for marketing pages, useless for dynamic content like blog posts or user profiles.

A URL-based API eliminates all of this. You construct a URL with query parameters, put it in a meta tag, and the image is generated and cached on the API server. Your Laravel app does zero image processing.

The OGPeek API Endpoint

OGPeek generates 1200×630 PNG images from URL parameters. The endpoint structure:

https://todd-agent-prod.web.app/api/v1/og
  ?title=My+Blog+Post+Title
  &subtitle=Laravel+%C2%B7+5+min+read
  &template=gradient
  &theme=dark
  &brandColor=%23F59E0B

Key parameters:

Because it returns an image from a GET request, every social crawler—Twitter, Facebook, LinkedIn, Slack, Discord—can fetch it directly. No authentication, no cookies, no JavaScript execution required.

Installing Guzzle HTTP Client

Laravel ships with Guzzle as a dependency, but if you are on a minimal installation or need to verify it is available, install it explicitly:

composer require guzzlehttp/guzzle

For the OGPeek integration, you do not actually need Guzzle to make HTTP requests—you are building URL strings, not fetching images server-side. However, Guzzle is useful if you want to pre-warm the OGPeek cache by hitting the URL from a queue job, or if you want to download and store OG images locally. Laravel's HTTP facade wraps Guzzle and provides a cleaner API:

use Illuminate\Support\Facades\Http;

// Pre-warm the OG image cache (optional)
$response = Http::get('https://todd-agent-prod.web.app/api/v1/og', [
    'title' => $post->title,
    'subtitle' => $post->category,
    'template' => 'gradient',
    'theme' => 'dark',
    'brandColor' => '#F59E0B',
]);

// $response->status() === 200 means the image is now cached

Creating a Blade Component for OG Images

The cleanest Laravel approach is a Blade component. Create a component that builds the OGPeek URL and renders the meta tags:

Step 1: Create the component class

php artisan make:component OgImage

Step 2: Define the component logic

// app/View/Components/OgImage.php
namespace App\View\Components;

use Illuminate\View\Component;

class OgImage extends Component
{
    public string $ogUrl;
    public string $title;
    public string $description;

    public function __construct(
        public string $pageTitle,
        public string $pageDescription = '',
        public string $subtitle = '',
        public string $template = 'gradient',
        public string $theme = 'dark',
        public string $brandColor = '#F59E0B',
    ) {
        $this->title = $pageTitle;
        $this->description = $pageDescription;
        $this->ogUrl = $this->buildOgUrl();
    }

    private function buildOgUrl(): string
    {
        $params = array_filter([
            'title'      => mb_substr($this->pageTitle, 0, 60),
            'subtitle'   => $this->subtitle ?: null,
            'template'   => $this->template,
            'theme'      => $this->theme,
            'brandColor' => $this->brandColor,
        ]);

        return 'https://todd-agent-prod.web.app/api/v1/og?'
            . http_build_query($params);
    }

    public function render()
    {
        return view('components.og-image');
    }
}

Step 3: Create the Blade template

<!-- resources/views/components/og-image.blade.php -->
<meta property="og:title" content="{{ $title }}">
<meta property="og:description" content="{{ $description }}">
<meta property="og:type" content="article">
<meta property="og:url" content="{{ url()->current() }}">
<meta property="og:image" content="{{ $ogUrl }}">
<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:title" content="{{ $title }}">
<meta name="twitter:description" content="{{ $description }}">
<meta name="twitter:image" content="{{ $ogUrl }}">

Step 4: Use the component in your layout

<!-- resources/views/layouts/app.blade.php -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ $pageTitle ?? config('app.name') }}</title>

    <x-og-image
        :page-title="$pageTitle ?? config('app.name')"
        :page-description="$pageDescription ?? ''"
        :subtitle="$ogSubtitle ?? ''"
    />

    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
    {{ $slot }}
</body>
</html>

Why a Blade component instead of a helper? Components are the idiomatic Laravel approach. They keep markup in Blade files, support typed properties, and are testable with $this->component(OgImage::class). A global helper function works too, but scatters HTML generation into PHP files where it does not belong.

Dynamic OG Images for Blog Posts

With the Blade component in place, adding dynamic OG images to blog posts is a matter of passing the right variables from your controller:

// app/Http/Controllers/BlogController.php
namespace App\Http\Controllers;

use App\Models\Post;

class BlogController extends Controller
{
    public function show(Post $post)
    {
        return view('blog.show', [
            'post'            => $post,
            'pageTitle'       => $post->title,
            'pageDescription' => $post->excerpt,
            'ogSubtitle'      => $post->category->name
                . ' · ' . $post->read_time . ' min read',
        ]);
    }

    public function index()
    {
        return view('blog.index', [
            'posts'           => Post::published()->latest()->paginate(12),
            'pageTitle'       => 'Blog',
            'pageDescription' => 'Articles on Laravel, PHP, and web development.',
            'ogSubtitle'      => 'Laravel · PHP · Web Dev',
        ]);
    }
}

Every blog post now gets a unique OG image with its title and category. When someone shares your post on Twitter or LinkedIn, the preview card shows the actual post title instead of a generic site logo.

Middleware Approach for Automatic OG Tag Injection

If you have dozens of controllers and do not want to add OG variables to every one, middleware can inject OG tags automatically by reading the page title from the rendered HTML:

// app/Http/Middleware/InjectOgTags.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class InjectOgTags
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // Only process HTML responses
        $contentType = $response->headers->get('Content-Type', '');
        if (!str_contains($contentType, 'text/html')) {
            return $response;
        }

        $html = $response->getContent();

        // Skip if og:image already exists
        if (str_contains($html, 'og:image')) {
            return $response;
        }

        // Extract title from <title> tag
        if (preg_match('/<title>(.+?)<\/title>/i', $html, $m)) {
            $title = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
            $title = mb_substr($title, 0, 60);

            $ogUrl = 'https://todd-agent-prod.web.app/api/v1/og?'
                . http_build_query([
                    'title'      => $title,
                    'template'   => 'gradient',
                    'theme'      => 'dark',
                    'brandColor' => '#F59E0B',
                ]);

            $ogTags = implode("\n    ", [
                '<meta property="og:image" content="' . e($ogUrl) . '">',
                '<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="' . e($ogUrl) . '">',
            ]);

            $html = str_replace('</head>', $ogTags . "\n</head>", $html);
            $response->setContent($html);
        }

        return $response;
    }
}

Register the middleware in your bootstrap/app.php (Laravel 11+) or app/Http/Kernel.php (Laravel 10 and earlier):

// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\InjectOgTags::class,
    ]);
})

When to use middleware vs. Blade components: Use the Blade component when you want explicit, per-page control over the OG image title and subtitle. Use the middleware as a safety net that catches pages where no component was added. You can use both—the middleware checks for existing og:image tags and skips pages that already have them.

Caching OG Image URLs in Laravel

OGPeek caches images at the CDN level, so repeated requests with the same parameters are fast. But if you want to avoid building the URL string on every request, or if you plan to pre-warm the cache, Laravel's Cache facade gives you several options:

Simple URL caching with Redis or file driver

use Illuminate\Support\Facades\Cache;

function cachedOgImageUrl(string $title, string $subtitle = ''): string
{
    $cacheKey = 'og_image:' . md5($title . $subtitle);

    return Cache::remember($cacheKey, now()->addDays(7), function () use ($title, $subtitle) {
        $params = array_filter([
            'title'      => mb_substr($title, 0, 60),
            'subtitle'   => $subtitle ?: null,
            'template'   => 'gradient',
            'theme'      => 'dark',
            'brandColor' => '#F59E0B',
        ]);

        return 'https://todd-agent-prod.web.app/api/v1/og?'
            . http_build_query($params);
    });
}

This caches the generated URL string for 7 days. Since the URL is deterministic—same title and subtitle always produce the same URL—this is safe to cache indefinitely. The 7-day TTL is a convenience in case you change your template or theme parameters and want the cache to eventually refresh.

Cache invalidation on content update

If a blog post title changes, the cached OG URL becomes stale. Use a model observer to clear the cache when the post is updated:

// app/Observers/PostObserver.php
namespace App\Observers;

use App\Models\Post;
use Illuminate\Support\Facades\Cache;

class PostObserver
{
    public function updated(Post $post): void
    {
        if ($post->isDirty('title')) {
            // Clear the old cached OG URL
            $oldTitle = $post->getOriginal('title');
            Cache::forget('og_image:' . md5($oldTitle . $post->category->name));
        }
    }
}

Batch Generation with Laravel Queues

For sites with thousands of pages, you can pre-warm the OGPeek cache by dispatching queue jobs that hit each URL. This ensures the first social share of any page loads instantly:

// app/Jobs/WarmOgImageCache.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class WarmOgImageCache implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 10;

    public function __construct(
        public string $title,
        public string $subtitle = '',
    ) {}

    public function handle(): void
    {
        $response = Http::timeout(30)->get(
            'https://todd-agent-prod.web.app/api/v1/og',
            array_filter([
                'title'      => mb_substr($this->title, 0, 60),
                'subtitle'   => $this->subtitle ?: null,
                'template'   => 'gradient',
                'theme'      => 'dark',
                'brandColor' => '#F59E0B',
            ])
        );

        if ($response->successful()) {
            Log::info("OG image warmed: {$this->title}");
        } else {
            Log::warning("OG image warm failed: {$this->title}", [
                'status' => $response->status(),
            ]);
        }
    }
}

Dispatch the jobs from an Artisan command to warm all blog posts at once:

// app/Console/Commands/WarmOgImages.php
namespace App\Console\Commands;

use App\Jobs\WarmOgImageCache;
use App\Models\Post;
use Illuminate\Console\Command;

class WarmOgImages extends Command
{
    protected $signature = 'og:warm';
    protected $description = 'Pre-warm OG image cache for all published posts';

    public function handle(): int
    {
        $posts = Post::published()->get();

        $posts->each(function (Post $post) {
            WarmOgImageCache::dispatch(
                $post->title,
                $post->category->name . ' · ' . $post->read_time . ' min read',
            );
        });

        $this->info("Dispatched {$posts->count()} OG image warm jobs.");
        return Command::SUCCESS;
    }
}

Run it with php artisan og:warm after a deploy, or schedule it in routes/console.php:

// routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\Schedule;

Schedule::command('og:warm')->daily();

Rate limiting: If you are warming thousands of images, add a rate limiter to your queue worker or use Laravel's Bus::batch() with a throttle. OGPeek's free tier allows 50 images per day. The Starter plan ($9/mo) gives you 10,000 per month, and Pro ($29/mo) gives 50,000 per month.

OGPeek Pricing

Choose the plan that fits your traffic. All plans include the same API, templates, and themes—the only difference is volume:

Plan Price Volume Best For
Free $0 50 images/day Side projects, testing
Starter $9/mo 10,000 images/month Blogs, small SaaS
Pro $29/mo 50,000 images/month High-traffic sites, agencies

Most Laravel blogs and SaaS apps fit comfortably in the Starter tier. The Pro tier is designed for platforms with user-generated content where every profile, listing, or post needs its own OG image.

Intervention Image vs. OGPeek: A Quick Comparison

Intervention Image is the standard PHP image library for Laravel. It can generate OG images, but the trade-offs are significant for this specific use case:

Aspect Intervention Image OGPeek
Server dependencies GD or Imagick extension, TTF fonts None
Lines of code 50–120+ per template 5–10
Memory per render ~15–60 MB (image canvas) 0 MB (URL string only)
Render time 100–400ms on server CDN-cached, near instant
Typography quality Limited to installed server fonts Web-quality rendering
Works on shared hosting Often blocked (no Imagick) Yes (just a URL)
Custom image compositing Full control Template-based

When Intervention Image makes sense: If your OG images need to include user-uploaded photos, dynamically generated charts, or complex multi-layer composites that go beyond text on a background, Intervention Image gives you pixel-level control. For branded title cards—which is what 90% of OG images are—a URL-based API is faster to implement and maintain.

Testing OG Tags in Laravel

Verify your OG tags are rendering correctly before deploying. Laravel's test suite makes this straightforward:

// tests/Feature/OgImageTest.php
namespace Tests\Feature;

use App\Models\Post;
use Tests\TestCase;

class OgImageTest extends TestCase
{
    public function test_blog_post_has_og_image_tag(): void
    {
        $post = Post::factory()->create([
            'title' => 'Testing OG Images in Laravel',
        ]);

        $response = $this->get("/blog/{$post->slug}");

        $response->assertStatus(200);
        $response->assertSee('og:image', false);
        $response->assertSee('todd-agent-prod.web.app/api/v1/og', false);
        $response->assertSee(
            urlencode('Testing OG Images in Laravel'),
            false
        );
    }

    public function test_og_image_url_encodes_special_characters(): void
    {
        $post = Post::factory()->create([
            'title' => 'Laravel & Vue: A Comparison',
        ]);

        $response = $this->get("/blog/{$post->slug}");

        // Ampersand should be encoded, not raw
        $response->assertStatus(200);
        $response->assertSee('og:image', false);
        $response->assertDontSee(
            'title=Laravel & Vue',
            false
        );
    }
}

For manual verification, use social platform debugging tools:

Add OG Images to Your Laravel App Today

Start with 50 free images per day. No account required, no credit card, no server dependencies.

Try OGPeek free →

Wrapping Up

Laravel gives you all the tools to implement dynamic OG images cleanly: Blade components for the markup, middleware for automatic injection, the Cache facade for URL caching, and queues for batch pre-warming. The OGPeek API handles the actual image generation so your server never touches an image canvas.

The implementation path is straightforward. Start with the Blade component approach—it takes 15 minutes and covers the majority of use cases. Add the middleware if you want a safety net for routes that do not explicitly set OG tags. Use the queue-based batch warmer if you have a large content catalog and want every OG image cached before the first share.

For more OGPeek integration guides, see the complete API reference, the image size and format guide, or framework-specific tutorials for Next.js, Django, and Ruby on Rails.