Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Sitemap Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Crawl mode — SitemapGenerator walks your live site over HTTP. Zero maintenance, but slow, network-dependent, and it only sees what an anonymous visitor sees.
  2. Manual mode — 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.

Implementation Patterns

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.

Gotchas and Tips

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.
  • Custom render views — the package renders via Blade; you can publish/override the views if you need namespaced extensions not covered by the tag objects.
  • 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".

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo
bernskioldmedia/laravel-harvest
bernskiold/laravel-data-scrubber
bernskiold/laravel-currency-converter
bernskiold/laravel-campaign-monitor
bernskioldmedia/laravel-autotranslate
siberfx/telegram-bot
roy-sandip/laravel-view-extended
gregsphotography/site-builder-core
roy-sandip/laravel-view
anodyne/blade-fluentui-icons
webx-ui/module-catalog-collections