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.
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:
- 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.
- 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.
- 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:
title— Primary headline rendered large. Keep under 60 characters for best results.subtitle— Secondary line below the title. Ideal for category, author, or date.template— Visual layout:gradient,minimal,split.theme— Color scheme:dark,midnight,dawn,slate.brandColor— Accent color as URL-encoded hex.%23F59E0Bis amber.
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:
- Twitter/X:
cards-dev.twitter.com/validator - Facebook/LinkedIn:
developers.facebook.com/tools/debug/ - Slack: Paste a URL in a DM to yourself. Slack renders preview cards within seconds.
- OpenGraph.xyz: Third-party tool that shows exactly what social crawlers see.
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.