spatie/laravel-sitemap
Generate sitemaps for your Laravel site with ease. Crawl your entire site automatically or build sitemaps manually, add URLs with metadata like last modification dates, and split large crawls into multiple sitemap files.
Install and publish config
composer require spatie/laravel-sitemap
php artisan vendor:publish --provider="Spatie\Sitemap\SitemapServiceProvider" --tag=config
The published config/sitemap.php exposes guzzle_options, execute_javascript, crawl_profile, and crawler concurrency. For most apps you won't touch it until you hit a crawling wall (auth headers, JS-rendered pages, timeouts).
Pick your generator mode first
There are two separate mental models in this package, and choosing the right one up front avoids most pain:
SitemapGenerator walks your live site over HTTP. Zero maintenance, but slow, network-dependent, and it only sees what an anonymous visitor sees.Sitemap::create()->add(...) where you enumerate URLs yourself. Fast, deterministic, testable, and the correct choice for anything with a database behind it.The "best of both worlds" pattern in the README (crawl, then ->getSitemap()->add(...)) is genuinely useful for a marketing site + a handful of dynamic pages, but it is not the pattern you want for a site with 20k products.
First use case: a scheduled console command
The canonical day-one implementation is an artisan command that writes to public/ and is scheduled nightly:
// app/Console/Commands/GenerateSitemap.php
class GenerateSitemap extends Command
{
protected $signature = 'sitemap:generate';
public function handle(): int
{
Sitemap::create()
->add(Post::published()->get())
->add(Page::all())
->writeToFile(public_path('sitemap.xml'));
return self::SUCCESS;
}
}
// routes/console.php (Laravel 11+) or app/Console/Kernel.php
Schedule::command('sitemap:generate')->dailyAt('03:00');
Register Sitemap: https://example.com/sitemap.xml in robots.txt or via a route, and you're done. Start here, then evolve.
Sitemapable is the extension point you'll actually use
Rather than hand-building Url objects, implement the contract on models. It keeps sitemap logic next to the model and makes it reusable across commands:
class Post extends Model implements Sitemapable
{
public function toSitemapTag(): Url|string|array
{
return Url::create(route('blog.show', $this))
->setLastModificationDate($this->updated_at)
->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY)
->setPriority(0.8);
}
}
Because the return type allows array, you can emit multiple tags per model — handy for paginated resources:
public function toSitemapTag(): array
{
return collect(range(1, $this->page_count))
->map(fn ($p) => Url::create(route('blog.show', [$this, 'page' => $p])))
->all();
}
Then Sitemap::create()->add(Post::published()->cursor()) just works — the sitemap accepts iterables.
Manual sitemaps scale better than crawling
For anything database-backed, enumerate. Use cursor() to keep memory flat:
$sitemap = Sitemap::create();
Post::published()->select(['id', 'slug', 'updated_at'])->cursor()
->each(fn (Post $post) => $sitemap->add($post));
$sitemap->writeToFile(public_path('sitemap-posts.xml'));
Selecting only the columns you need is worth it — toSitemapTag() frequently touches updated_at, slug, and relations.
Use maxTagsPerSitemap() + an index for large sites
Search engines cap a single sitemap at 50,000 URLs / 50 MB uncompressed. The package will happily write a file that exceeds it and you'll only find out in Search Console:
Sitemap::create()
->add(Post::published()->cursor())
->maxTagsPerSitemap(50000)
->writeToFile(public_path('sitemap-posts.xml'));
This emits sitemap-posts_0.xml, sitemap-posts_1.xml, … but does not write an index for you in manual mode. You must write sitemap.xml yourself:
SitemapIndex::create()
->add('/sitemap-posts_0.xml')
->add('/sitemap-posts_1.xml')
->add('/sitemap-pages.xml')
->writeToFile(public_path('sitemap.xml'));
The newer sitemapIndexPath() on SitemapGenerator handles this automatically for crawl mode.
Split a crawl by section with the closure form of writeToFile
This is the feature most teams miss. Instead of one giant crawl dump, route URLs to files by path — which makes per-section indexing coverage visible in Search Console:
SitemapGenerator::create(config('app.url'))
->sitemapIndexPath(public_path('sitemap.xml'))
->writeToFile(fn (Url $url) => match (true) {
str_starts_with($url->path(), '/blog') => public_path('sitemap-blog.xml'),
str_starts_with($url->path(), '/docs') => public_path('sitemap-docs.xml'),
str_starts_with($url->path(), '/admin') => null, // excluded entirely
default => public_path('sitemap-pages.xml'),
});
Returning null is the cleanest way to exclude a section — better than fighting Disallow rules in robots.txt that the crawler also honors.
Constrain the crawler rather than crawling everything
SitemapGenerator::create(config('app.url'))
->configureCrawler(fn (Crawler $crawler) => $crawler->setConcurrency(4))
->setMaximumCrawlCount(5000)
->shouldCrawl(fn (Url $url) => ! str_contains($url->path(), '/search'))
->writeToDisk('public', 'sitemap.xml');
shouldCrawl is the right hook for query-string URLs, faceted search, and paginated archives — anything that would otherwise balloon the sitemap with near-duplicates.
Enrich with image, video, and news tags
Since 6.2/6.3 the tag objects support media extensions. For a content site:
Url::create(route('posts.show', $post))
->addImage(Image::create($post->hero_url)->setCaption($post->title)->setTitle($post->title))
->addVideo(Video::create($post->video_url))
->setLastModificationDate($post->updated_at);
Google News entries need recent publication_date values, so generate those from a published_at-scoped query rather than the full archive.
Run the crawl in a queued job, not a web request
Crawling a real site takes minutes. A schedule → command → Bus::dispatch → job with an explicit $timeout is the safe shape. Alternatively, avoid crawling entirely and use manual mode in a queued job; it finishes in seconds for most sites.
Test the output, not the crawler
The XML is deterministic in manual mode, so assert against it:
$this->artisan('sitemap:generate')->assertSuccessful();
$this->assertStringContainsString(
route('blog.show', $post),
file_get_contents(public_path('sitemap.xml'))
);
Use Sitemap::create()->add(...)->render() to get the XML string in-memory and assert on it without touching disk.
The crawler makes real HTTP requests — to whatever URL you give it
SitemapGenerator::create('http://localhost:8000') in a test or CI job will hit your dev server, or hang, or produce an empty sitemap. Point it at a publicly reachable URL, or (better) use manual mode in tests entirely. Also note: if the crawler hits a site behind basic auth, a maintenance-mode response, or APP_ENV=local noindex headers, you'll get a silently truncated sitemap.
robots.txt is respected — including your own Disallow: /
A staging site with a blanket disallow produces a sitemap containing only the seed URL, or nothing. This is the single most common "why is my sitemap empty" cause. Check robots.txt before debugging anything else.
lastModificationDate is no longer set to now() by default
Since 6.3.1, URLs without an explicit setLastModificationDate() have no <lastmod>. Older tutorials and copy-pasted code assume the implicit now(). Don't restore that behavior — a fabricated lastmod on every URL is worse than no lastmod, and Google will start ignoring the field. Set it from updated_at and mean it.
Relative URLs resolve against config('app.url')
Url::create('/home') becomes absolute using app.url. That means a wrong APP_URL in production yields a sitemap full of http://localhost entries — and the file still writes successfully. Validate the first generated sitemap by eye every deploy; there's no error to catch this.
Priority 0 was silently dropped before 6.4.0
The old code used a falsy check, so setPriority(0) rendered nothing. Fixed in 6.4.0, but if you're pinned below it, use 0.1 or upgrade. Relatedly, 0.0 and 1.0 render as 0.0 / 1.0, not 0 / 1.
Links are no longer sorted (6.0.1)
If a test asserts line order or a diff-based deploy check complains, that's why. If you want ordering, sort your collection before adding it — the package won't do it for you.
maxTagsPerSitemap() alone is not enough
In manual mode it splits files but writes no index. In crawl mode, combining it with writeToFile(fn (Url $url) => ...) produces numbered files per group (sitemap-blog_0.xml, sitemap-blog_1.xml) — all listed in the index only if you also called sitemapIndexPath(). Forgetting the index leaves orphaned sitemaps that nothing links to.
Disk writes need the right path semantics
writeToFile(public_path('sitemap.xml')) writes to a filesystem path; writeToDisk('public', 'sitemap.xml') writes through Laravel's filesystem, so it respects your filesystems.php config and works with S3. Mixing them up — e.g. passing public_path() to writeToDisk — produces nested storage/app/public/var/www/... paths. If you serve from a CDN, writeToDisk is the one you want.
Memory is held in full unless you chunk
getSitemap() and manual add() accumulate every tag in memory before rendering. A 100k-URL site will spike. Mitigations, in order of preference: cursor() instead of get(); maxTagsPerSitemap() to split; generate per-section sitemaps in separate queued jobs.
The crawler's temp directory must be writable
The package depends on spatie/temporary-directory. In containerized or read-only-filesystem environments you'll get obscure exceptions mid-crawl. Ensure storage/app (or sys_get_temp_dir()) is writable, and bump guzzle_options['timeout'] if your pages are slow — the defaults will drop URLs under load.
Filter before rendering, not in the closure
If you need to remove already-added tags (e.g. unpublished models fetched in bulk), 5.9.1 added filter(). Cleaner than rebuilding the sitemap:
$sitemap->filter(fn (Url $url) => ! str_contains($url->path(), '/draft'));
Duplicate URLs are not deduplicated
Crawling with query strings, trailing slashes, or HTTP/HTTPS variants yields near-duplicate entries. Normalize at the source: shouldCrawl for the crawler, and ->unique('slug') / distinct() in your query for manual mode. Watch for http:// ↔ https:// and www redirects — the crawler follows redirects and records the destination, which is usually what you want, but only if your canonical URLs agree with it.
Image title and license are not URLs (6.2.3)
Before the fix, some image fields were validated as URLs and would throw. If you're generating image sitemaps on an older version, upgrade rather than working around it.
Extension points worth knowing
configureCrawler() — inject Guzzle options, headers, auth cookies, concurrency.execute_javascript in config — routes through Browsershot; only enable it if you genuinely have a client-rendered site, since it's orders of magnitude slower.Sitemapable — the cleanest seam. Keep toSitemapTag() side-effect free and cheap (no count() queries, no HTTP calls); it runs once per row.Version pinning notes
The package tracks spatie/crawler majors (v7 → v8 at 7.0.0). If you're on Laravel 11+ and a recent sitemap version, you're fine; if you're straddling a crawler upgrade, read the crawler changelog too — the sitemap package's own release notes usually just say "allow crawler vN".
How can I help you explore Laravel packages today?