ultrono/laravel-sitemap
A lightweight Laravel package for generating XML sitemaps. Easily define URLs and metadata such as priority and change frequency, then output sitemap files or responses for search engines with minimal configuration and a simple, fluent API.
composer require ultrono/laravel-sitemapphp artisan vendor:publish --provider="Ultrono\Sitemap\SitemapServiceProvider" --tag="sitemap-config". Confirm the provider class name by checking the package's composer.json extra.laravel.providers entry or its README.src/SitemapGenerator.php — crawl-based generationsrc/Sitemap.php — building sitemaps by hand / from modelssrc/Tags/Url.php — the tag object (lastmod, changefreq, priority, alternates, images)config/sitemap.php — crawl defaults, output path, throttlinguse Ultrono\Sitemap\SitemapGenerator;
SitemapGenerator::create(config('app.url'))
->writeToFile(public_path('sitemap.xml'));
Note: this package is a fork/republish of the well-known spatie/laravel-sitemap, with the namespace swapped. The repository link isn't verifiable from the metadata, so treat the namespace (Ultrono\Sitemap) as authoritative and confirm the exact class names in src/ after composer require. Every pattern below maps 1:1 to the Spatie API it descends from.
Crawl-based (fastest to ship). Point the generator at your production URL and let it discover pages. Tune the crawler with configureCrawler:
SitemapGenerator::create('https://example.com')
->configureCrawler(fn ($crawler) => $crawler
->setMaximumDepth(4)
->setConcurrency(2)
->setDelayBetweenRequests(200))
->writeToFile(public_path('sitemap.xml'));
This is fine for marketing sites. It is not fine for large apps — it re-crawls every run and is HTTP-bound.
Model-based (the pattern you'll actually keep). Make your Eloquent models describe their sitemap entry by implementing the package's Sitemapable contract:
use Ultrono\Sitemap\Contracts\Sitemapable;
use Ultrono\Sitemap\Tags\Url;
class Post extends Model implements Sitemapable
{
public function toSitemapTag(): Url|string|array
{
return Url::create(route('posts.show', $this))
->setLastModificationDate($this->updated_at)
->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY)
->setPriority(0.8);
}
}
Then the whole sitemap is a composition:
Sitemap::create()
->add(Url::create('/')->setPriority(1.0))
->add(Post::published()->get())
->add(Page::all())
->writeToFile(public_path('sitemap.xml'));
add() accepts a single tag, a string/relative path, an array, a Collection, or any iterable of Sitemapable — which is what makes Post::all() work.
Sitemap index for large sites. Split by content type and emit an index:
SitemapIndex::create()
->add(Sitemap::create()->add(Post::all())->writeToFile(public_path('sitemap-posts.xml')))
->add(Sitemap::create()->add(Page::all())->writeToFile(public_path('sitemap-pages.xml')))
->writeToFile(public_path('sitemap.xml'));
Rich tags. Url exposes addImage(), addVideo(), addNews(), and addAlternate() (hreflang). Locale-aware sites usually build one sitemap per locale and cross-link with alternates.
Workflow: command + schedule. Define sitemap:generate in routes/console.php (or an invokable command), then $schedule->command('sitemap:generate')->dailyAt('03:00')->withoutOverlapping();. For anything bigger than a small site, dispatch generation to a queued job and write to a temp path before atomically moving into public/.
Integration tip: link it from robots.txt. A sitemap nobody references is a sitemap nobody fetches:
Route::get('robots.txt', fn () => response(
"User-agent: *\nAllow: /\nSitemap: ".url('sitemap.xml'),
)->header('Content-Type', 'text/plain'));
add(Post::all()) loads everything. Swap to Post::query()->chunk(1000, fn ($posts) => $sitemap->add($posts)) and reuse the same Sitemap instance.SitemapIndex — the package won't warn you, the crawlers just silently truncate.writeToFile() needs a writable absolute path. public_path() is right for production; use storage_path('app/sitemap.xml') and serve it via a route if your host mounts public/ read-only (common on containerised deploys).Url entries by default in every version. If your routes generate both /posts/1 and /posts/1/, you'll ship duplicate entries. Normalise in toSitemapTag() or filter before adding.Url::create('/about') is stored as-is; whether it's absolute depends on config. Prefer route() / url() for anything you're debugging at 2am.lastmod needs a Carbon instance. Passing a string works in some versions and not others — pass $model->updated_at directly.robots.txt by default. If pages are missing from a crawl, that is the first thing to check; also check for JS-rendered content, which the crawler will not execute.config/sitemap.php, run php artisan config:clear — otherwise your crawl settings silently do nothing on config:cache deployments.Sitemap in the test, call ->render(), and assert assertSee('https://...'). Filesystem assertions get flaky in parallel test runs.SitemapGenerator::create(...)->getSitemap() returns the built Sitemap before writing — the hook for post-crawl manipulation (filtering, adding tags, splitting) without re-crawling.How can I help you explore Laravel packages today?