Sitemap registry
The package generates XML sitemaps (one file per source plus an index) and serves them at /sitemap.xml and /sitemap-{name}.xml. Generation wraps spatie/laravel-sitemap:
composer require spatie/laravel-sitemapRegistering sources
Register named sources in a service provider's boot():
use App\Models\Post;
use Rankbeam\Seo\Facades\SEO;
// A model class — every (indexable) record's getUrlForSEO()
SEO::sitemaps()->register('posts', Post::class);
// A closure returning URLs
SEO::sitemaps()->register('pages', fn () => ['/about', '/contact']);
// Any iterable of URLs
SEO::sitemaps()->register('legal', ['/imprint', '/privacy']);Each source renders to sitemap-{name}.xml; sitemap.xml becomes the index listing them all.
The registry API also offers has($name), names(), forget($name), and flush().
Config-driven sources
Prefer configuration? config/seo.php accepts model sources and static URLs:
'sitemap' => [
'models' => [
\App\Models\Post::class => ['priority' => 0.8, 'changefreq' => 'weekly'],
],
'static_urls' => [
['url' => '/', 'priority' => 1.0, 'changefreq' => 'daily'],
],
],Auto-discovery defers to the registry
If a model is covered by a named registered source, auto-discovery skips it — registering 'posts' will not also produce a sitemap-post.xml.
Generating
php artisan seo:sitemapFiles are written to the disk configured at seo.sitemap.disk (default public). Schedule the command to keep sitemaps fresh:
// routes/console.php or bootstrap/app.php scheduling
Schedule::command('seo:sitemap')->daily();Sitemaps exceeding seo.sitemap.max_urls_per_sitemap (default 50,000 — the XML spec limit) are split automatically.
Serving
The package routes serve whatever the command generated, with XML headers, cache headers, and X-Robots-Tag: noindex:
/sitemap.xml— the index (or single sitemap)/sitemap-posts.xml— a named source
Serving your own statically generated sitemap instead? Disable the routes:
// config/seo.php
'routes' => ['enabled' => false],Styled sitemap in the browser
Spatie's engine renders a raw wall of XML. Open a Rankbeam sitemap in a browser and you get a readable, branded page instead — every URL in a table with its lastmod, change frequency, priority, and image/alternate counts, plus inline validation notes:

It works by referencing an XSL stylesheet from each generated sitemap:
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://your-app.test/sitemap.xsl"?>
<urlset ...>Search engines ignore the instruction, so the sitemap stays a normal machine-readable XML document — this only changes what a human sees. The index and every child sitemap are styled alike.
On by default. Unlike the image/hreflang extensions, the stylesheet adds no data and does no per-record work — it's one instruction line crawlers skip, so it's on out of the box. Turn it off to emit plain XML:
Requires spatie/laravel-sitemap ≥ 8.1
The instruction is written through spatie's setStylesheet(), added in spatie/laravel-sitemap 8.1. If your app resolves an older version (some PHP/Laravel combinations do), sitemaps are generated as plain, unstyled XML — nothing breaks. composer update spatie/laravel-sitemap to get the styled view.
// config/seo.php
'sitemap' => [
'stylesheet' => ['enabled' => false],
],Validation notes
The rendered page flags two things it can check without leaving the browser:
- URLs missing a
lastmod— surfaced, never invented. Google discounts a sitemap that fibs about freshness, so the stylesheet points the gap out rather than filling it in. - Non-absolute URLs — a
<loc>that isn't an absolutehttp(s)URL.
Self-hosting the stylesheet
By default the package serves the stylesheet from its own /sitemap.xsl route and points each sitemap at it. Browsers only apply an XSLT that is same-origin with the sitemap, so if your sitemaps live on another origin (a CDN, say), publish the file and point the config at your copy:
php artisan vendor:publish --tag=seo-assets// config/seo.php
'sitemap' => [
'stylesheet' => [
'url' => 'https://cdn.example.com/vendor/seo/sitemap.xsl',
],
],Safe by construction
Every value the stylesheet renders — URLs included — goes through XSLT output escaping, and a <loc> only becomes a clickable link when it is an http(s) URL, so hostile URL content can't inject markup or a javascript: link into the page. If you customise the published .xsl, keep it that way: don't add disable-output-escaping.
What gets included
Model sources include records that resolve as indexable; a model whose robots resolve to noindex stays out of the sitemap. URLs come from getUrlForSEO() — the same method that powers canonicals, so the sitemap and the canonical never disagree.
Image & hreflang extensions
Two optional extensions enrich each model URL with the data the package already resolves for that record. Both are off by default — enable the ones you want in config/seo.php:
'sitemap' => [
'images' => true, // <image:image> per URL
'alternates' => true, // <xhtml:link rel="alternate"> per URL
],They apply to models using the HasSEO trait (the values come from the model's fully resolved seoData()):
imagesadds a Google image-sitemap entry built from the resolved og/content image — the same value rendered asog:image, so the sitemap never disagrees with the page. When a record has no image of its own this is the site-widedefault_og_image, so enable it only if a per-URL image is meaningful for your content.alternatesadds<xhtml:link rel="alternate" hreflang="…">entries from the model'sgetSEOAlternates()— the same hreflang links rendered in the page<head>. Return absolute URLs:
public function getSEOAlternates(): ?array
{
return [
['hreflang' => 'en', 'href' => route('posts.show', [$this, 'locale' => 'en'])],
['hreflang' => 'fr', 'href' => route('posts.show', [$this, 'locale' => 'fr'])],
['hreflang' => 'x-default', 'href' => route('posts.show', $this)],
];
}hreflang must be reciprocal and self-referencing
The values you return are emitted verbatim — the package does not validate language codes or invent links. Google only honours an annotation when every language version lists itself and all the others, and the references are reciprocal (each page points back). So getSEOAlternates() must return the complete set (including a self-reference) and every localised variant must return that same complete set. Use valid language / language-REGION codes or x-default, and absolute http(s) URLs. Entries missing a non-empty hreflang or href are skipped.
Cost at scale
With either extension on, the builder resolves each record's full seoData() (the entire precedence chain — defaults lookups, computed values, the model's getSEO*() getters) once per URL. On a large catalogue that is a real cost: each record can issue several cache/database operations, and custom getters may add their own queries. It is built for the scheduled seo:sitemap command, not a request cycle — benchmark before enabling it on a sitemap approaching the 50,000-URL limit, and keep both flags off if you don't need the entries.
Already publishing a config?
config/seo.php is merged shallowly, so an app that published the config file before this release won't pick up the sitemap.images / sitemap.alternates keys automatically — the SEO_SITEMAP_IMAGES / SEO_SITEMAP_ALTERNATES env vars alone won't switch them on. Add the two keys to your published sitemap array (see the block above) or re-publish the config.
Full control: hand-built Spatie tags
For anything the resolved data doesn't cover — image captions, video, or news entries, or bespoke hreflang sets — return a fully hand-built Spatie\Sitemap\Tags\Url from a registered source. The builder passes Url tags through verbatim and never appends its own extensions to them, so you stay in complete control:
use Spatie\Sitemap\Tags\Url;
SEO::sitemaps()->register('videos', fn () => Video::query()
->get()
->map(fn (Video $video) => Url::create($video->url)
->addImage($video->thumbnail_url, caption: $video->title)
->addVideo(
thumbnailLoc: $video->thumbnail_url,
title: $video->title,
description: $video->description,
contentLoc: $video->file_url,
)
->addAlternate($video->frenchUrl, 'fr')
));The same escape hatch is available per record: a model implementing Sitemapable whose toSitemapTag() returns a Url is emitted exactly as returned.