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

Sitemap Bundle Laravel Package

werkspot/sitemap-bundle

Werkspot Sitemap Bundle is a Symfony bundle for generating XML sitemaps, helping search engines discover your application's URLs. It provides sitemap generation and routing integration for Symfony projects.

View on GitHub
Deep Wiki
Context7

Getting Started

Reality check first: werkspot/sitemap-bundle is a Symfony bundle, not a Laravel package — there is no service provider, no config/sitemap.php to publish, and no facade. If you're in a Laravel app, spatie/laravel-sitemap is the tool you actually want; read no further unless you're maintaining a Symfony service, a shared package, or a Laravel app that consumes a Symfony sitemap over HTTP.

Assumptions for v7.0.0: PHP 8.2+ (8.5 supported), Symfony 7.4 or 8.x. Symfony 6.4 LTS and 7.0–7.3 are no longer supported — see Gotchas.

  1. composer require werkspot/sitemap-bundle:^7.0
  2. Register the bundle in config/bundles.php (there is no Flex recipe, so this is manual):
    Werkspot\Bundle\SitemapBundle\WerkspotSitemapBundle::class => ['all' => true],
    
  3. Import the routes — the bundle ships the controller, so you generally write no routing yourself:
    # config/routes.yaml
    werkspot_sitemap:
        resource: '@WerkspotSitemapBundle/Resources/config/routing.xml'
    
  4. Open vendor/werkspot/sitemap-bundle/src/Sitemap/SitemapPageInterface.php before writing anything. It's the entire contract — page count, entries for a page, change frequency, priority, last modification — and it's small enough to read in thirty seconds. Every question about this bundle is answered by that file plus the compiler pass that collects tagged services.

First use case: one tagged service per content type, then curl localhost:8000/sitemap.xml.

// src/Sitemap/ProductSitemap.php
namespace App\Sitemap;

use Werkspot\Bundle\SitemapBundle\Sitemap\SitemapPageInterface;

final class ProductSitemap implements SitemapPageInterface
{
    private const PER_PAGE = 5000;

    public function __construct(private ProductRepository $products) {}

    public function getNumberOfPages(): int
    {
        return (int) ceil($this->products->countPublished() / self::PER_PAGE);
    }

    // ...remaining interface methods delegate to the repo for the given $page
}
# config/services.yaml
services:
    _defaults: { autowire: true, autoconfigure: true }

    App\Sitemap\:
        resource: '../src/Sitemap/'
        tags: ['werkspot.sitemap.page']

Add Sitemap: https://example.com/sitemap.xml to robots.txt and you're done.

Implementation Patterns

One page service per content type, not one service for everything. ProductSitemap, CategorySitemap, BlogSitemap, CmsSitemap. Each is independently testable, independently cacheable, and the bundle assembles the sitemap index for you when total pages exceed one. This also gives you a natural fault boundary: when the blog query starts timing out at 3am, you know which service to look at.

Auto-tag via _instanceof instead of per-service tags. A silent missing tag is the single most common failure mode (the page simply doesn't appear, with no error). This removes the class of bug entirely:

services:
    _instanceof:
        Werkspot\Bundle\SitemapBundle\Sitemap\SitemapPageInterface:
            tags: ['werkspot.sitemap.page']

Paginate in SQL, not in PHP. getEntries($page) should map to LIMIT/OFFSET (or keyset pagination on (updated_at, id)) against a published/visible scope. Never findAll() then array_chunk. For a 400k-product catalogue, OFFSET gets slow deep in the set — keyset pagination keyed off the page number is worth the extra code past ~50k rows.

Cache the expensive part, not the XML. Two options:

  • HTTP layer: a kernel.response listener or #[Cache(...)] on your own controller, public, s-maxage=3600. Cheapest, and lets nginx/Varnish/CDN do the work. Invalidate on publish via a cache tag or a version prefix in the URL.
  • Data layer: wrap each page service in a decorator that caches the entry arrays in a Symfony cache pool keyed by (service, page, catalogueVersion).

Prefer the HTTP layer. The XML is cheap to write; the queries are not.

Testing pattern. Assert on the DOM, not on strings, and assert counts as well as presence — a silent truncation is worse than a missing page:

$client->request('GET', '/sitemap.xml');
$xml = new \DOMDocument();
$xml->loadXML($client->getResponse()->getContent());
$this->assertSame(
    $repository->countPublished(),
    $xml->getElementsByTagName('url')->length
);

Unit-test each page service against a seeded DB: page count is correct, page 1 and page N contain exactly the expected IDs, no duplicates across pages, no unpublished rows leak.

Warm on deploy. Sitemap generation is read-heavy and public. Add a deploy step (or a Messenger job) that hits /sitemap.xml?warm=1 or invokes your page services directly so the first Googlebot hit isn't against a cold pool. If you generate via CLI, see the router.request_context gotcha below.

Localised / multi-domain. One page service per locale, each emitting absolute URLs with its own host. Don't rely on the request host for the entries — take the base URL from config so CLI generation and CDN-fronted requests produce identical output.

Extension points worth knowing: the controller is a normal service, so you can decorate it, override the route with your own controller that consumes the same tagged iterator, or hand-roll /sitemap-products.xml per section if the built-in index semantics don't match your Search Console setup.

Gotchas and Tips

The v7 breaking change is a hard version floor. Symfony <7.4 is dropped. If you're on 6.4 LTS (still the most common production target) or 7.0–7.3, Composer will refuse to install and, crucially, the conflict message points at the bundle rather than at Symfony. Pin werkspot/sitemap-bundle:^6.0 and stay there until your Symfony upgrade lands. Don't let a routine composer update drag this in behind your back — check composer why-not werkspot/sitemap-bundle 7.0 if resolution fails after an unrelated bump.

PHP 8.5 support in the changelog does not mean PHP 8.5 is required. It means no deprecations, no platform conflict. But confirm your own config.platform.php in composer.json — if you pin that to 8.1, Composer evaluates constraints against 8.1 and you'll get a confusing failure.

The bundle does not cache or set cache headers. Out of the box the controller is a normal Symfony response. You will get Cache-Control: no-cache, private from the framework defaults and Googlebot will re-hit your DB every time. Add explicit headers via a response listener; this is the highest-leverage five lines in the whole integration.

Dev vs prod routing surprises. bin/console debug:router | grep sitemap first when the route 404s. Trailing-slash redirects and APP_ENV-specific route loading are the usual culprits.

Debug a missing page with the container, not with code. bin/console debug:container --tag=werkspot.sitemap.page shows exactly which services were collected and in what order. If your page isn't listed, it's a tagging problem — 90% of "my sitemap is empty" reports.

Entry order is collection order. The sitemap index follows the order the compiler pass happened to collect services in — effectively filesystem/service-definition order, which is not stable across refactors. If ordering matters for your consumers, sort the array you return from getEntries() explicitly. Never assume.

Zero pages produces an empty <urlset>, which is invalid per the sitemap spec. Search Console reports it as an error and it can poison your index. Guard getNumberOfPages() to return 0 and be aware of what the bundle does with it — or return a single meaningful entry (homepage) for thin catalogues.

lastmod must be a full W3C datetime with an offset. Y-m-d\TH:i:sP, not Y-m-d. Doctrine datetime columns come back in the app's default timezone — convert explicitly with an immutable DateTimeImmutable in UTC. Fudging it to "now" on every request is the fastest way to teach Google to ignore your lastmod entirely.

Respect the 50,000 URL / 50 MB limits with headroom. Aim for 5,000–10,000 URLs per page. It keeps individual responses fast and cacheable, keeps memory bounded, and means a single slow query doesn't stall the whole index. The XML must be uncompressed in the file — gzip at the web server, not in the output.

<priority> is ignored by Google. Set it once, sensibly, and stop. changefreq too. Time spent tuning these is time not spent on lastmod accuracy and on making sure 404s and redirects never enter the sitemap.

XML escaping bites with query-string URLs. & in filter URLs must be escaped — let the XML writer do it rather than string-concatenating, and rawurlencode path segments yourself. A single unescaped ampersand invalidates the entire document, and browsers render the parse error while Google reports "couldn't read sitemap".

CLI generation and reverse proxies both corrupt absolute URLs. The controller builds <loc> from the request context. Behind a CDN or when warming from a console command, that's the internal host or localhost. Fix with framework.trusted_proxies + trusted_headers for web requests, and router.request_context (or an explicit base-URL parameter in your own controller) for CLI. Verify by diffing a curl'd sitemap against a CLI-generated one in CI.

Don't put this route behind your app firewall. If access_control covers ^/, the sitemap returns a login redirect and Search Console silently fails to fetch. Explicitly allow ^/sitemap before the catch-all rule.

URL/canonical consistency is the whole point. Every <loc> must be the canonical, indexable, 200-status, non-redirecting version of the page. Trailing-slash mismatches and http vs https are the classic own-goals — assert in a test that no <loc> in the output resolves to anything other than 200 at its final URL.

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.
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels