Generated OG images
From core 3.20, Chrome rendering disables JavaScript and blocks HTTP(S), FTP and WebSocket asset requests. Custom templates must use static HTML/CSS and embedded assets, as the bundled templates do.
A page with no social card falls back to one shared default_og_image — the same picture on every share. This feature gives each page its own 1200×630 Open Graph / Twitter card, rendered from a Blade template by a real headless browser (via spatie/browsershot), so the title wraps across lines, accents render, CJK falls back to the right font, and overlong titles truncate cleanly — none of which a hand-rolled image library gets right on its own.
This is a free, core feature, and it's off by default. When it's off, default_og_image is used unchanged and the package stays zero-dependency.
Static pre-generation, by design
Cards are generated ahead of time by an artisan command, not on the fly during a web request. A page only ever links a card that already exists on disk — so a visitor's request never spawns a browser, and never links a missing (404) image. There is no live render endpoint (see Caveats).
Requirements
The browser driver is an optional dependency, so the free core installs without it. To turn the feature on you need, in your application:
composer require spatie/browsershotPlus the runtime Browsershot drives:
- Node.js on the host.
- Puppeteer, installed in your application root so Node resolves it:bash
npm install puppeteer - Chrome / Chromium — Puppeteer downloads its own Chromium by default; in production you'll usually point at a system Chrome (see
chrome_path).
Install puppeteer in the app root on Windows
On Windows, install puppeteer in the application root rather than relying on npm_module_path. That config key maps to Browsershot's setNodeModulePath(), which emits a POSIX NODE_PATH=… prefix and is a no-op on Windows — Node there resolves modules by walking up directories from the app, so a root install is what works. See Caveats.
Enabling
Publish the config if you haven't (php artisan vendor:publish --tag=seo-config) and flip the switch:
// config/seo.php
'og_image' => [
'enabled' => true, // requires spatie/browsershot + Chrome
],Then pre-generate the cards (nothing renders until you do):
php artisan seo:og-imagesHow resolution works
Generation never overrides an image you set. When the feature is on, the resolver fills og:image only when the page has no image of its own — that is, when the resolved og:image is either empty or still the site-wide static default_og_image. An explicit per-model image (from getSEOImage(), a seo_meta row, a content field, …) always wins over a generated card.
To decide the value, the resolver calls the generator's existence-gated lookup: it computes the card's storage path and returns its public URL only if that file already exists on the configured disk. It never renders. That's the whole safety story:
- A web request never spawns a browser — worst case it links the static
default_og_image, exactly as before the feature existed. - A page never links a not-yet-generated image, so there's no window where shares point at a 404.
You close the gap between "content changed" and "card exists" by running the seo:og-images command — on deploy and/or on a schedule.
The seo:og-images command
Pre-generates the cards so the resolver has something to serve.
php artisan seo:og-images # warm the configured models
php artisan seo:og-images --model="App\Models\Post"
php artisan seo:og-images --force # re-render even existing cards
php artisan seo:og-images --prune # + delete orphaned cards--model=*— one or more model classes to warm. Repeatable. When omitted the command usesseo.og_image.models, falling back to your sitemap models (seo.sitemap.models) — the same share-the-sitemap-sources posture asseo:llms-txt.--force— re-render cards that already exist (use after changing the template or brand colors without bumpingcache_version).--prune— after warming, delete stored cards under the configured path that no longer match any current model's content (see below). For safety it only removes files whose names are generated content hashes (never your other assets in the same directory), and it is ignored on a scoped--modelrun (whose keep-set wouldn't cover your other models) — run it without--model.
Each model must use the HasSEO trait. A record with no title is skipped (there's nothing to put on the card); the command reports generated, skipped, failed and (with --prune) pruned counts.
Scheduling
Warm on a schedule so cards track your content, and prune the orphans left behind when titles change:
// routes/console.php
Schedule::command('seo:og-images --prune')->daily();The invalidation model
A card's filename is a hash of everything that affects its pixels — the title, site name, template name, driver, dimensions, the brand gradient colors, the cache_version number, and the installed package version.
That hash is the cache key, and it has two consequences worth understanding:
- Change a title → new hash → new file. The old card is now an orphan on disk, and the page falls back to the static default until you re-warm. Running the command generates the new card;
--prunedeletes the orphan. This is the invalidation model — there's no separate "bust one page" step. - Bump
cache_version, or upgrade the package → every hash changes. Usecache_versionafter editing the template or brand colors to invalidate every card at once; a package upgrade folds in automatically, so a new release that changes the bundled template can't serve stale cards.
Bundled templates
Three templates ship with the package, all 1200×630 on the same brand gradient:
| Template | Best for | Shows |
|---|---|---|
seo::og.default | Anything | Title + site name |
seo::og.article | Blog posts, news | Section eyebrow + title + author · date byline |
seo::og.product | Products, listings | Brand lockup + category chip + title + description |
Pick one globally with seo.og_image.template, or map templates per model type so an article and a product get different cards automatically:
// config/seo.php
'og_image' => [
'templates' => [
App\Models\Post::class => 'seo::og.article',
App\Models\Product::class => 'seo::og.product',
],
],A model can also override its own template at runtime by defining getOgImageTemplate(): ?string (return a view name, or null to fall back to the map/default). Precedence: the model hook, then the templates map, then the global template.
Customizing the template
The card is a Blade view (seo::og.default by default) rendered to a self-contained HTML document — the bundled font is inlined as a data URI so the browser needs no network. Two ways to change it:
Publish and edit the bundled view:
php artisan vendor:publish --tag=seo-viewsThen edit resources/views/vendor/seo/og/default.blade.php.
Or point at your own view:
// config/seo.php
'og_image' => [
'template' => 'og.my-card', // resources/views/og/my-card.blade.php
],The template receives these variables:
| Variable | Type | Notes |
|---|---|---|
$title | string | The OG title if set, else the page title. |
$siteName | ?string | The resolved og:site_name. |
$fontDataUri | string | The bundled bold font as a data: URI (empty string if unavailable — the browser then uses its own sans-serif). |
$gradientFrom | string | seo.og_image.gradient_from. |
$gradientTo | string | seo.og_image.gradient_to. |
$width | int | Output width (default 1200). |
$height | int | Output height (default 630). |
$locale | ?string | Resolved page locale, for the <html lang> attribute. |
$author | ?string | Article author (used by seo::og.article). |
$publishedDate | ?string | Publication date for seo::og.article: ICU medium format in the page locale when available; otherwise Carbon translates the month in M j, Y order. Null when no date is provided. |
$section | ?string | Content section / category (article eyebrow, product chip). |
$description | ?string | The OG description, else the page description (used by seo::og.product). |
The template name is part of the cache key
Both the template name and the gradient colors feed the content hash, so switching templates or changing colors invalidates existing cards automatically. Editing a template in place does not (the name is unchanged) — bump cache_version (or run --force) after editing.
Configuration
// config/seo.php
'og_image' => [
'enabled' => false, // master switch (off by default)
'driver' => 'browsershot', // the render driver; register your own via OgImageManager::extend()
'template' => 'seo::og.default', // the default Blade view rendered as the card
'templates' => [], // per-model-class template overrides (see "Bundled templates")
'strip_title_suffix' => true, // trim seo.title_suffix off the card title (the card shows the site name itself)
'width' => 1200, // social-card standard
'height' => 630,
'disk' => 'public', // must be publicly served — its url() becomes the og:image
'path' => 'og-images', // path prefix on that disk
// Models seo:og-images warms. Empty → falls back to seo.sitemap.models.
// Accepts a list [Post::class] or a map [Post::class => [...]].
'models' => [],
// Bump to invalidate every card after editing a template/colors in place.
// The installed package version is folded in too, so an upgrade busts them.
'cache_version' => 1,
// Brand gradient (diagonal) for the bundled default template.
'gradient_from' => '#1e2a5a',
'gradient_to' => '#3D5AFE',
// Browsershot binary paths. null = its defaults (node/npx on PATH,
// puppeteer's bundled Chromium). Set explicitly in production.
'chrome_path' => null, // path to a system Chrome/Chromium
'node_binary' => null, // path to the node binary
'npm_module_path' => null, // node_modules dir (no-op on Windows — see Caveats)
'timeout' => 60, // hard per-render timeout, seconds
// Launch Chrome with --no-sandbox; weakens browser isolation.
// Prefer configuring the host to support Chrome's sandbox (see below).
'no_sandbox' => false,
// Extra Chromium CLI flags, e.g. ['disable-dev-shm-usage', 'disable-gpu']
// on a low-/dev-shm container. Leading "--" optional; map form for
// value-bearing flags: ['proxy-server' => 'http://…'].
'browsershot_args' => [],
// Fallback font families for glyphs the bundled face lacks (CJK, Thai,
// Arabic, …). null = the built-in Noto list; see "Fonts and non-Latin
// scripts" below.
'font_stack' => null,
],Most scalar values have a matching env var (SEO_OG_IMAGE_ENABLED, SEO_OG_IMAGE_DISK, SEO_OG_IMAGE_CHROME_PATH, SEO_OG_IMAGE_NO_SANDBOX, …) — see the config file for the full list. The array-shaped keys (templates, models, browsershot_args, font_stack) are edited in the config file directly.
The disk must be publicly served, because the resolver uses its url() as the og:image value. With the public disk, run php artisan storage:link once so public/storage points at it.
Running on Linux (the sandbox)
On hosts that restrict Chrome's sandbox mechanisms, php artisan seo:og-images can fail with:
No usable sandbox! Update your OS ... or see
https://chromium.googlesource.com/.../linux/suid_sandbox_development.mdOne possible cause is restricted user namespaces on Ubuntu 23.10+. Check the Puppeteer troubleshooting guide and the browser's actual launch error. Prefer fixing the host configuration so Chrome can retain its sandbox.
1. Explicit fallback: run Chrome with --no-sandbox. This disables browser isolation. Use only if your deployment deliberately accepts that tradeoff:
// config/seo.php
'og_image' => [
'no_sandbox' => true, // or set SEO_OG_IMAGE_NO_SANDBOX=true
],Rankbeam renders static generated HTML and blocks remote asset requests, but those controls do not replace Chrome's sandbox. Keep the render process unprivileged and isolated from unrelated workloads and secrets.
2. Keep the sandbox. Leave no_sandbox off. Where AppArmor is the cause, adapt a profile for the exact Chrome executable; see the Chromium guidance. For example:
# /etc/apparmor.d/chrome-og
abi <abi/4.0>,
include <tunables/global>
profile chrome-og /path/to/chrome flags=(unconfined) {
userns,
include if exists <local/chrome-og>
}Then load the profile with sudo apparmor_parser -r /etc/apparmor.d/chrome-og and verify Chrome launches with its sandbox enabled.
Other flags
For a container that's short on shared memory (the other common Linux failure — a Chrome crash mid-render), add flags via browsershot_args:
'browsershot_args' => ['disable-dev-shm-usage'],Custom drivers
browsershot is the only bundled driver, but the renderer is behind a contract (Rankbeam\Seo\Contracts\OgImageRenderer). Register your own — say a canvas- or service-based renderer — and select it with seo.og_image.driver:
use Rankbeam\Seo\Services\OgImage\OgImageManager;
app(OgImageManager::class)->extend('my-driver', fn ($app) => new MyRenderer());A driver only turns a self-contained HTML string into PNG bytes at a given size; it owns no layout or templating.
Fonts and non-Latin scripts
The bundled card font (Noto Sans Bold, OFL) covers Latin, Cyrillic and Greek. Every other script — Chinese, Japanese, Korean, Thai, Arabic, Hebrew, Devanagari, emoji — comes from fonts installed on the machine that runs seo:og-images. Nothing else is bundled on purpose: one CJK font is 16 MB+, and Chrome's own per-character fallback does the right thing as soon as a suitable font exists on the host.
Three things make that reliable (3.15):
A per-script
font-familystack in every bundled template. The body declares'OGBrand'(the bundled face) first, thenseo.og_image.font_stack— by defaultNoto Sans, the fourNoto Sans CJKfamilies,Noto Sans Thai,Noto Sans Arabic,Noto Sans Hebrew,Noto Sans Devanagari,Noto Color Emoji— thensans-serif. Chrome falls back per character to the first family that is installed, so the list only helps; a family that is missing is skipped. The page language's CJK family is moved to the front (ja→ JP,zh-Hans→ SC,zh-Hant/zh-TW/zh-HK→ TC,ko→ KR), because the same Han code point is drawn differently in each national font (Han unification), and the<html lang>attribute carries the page locale in BCP 47 form. The stack is part of the cache key, so changing it re-renders every card.A pre-flight in
seo:og-images. Before rendering, the command asks fontconfig (fc-list :lang=ja,th,ar, …) whether a font covers the scripts in the title, site name and description, including a minority script in mixed text, and warns once per script with the package to install:No installed font covers cjk text — its cards may render as boxes. Install one: apt-get install fonts-noto-cjkWhere fontconfig is absent (Windows, macOS, a minimal container) it stays quiet rather than guess. The render itself never fails for a missing font — Chrome draws .notdef boxes — which is exactly why the warning exists.
A glyph fixture per script in the live smoke test. With
SEO_OG_IMAGE_LIVE_TEST=1,tests/Feature/OgImage/BrowsershotSmokeTest.phprenders a title in ja, zh-Hans, zh-Hant, ko, el, ru, tr, th, ar, he and hi next to a same-length control made of an unassigned code point (guaranteed boxes) and fails, naming the script and the package, when the two PNGs are byte-identical. This is a smoke check, not proof of every glyph: mixed Latin text or different wrapping can make the images differ even when some glyphs are missing. Inspect the actual render and the fonts used on the deployment host. The language-level FontProbe warning is also a preflight, not a complete font-coverage certificate. There is no coreseo:doctorcommand; useseo:og-imagesfor this preflight.
On Debian/Ubuntu:
apt-get install fonts-noto-cjk fonts-noto-core fonts-noto-color-emoji
fc-cache -fOwn templates published with --tag=seo-views before 3.15 keep working: they receive the new $fontFamily and $lang variables and may ignore them.
Caveats
Stated honestly, because they bite in production:
- Pre-generation only — no live render endpoint (v1). There is no route that renders a card on request. Because nothing renders on a web request, there is no signed-URL / SSRF / DoS surface to configure or defend — the trade-off is that you must run
seo:og-images(on deploy and/or a schedule) for cards to exist. npm_module_pathis a no-op on Windows. It maps to Browsershot'ssetNodeModulePath(), which prepends a POSIXNODE_PATH=…to the command — ignored by Windows. On Windows, installpuppeteerin the application root so Node resolves it by walking up directories. (On Linux/macOS the setting works as expected.)- Non-Latin scripts need a font on the host. See Fonts and non-Latin scripts below: the bundled font covers Latin, Cyrillic and Greek; everything else comes from fonts installed on the deploy image, and the command tells you when one is missing.
- Fails open. If a render fails (missing package, browser crash, timeout), the command reports it and the page simply keeps its static
default_og_image— a broken browser never 500s a page.