kphoen/sitemap-bundle
Symfony bundle for generating XML sitemaps from your application’s routes and URLs. Configure sitemap entries and produce sitemap.xml to help search engines discover and index your content.
First, calibrate expectations: this is a Symfony bundle, not a Laravel package, and it is archived. There is no service provider, no config publish tag, no facade, and no composer require path that will work cleanly on a modern Laravel stack. For a Laravel developer there are exactly two ways to "leverage" it day-to-day:
If you want a drop-in for a new Laravel feature, use spatie/laravel-sitemap or write ~30 lines yourself. Do not make this a production dependency.
Where to look first, in this order:
DependencyInjection/Configuration.php — the source of truth for the kphoen_sitemap config root and every option that actually exists in your pinned version. Documentation lies; this file doesn't.Sitemap/ — the provider contract (a SitemapProviderInterface with a populate(...)/addUrl(...) style method). Everything you build hangs off this interface.Resources/config/services.xml plus any CompilerPass/ directory — the exact tag name providers must carry. Guess wrong and you get silent emptiness, not an error.Legacy Symfony bootstrap (only if you're in that app):
// AppKernel.php
new KPhoen\SitemapBundle\KPhoenSitemapBundle(),
Then create one provider per content type, register it as a service, and tag it with the tag found in the compiler pass. Verify with:
curl -s localhost:8000/sitemap.xml | xmllint --noout -
The first use case to get working is static routes only — one provider emitting /, /about, /contact. Get valid XML on screen before you touch Doctrine. Doctrine integration is where the memory and query-shape problems live, and you want a known-good baseline to diff against when URLs mysteriously vanish.
The Laravel equivalent of the entire bundle is roughly this:
// app/Console/Commands/GenerateSitemap.php
SitemapGenerator::create(config('app.url'))
->hasCrawled(fn (Url $url) => ! str_starts_with($url->path(), 'admin'))
->writeToFile(public_path('sitemap.xml'));
Schedule it nightly, serve the static file, never generate at request time.
Provider-per-content-type, mirrored as tagged sources. The bundle's core idea is many small contributors feeding one aggregator. Reproduce that in Laravel instead of writing one god-generator:
interface SitemapSource { public function urls(): iterable; }
// AppServiceProvider::boot()
$this->app->tag([PagesSource::class, PostsSource::class, ProductsSource::class], 'sitemap.source');
// builder
foreach ($this->app->tagged('sitemap.source') as $source) {
foreach ($source->urls() as $url) { /* add to writer */ }
}
Tagged collections are the Laravel analogue of Symfony's !tagged argument. The payoff is that "what is in my sitemap" becomes an explicit, testable list, and splitting into a sitemap index later is nearly free — one child sitemap per source.
Query discipline on the Doctrine side. Never hydrate full entities to read a slug and a timestamp:
$q = $repo->createQueryBuilder('p')
->select('p.slug', 'p.updatedAt')
->getQuery();
foreach ($q->toIterable() as $row) { /* addUrl */ }
$em->clear(); // periodically, or memory climbs
A 20k-row findAll() will exhaust memory. The Laravel mirror is ->select('slug','updated_at')->lazy() or ->chunkById(500, ...) instead of ->get().
Stream, don't buffer. The bundle emits XML through a Symfony response and the underlying sitemap-php generator buffers in memory. For large sites write directly to a file handle (Symfony) or use response()->stream() (Laravel) so peak memory stays flat regardless of URL count.
Switch to an index before you hit the ceilings. Hard limits are 50,000 URLs and 50 MB uncompressed per file. Once either is in sight, emit a <sitemapindex> pointing at /sitemap-posts.xml, /sitemap-pages.xml, etc. Because each provider is already independent, this is a loop change, not a rewrite.
Generate on a schedule; serve statically. The single most common production mistake in this space is a controller that runs queries per request. Instead:
$schedule->command('sitemap:generate')->dailyAt('03:00');
or Cache::remember('sitemap.xml', now()->addHours(6), fn () => $xml). Then point robots.txt at it explicitly:
Sitemap: https://example.com/sitemap.xml
Integration tips.
.xml.gz, but in-app compression is rarely worth the debugging cost.xhtml:link alternates feature, the xmlns:xhtml declaration must live on the <urlset> element or the whole document is invalid.Archived means archived. No PHP 8 work, no Symfony 5/6 support, no security patches. composer require on a modern stack will fail resolution or drag you into a conflict chain. Pinning a dev-master commit is a liability, not a strategy. With 23 stars and zero dependents there is no community to answer questions — the source is the documentation.
Tag registration fails silently. Symfony boots happily with an untagged provider; nothing errors, your URLs simply don't appear in the output. Debug with:
bin/console debug:container --tag=<tag_name>
The Laravel mirror of this bug is forgetting the $this->app->tag(...) call in AppServiceProvider::boot(), or registering the tag after the consumer has already resolved the tagged iterator. Symptom is identical: an empty-but-valid sitemap.
Escaping and encoding bite late. Slugs with &, ', or non-ASCII characters must be XML-escaped and percent-encoded respectively. One bad slug produces a document that xmllint rejects — and crawlers reject it too, usually without telling you. Add a schema/uniqueness test to CI: parse the output, assert every <loc> is absolute and unique, assert no empty <urlset>.
lastmod must be W3C-8601. 2024-01-01 10:00:00 is not valid; 2024-01-01T10:00:00+00:00 is. Also, a lastmod that changes on every regeneration (because you used now()) teaches crawlers to distrust the field entirely. Derive it from real updated_at values only.
changefreq and priority are ignored by Google. The bundle supports them; Google has publicly disregarded both for years. Emit them if other engines matter to you, but do not spend time tuning values or building logic around them.
Canonicalisation is your job, not the bundle's. Duplicate URLs across providers (e.g. a product reachable via /products/x and /categories/y/x) will silently double your index. Deduplicate by canonical URL in the aggregator, and filter out non-200 routes, drafts, and anything behind auth before they reach the writer.
Empty results are worse than no sitemap. An empty <urlset> or a stale file left on disk after a failed job is a real failure mode. Wrap generation in a try/catch that writes to a temp path and only rename()s on success, and add a freshness alarm — SEO damage from a stale sitemap is delayed and hard to detect in application logs.
Extension points worth knowing: the provider interface (add your own sources), the compiler pass (order/priority of providers), and the response/controller (content type, caching headers). Everything else is config you'll likely outgrow. Copy those three ideas into an in-house generator and leave the bundle behind.
How can I help you explore Laravel packages today?