Configuration
Publish the config file:
php artisan vendor:publish --tag=seo-configEverything below lives in config/seo.php. Values shown are the defaults.
Site-wide defaults (layer 1)
'site_name' => env('APP_NAME', 'My Site'),
'title_suffix' => ' | ' . env('APP_NAME', 'My Site'),
'title_suffix_skip_when_contains' => [], // brand tokens, e.g. ['Acme']
'default_og_image' => env('SEO_DEFAULT_OG_IMAGE', '/images/og-default.jpg'),
'default_robots' => env('SEO_DEFAULT_ROBOTS', 'index,follow'),
'default_twitter_card' => env('SEO_DEFAULT_TWITTER_CARD', 'summary_large_image'),
'twitter_site' => env('SEO_TWITTER_SITE'), // @username, without @
'twitter_creator' => env('SEO_TWITTER_CREATOR'),
'favicon' => '/favicon.ico',title_suffix is appended to resolved titles unless the title already ends with it.
title_suffix_skip_when_contains is a brand-aware suppression list. When the resolved title already contains one of these tokens as a whole word (case-insensitive, word-boundary aware — so Acmestic does not match Acme), the suffix is skipped to avoid a redundant double-brand title. The default [] preserves the historical behavior.
Robots rendering policy
'robots' => [
'emit_default' => env('SEO_EMIT_DEFAULT_ROBOTS', false),
],The rendered <head> omits the <meta name="robots"> tag when the resolved directive equals default_robots (above) — a redundant index,follow is noise, and its absence is exactly what a crawler treats as index,follow. A directive that deviates (noindex, nofollow, max-snippet:-1, …) is always emitted, verbatim. Set emit_default to true to always render the tag (restores the pre-3.1 behaviour). The granular @seoRobots directive is unaffected — it is an explicit opt-in and always renders. See the Rendering Contract for the supported directive vocabulary and precedence.
Indexing guard (non-production safety net)
'indexing_guard' => [
'enabled' => env('SEO_INDEXING_GUARD', false),
'allowed_environments' => ['production'],
'send_header' => env('SEO_INDEXING_GUARD_HEADER', true),
],When the app runs in an environment not in allowed_environments, the guard forces noindex,nofollow on every page (above the whole precedence chain, so it overrides even a stored per-page value), sends a matching X-Robots-Tag header, emits a disallow-all robots.txt, and makes seo:audit print a banner. On production it is inert.
Ships off (byte-identical until you opt in); arm it with SEO_INDEXING_GUARD=true and disable it with SEO_INDEXING_GUARD=false — one line either way. Override the allow-list via SEO_INDEXING_GUARD_ALLOWED (comma-separated; Str::is() wildcards like prod* work; an empty list guards everywhere).
send_header (on by default within the guard) also sends X-Robots-Tag: noindex,nofollow on every response routed through the app, so PDFs, feeds and images — which carry no <meta robots> — are held back too; the middleware is registered only when the guard is enabled. Strongly recommended — see the full Indexing guard guide.
Canonical URLs
'canonical' => [
'query_whitelist' => [], // e.g. ['page']
],A canonical the resolver derives (from the request URL or a model's getUrlForSEO()) has its query string stripped by default — tracking, filter and sort params all create duplicate-content canonical targets for the same page. List keys in query_whitelist and they are kept in derived canonicals, in this order, while every other param is still stripped. The usual case is page for paginated archives (/blog?page=2 is genuinely not /blog).
An explicitly set canonical (admin-entered, or from a higher precedence layer) is always emitted verbatim, query string and all — the whitelist governs only the derived fallback. Default [] preserves the strip-everything behaviour.
Feature toggles
'features' => [
'auto_create_meta' => env('SEO_AUTO_CREATE_META', true),
'sitemap' => env('SEO_SITEMAP_ENABLED', true),
'schema' => env('SEO_SCHEMA_ENABLED', true),
'multilingual' => env('SEO_MULTILINGUAL_ENABLED', false),
],auto_create_meta creates an empty seo_meta row when a HasSEO model is created (note: seeders using WithoutModelEvents bypass this).
Focus keywords
'keywords' => [
'enabled' => env('SEO_KEYWORDS_ENABLED', false),
],The focus-keyword workflow gate. While false (the default), a page that has no focus keyword is not flagged anywhere — neither seo:audit nor the Pro scan complains, so an app that never adopts focus keywords is never nagged about a feature it doesn't use. Turn it on once you start setting focus keywords (e.g. with the Filament focus-keyword field) and the free audit, the Pro scan, and the Pro editor all begin reporting a missing_focus_keyword notice on pages that still lack one — they read this same flag, so they always agree.
Free audit (seo:audit)
'audit' => [
// \App\Models\Post::class, \App\Models\Page::class
'models' => [],
],The models the free seo:audit command audits when no --model option is passed. Each must use the HasSEO trait. When empty, the command falls back to the models registered under sitemap.models.
Computed fallbacks (layer 5)
'computed' => [
// Ordered attribute candidates for the description fallback.
// Empty = built-in chain: excerpt, summary, description, intro,
// lead, teaser, content, body, text, article.
'description_fields' => [],
// Truncation length — word boundary, no ellipsis.
'description_max_length' => 160,
// Social / Open Graph image selection.
'image_selection' => [
// 'first' (default) — first non-empty source wins, nothing measured.
// 'best' — score local candidates by closeness to the ideal below,
// skipping any under the minimum.
'strategy' => env('SEO_IMAGE_SELECTION', 'first'),
'minimum_width' => 200,
'minimum_height' => 200,
'ideal_width' => 1200,
'ideal_height' => 630,
],
],Under the opt-in best strategy, the builder scores an ordered candidate list — getSEOImage() first (it stays the highest-priority candidate), then the model's getSEOImages() hook, then the common image fields, the first content image, and the configured default — by how close each image's pixel dimensions are to the ideal, and skips any below the minimum. Only local images are measured (a relative path under public/, the public disk, or an absolute URL on your own host); a remote URL is never fetched and only acts as a fallback. When no local candidate clears the minimum, selection falls back to first-match, so best never returns less than first would. Expose candidates from your model:
use Rankbeam\Seo\Data\SEOImageCandidate;
public function getSEOImages(): iterable
{
return [
SEOImageCandidate::make($this->hero_url)->priority(100),
SEOImageCandidate::make($this->thumbnail_url)->priority(10),
];
}Sitemaps
'sitemap' => [
'disk' => env('SEO_SITEMAP_DISK', 'public'),
'path' => 'sitemap.xml',
'max_urls_per_sitemap' => 50000,
// ModelClass::class => ['priority' => 0.8, 'changefreq' => 'weekly']
'models' => [],
// [['url' => '/', 'priority' => 1.0, 'changefreq' => 'daily']]
'static_urls' => [],
'ping_search_engines' => env('SEO_SITEMAP_PING', false),
],See the sitemap registry guide for programmatic sources.
Schema (JSON-LD)
'schema' => [
'organization' => [
'name' => env('APP_NAME'),
'url' => env('APP_URL'),
'logo' => env('SEO_ORGANIZATION_LOGO'),
// 'sameAs' => [], // social profile URLs
],
'publisher' => [
'name' => env('APP_NAME'),
'logo' => env('SEO_PUBLISHER_LOGO'),
],
'website' => [
'name' => env('APP_NAME'),
'url' => env('APP_URL'),
// 'potentialAction' => [], // SearchAction for sitelinks search box
],
],These feed the schema graph nodes.
Routes
'routes' => [
'enabled' => env('SEO_ROUTES_ENABLED', true),
'prefix' => '',
'middleware' => ['web'],
'api_prefix' => 'api/seo',
'api_middleware' => ['api'],
],Set enabled => false when your app serves its own static /sitemap.xml.
Cache
'cache' => [
'prefix' => 'seo_',
'store' => env('SEO_CACHE_STORE'), // null = app default
// Resolver result cache — the scale lever for hot frontends. OFF by default.
'resolver' => [
'enabled' => env('SEO_RESOLVER_CACHE', false),
'ttl' => env('SEO_RESOLVER_CACHE_TTL', 3600),
],
],Resolver result cache
The SEOResolver runs the full precedence chain — config → global / model-type / route defaults → computed model values → explicit seo_meta → title suffix / canonical / schema — on every frontend render. On a high-traffic site (the reference app does ~20k req/day) that is several DB reads per page.
Enable cache.resolver.enabled and a model's fully-resolved SEO is cached and a cache hit skips the precedence chain entirely — in the package benchmark a warm hit issues zero database queries where each uncached resolve re-reads the model's seo_meta. The cached payload is a plain array, rehydrated with SEOData::fromArray() (never an object — Laravel 13 ships cache.serializable_classes = false, so a cached object returns as a __PHP_Incomplete_Class).
It uses the store configured above, so point it at a shared, persistent cache (redis / memcached) in production — the cache and its invalidation must be visible to every web/queue worker. Leave it off until you have one.
Invalidation is automatic and correct — caching ON resolves identically to OFF. Entries are keyed by (model class, id, locale, route, request URL) and cleared when:
- the page's
seo_metarow is saved or deleted (any path:saveSEO(), Filament, a directSEOMetawrite); - a content field on the model changes — the columns from
getSEOContentFields()(the default includes every built-in computed fallback field: title/headline fields, excerpt/summary/content/body/text/ article fields, and common image fields such asfeatured_image,thumbnail,cover_image,og_image,photo,banner, andhero_image; override it if your model computes SEO from additional columns); - any
seo_defaultsrow changes (a default can feed any model, so this flushes the whole resolution cache).
On a taggable store (redis, memcached, array) a model's entries clear via cache tags; on a non-taggable store (file, database) the package falls back to a per-model version stamp. Both work without key-scanning.
TIP
Only model-backed resolves are cached. SEO::render()/@seo() for a hand-built SEOData and @seoForRoute() for a model-less route still resolve live.
WARNING
The cache reflects the model's updated_at/computed modified_time as of the last content-field change (or until the TTL expires). A bare touch() that moves only updated_at without changing a getSEOContentFields() column does not force a re-resolve — article:modified_time may lag by up to the TTL. Add any app-specific computed column to getSEOContentFields() if you need it busted eagerly.