tomvondracek / bolt-sitemap
Bolt CMS extension that serves a spec-compliant XML sitemap index: per-locale hreflang, slugless singletons, static routes, images, removal-aware lastmod, ETag caching and no XSLT.
Package info
github.com/Vondry/bolt-sitemap
Type:bolt-extension
pkg:composer/tomvondracek/bolt-sitemap
Fund package maintenance!
Requires
- php: >=8.2
- ext-dom: *
- ext-libxml: *
- symfony/polyfill-intl-idn: ^1.43
- symfony/polyfill-php84: ^1.43
- twig/twig: ^3.30
Requires (Dev)
- appolodev/bolt-seo: ^2.3
- bolt/core: ^6.1.10
- phpstan/extension-installer: 1.4.3
- phpstan/phpstan: 2.2.16
- phpstan/phpstan-deprecation-rules: 2.0.5
- phpstan/phpstan-phpunit: 2.0.21
- phpstan/phpstan-strict-rules: 2.0.12
- phpunit/phpunit: ^11.5.57
- rector/rector: 2.6.7
- symplify/easy-coding-standard: ^13.3.11
Suggests
- appolodev/bolt-seo: Pages editors mark noindex or give another canonical URL in bolt-seo are left out of the sitemap, so the sitemap never contradicts the pages' own robots and canonical tags.
Provides
None
Conflicts
- bolt/core: <6.0 || >=7.0
- bolt/sitemap-extension: *
Replaces
None
This package is auto-updated.
Last update: 2026-10-27 17:05:19 UTC
README
Serves a sitemap index at /sitemap.xml for a Bolt 6 site, generated from its content and routes, following the
sitemaps.org protocol 0.9 and Google's image and hreflang extensions. It
replaces bolt/sitemap-extension. Without any theme override it gets these right:
- one
<url>per page and language, each with the complete, reciprocal set ofhreflangalternates, including itself andx-default - singletons at their slugless page (
/kontakt, not/kontakt/kontakt-2plus a duplicate listing entry), and their alternates too. A singleton with its ownrecord_route(such asprivacy_localewith a fixed path) keeps that route. - listing pages with alternates on the listing route (
/en/clanky, not a record's link with its slug cut off) - static pages: the routes in
config/routes.yamlthat render a template with Bolt'sTemplateController, together with their<name>_localetwins. Nothing has to be listed by hand. - the homepage record as
/, listed once - honest
<lastmod>:- a record's modification date, in UTC;
- for a listing or a child sitemap, also the last time a record was added or removed;
- no date at all where none is known (no
now, and no template file times, which are deploy times)
- no
<changefreq>,<priority>,image:titleor XSLT. Image andxhtmlnamespaces are declared only where they are used. - with bolt-seo installed (optional), pages it marks
noindexor gives another canonical URL are left out - absolute URLs on the canonical host (
general/canonical), percent-encoded, with Punycode hosts - a sitemap index with one child sitemap per ContentType, split at 10,000 URLs, so Search Console reports coverage per segment
application/xml; charset=UTF-8,X-Robots-Tag: noindexand a content-hash ETag (304 Not Modified). The XML is kept in Bolt's cache until content changes.- optionally, a readable view for people who open the sitemap in a browser, made without XSLT: a stylesheet, a script rendering a table with links, or your own files
Requirements
- Bolt CMS
^6.0, PHP>=8.2with thedomandlibxmlextensions
Installation
composer remove bolt/sitemap-extension # the two can't be installed together: both serve /sitemap.xml
composer require tomvondracek/bolt-sitemap
bin/console extensions:configure --with-config
bin/console cache:clear
extensions:configure copies the routes and the @bolt-sitemap Twig namespace into the project
(config/routes/extension_bolt-sitemap.yaml, config/packages/extension_bolt-sitemap.yaml). --with-config copies
the configuration to config/extensions/tomvondracek-sitemap.yaml.
Point robots.txt at the index and submit it in Google Search Console, Bing Webmaster Tools and Seznam Webmaster:
Sitemap: https://example.com/sitemap.xml
Remove a static public/sitemap.xml if there is one: Apache and nginx serve existing files before Symfony gets the
request. For the same reason, don't add an nginx location for sitemap*.xml.
What is listed
/sitemap.xml is a <sitemapindex> that points to these child sitemaps:
| File | Contents |
|---|---|
/sitemap-main.xml |
The homepage, every singleton with a page, and the static routes |
/sitemap-<slug>.xml |
A ContentType: its listing page and its published records. Past chunk_size URLs it continues in /sitemap-<slug>-2.xml, -3, … |
/sitemap-taxonomy-<slug>.xml |
The pages of a taxonomy named in taxonomies |
A child sitemap without URLs is left out of the index.
| Page | Listed | URL |
|---|---|---|
Homepage record (homepage: homepage or pages/5) |
always, even when viewless |
/, /en/, … |
No homepage setting |
yes, without <lastmod> |
/ |
homepage: <contenttype> (a listing) |
no: the listing page is the canonical one | — |
| Singleton | unless viewless or in exclude_contenttypes |
its listing route: /kontakt, /en/kontakt |
Singleton with its own record_route |
the same | that route, as is |
| Record | published; unless the ContentType is viewless or in exclude_contenttypes |
its record_route with its slug in each locale |
| Listing page | unless listings: false, a singleton, viewless_listing, hide_listing_from_xml_sitemap or in exclude_listings |
/clanky, /en/clanky |
| Static route | TemplateController routes without parameters, unless static_routes: false or in exclude_static_routes |
the route, plus its {_locale} twin of the same template for the other languages |
| Taxonomy page | for each taxonomy in taxonomies |
/<singular slug>/<term> |
A page is listed in every language of its ContentType's locales, each language as its own <url>. URLs in the
default locale have no prefix (remove_default_locale_on_canonical, on by default). The x-default alternate is the
default locale's URL.
<lastmod> is:
- a record: its modification date;
- a listing page: the later of its newest record's modification and the last time a record was added or removed;
- a static route: the date in
static_route_lastmod, otherwise none; - the homepage without a record, and taxonomy pages: none;
- a child sitemap in the index: the later of its newest
<lastmod>and the last time a URL was added to it or removed from it.
The times of additions and removals are kept in var/data/tomvondracek-sitemap.json, outside the cache, so
cache:clear doesn't reset them. A set of URLs seen for the first time has no known change date, so only the URLs'
own dates count. No date is ever invented.
Images
By default a record lists its first image, the one Bolt's |image filter returns. To list more, name the image and
imagelist fields per ContentType:
images: fields: products: [image, gallery, technical_drawing]
An empty list lists none (e.g. homepage: [] when its first image is a decorative badge). At most 1,000 images
per URL, each once. Only list images that are content, not logos or icons.
With bolt-seo (optional)
The extension works on its own and never requires appolodev/bolt-seo. Without it, every published page with a URL is listed. With it installed, nothing needs to be configured; the sitemap simply follows what editors set there.
What you get: the sitemap and the pages never send search engines conflicting signals. A page that
bolt-seo renders with noindex (or none), or with a canonical URL pointing to another page, is left out of the
sitemap:
- an editor sets Robots: noindex in a record's SEO tab, e.g. for a thank-you page, and its URL leaves the sitemap;
- an editor points a record's canonical URL at another page, e.g. a duplicate article, and only the original is listed;
override_defaultmarks a listing page or a static routenoindex(portfolio: theprojektylisting), and it leaves the sitemap too;- the decision is made per language: when only the English version is
noindex, only that URL is dropped, along with its hreflang alternates. The Czech page stays.
Without bolt-seo you would have to repeat each of these in exclude_* here, and keep the two in sync by hand. To
list every page anyway, set seo: { respect_noindex: false }.
What the sitemap reads from bolt-seo. These are files and values, not bolt-seo's PHP classes, so bolt-seo is never loaded by the sitemap:
| What | Used for |
|---|---|
the class Appolo\BoltSeo\Extension exists |
detecting that bolt-seo is installed; otherwise everything else is ignored, including a leftover config file |
config/extensions/appolo-boltseo.yaml (+ _local) |
bolt-seo's config |
default.robots |
the site-wide robots value |
override_default.<key>.robots, .canonical, .locales.<locale> |
overrides by route name (homepage also covers homepage_locale) or, for a listing page, by ContentType slug |
a record field named seo, holding JSON with robots and canonical |
a record's own values, in each language |
They are combined the way bolt-seo combines them: the override first, then the record's field, then the default. A
canonical URL in default is ignored on purpose: it would point every page at one address.
BoltSeoContractTest keeps the two in step. For each case it runs bolt-seo's own Seo class to render the page's
robots and canonical tags, and checks that the sitemap reaches the same decision. CI runs it against the newest
bolt-seo release, against bolt-seo's main branch, and every week, plus once without bolt-seo installed. If bolt-seo
changes how it decides, that test fails.
Configuration
config/extensions/tomvondracek-sitemap.yaml, every key optional. A tomvondracek-sitemap_local.yaml next to it
overrides single values.
listings: true # listing pages of ContentTypes exclude_contenttypes: [] # ContentTypes whose records are left out exclude_listings: [] # ContentTypes whose listing page is left out taxonomies: [] # e.g. [categories, tags] static_routes: true # TemplateController routes from config/routes.yaml exclude_static_routes: [] # route names static_route_lastmod: {} # route name => date, e.g. { about: 2026-10-01 } main_segment: main # name of /sitemap-<name>.xml with homepage, singletons, static routes chunk_size: 10000 # most URLs per child sitemap (the protocol allows 50000) max_age: 3600 # seconds crawlers, proxies and the server cache keep a sitemap; 0 = no caching hreflang: x_default: true images: enabled: true fields: {} # ContentType => image/imagelist fields; default: the first image seo: respect_noindex: true human_view: stylesheet: false # true = /sitemap.css, or the URL of your own CSS script: false # true = /sitemap-ui.js, or the URL of your own script templates: index: sitemap-index.xml.twig urlset: sitemap-urlset.xml.twig stylesheet: sitemap.css.twig script: sitemap-ui.js.twig
An unknown key or a value of the wrong type is an error that names the problem. An empty value means the default.
Showing the sitemap in a browser
Crawlers read the XML and ignore everything below. By default a browser shows the raw XML. Chrome removes XSLT on November 17, 2026, so the extension never uses it. There are two opt-in alternatives:
human_view: { stylesheet: true }adds<?xml-stylesheet type="text/css" href="/sitemap.css"?>: a styled list of URLs and dates. No JavaScript, but the URLs are not links. It also adds an XHTML viewport<meta>, without which phones lay the XML out at desktop width.human_view: { script: true }adds an XHTML<script>as the first element. It renders a table with links, images andhreflangpills, 1,000 rows at a time. With a strict Content Security Policy, allow/sitemap-ui.js.
Both can be on at once, and either can be the URL of your own file instead of true
(stylesheet: /theme/my-theme/sitemap.css). To change the bundled ones, copy sitemap.css.twig or
sitemap-ui.js.twig into your theme. The sitemap is the same XML for every Accept header and user agent: there is
no content negotiation.
Customizing
Templates
The XML is rendered from Twig templates, looked up in your theme first. To change one, copy it from the extension's
templates/ into the theme (or name another file under templates:):
| Template | Variables |
|---|---|
sitemap-index.xml.twig |
sitemaps (loc, lastmod), stylesheet, script |
sitemap-urlset.xml.twig |
urls (loc, lastmodW3c, alternates, images), hasImages, hasAlternates, stylesheet, script |
Nothing may precede <?xml: start the template with {#- … -#} or {%- … -%}. The response also strips a BOM or
blank lines before the declaration.
Adding or removing URLs
For most changes an event listener is simpler than a template. SitemapUrlsEvent is dispatched for every child
sitemap before it is rendered:
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Tomvondracek\Sitemap\Event\SitemapUrlsEvent; use Tomvondracek\Sitemap\Segment\Segment; use Tomvondracek\Sitemap\Url\SitemapUrl; #[AsEventListener] final class SitemapListener { public function __invoke(SitemapUrlsEvent $event): void { if ($event->segment->type === Segment::MAIN) { $event->addUrl(new SitemapUrl('https://example.com/cenik')); } $event->setUrls(array_filter($event->getUrls(), static fn (SitemapUrl $url): bool => ! str_contains($url->loc, '/interni/'))); } }
SitemapUrl::cluster() builds the per-language URLs with their alternates from a locale => URL map. URLs must be
absolute and on the site's host. The result is cached, so a listener must not depend on the request.
Caching
Each sitemap is kept for max_age seconds in Bolt's cache.app pool, tagged tomvondracek_sitemap, per host and
config:
- Saving, deleting or changing the status of content drops it at once: records, field values and translations, taxonomies, relations or media, whether changed in the editor, with bulk actions or through the API.
- Bolt's timed publishing and depublishing is a plain SQL update without events. It shows up once the cached copy expires.
- Logged-in users and debug mode always get a fresh sitemap.
- To drop only the sitemaps:
bin/console cache:pool:invalidate-tags tomvondracek_sitemap.
Browsers and crawlers revalidate with the ETag, which changes on additions, edits and removals alike. There is
deliberately no Last-Modified header: taken from the newest <lastmod>, it would hide removals.
Migrating from bolt/sitemap-extension
composer remove bolt/sitemap-extension, then install this extension as above. Afterwards check that the other extensions'config/packages/extension_*.yamlandconfig/routes/extension_*.yamlare still there: when Bolt's post-update script can't boot the container in the middle of the swap, it removes them. Runbin/console extensions:configureagain to restore them.- Delete the theme's copy of the sitemap template (e.g.
extensions/_sitemap.xml.twig) andconfig/extensions/bolt-sitemapextension.yaml. - Carry the old options over:
| bolt/sitemap-extension | here |
|---|---|
show_listings: true |
listings: true (the default) |
limit |
chunk_size (more records are split, not cut off) |
taxonomies |
taxonomies |
exclude_contenttypes, exclude_listings |
the same |
hide_listing_from_xml_sitemap (ContentType) |
still honoured |
templates.xml |
templates.urlset (only for a real customization) |
templates.xsl, /sitemap.xsl |
gone; see Showing the sitemap in a browser |
| a hand-written list of static routes | static_routes: true (the default) |
record_route: pagebinding |
still honoured |
/sitemap.xml is now an index rather than a single list. Search engines follow it, so a submitted /sitemap.xml
keeps working.
Checking a deployment
For example:
curl -s https://example.com/sitemap.xml | head -c 5 | xxd # 3c3f 786d 6c: <?xml curl -sI https://example.com/sitemap.xml # 200, application/xml, X-Robots-Tag, ETag curl -s https://example.com/sitemap-main.xml | xmllint --noout -
The schemas for a full validation are in tests/Fixtures/xsd/ (sitemap-all.xsd, siteindex-all.xsd).
Development
composer install vendor/bin/phpunit vendor/bin/ecs check src tests vendor/bin/phpstan analyse --memory-limit=1G vendor/bin/rector process --dry-run
To try it in a Bolt project from a local checkout:
composer config repositories.bolt-sitemap '{"type":"path","url":"../bolt-sitemap"}'
composer remove bolt/sitemap-extension
composer require tomvondracek/bolt-sitemap:@dev
bin/console extensions:configure --with-config
Layout
| Path | What |
|---|---|
src/Controller/SitemapController.php |
Routes: /sitemap.xml, /sitemap-<name>.xml, /sitemap.css, /sitemap-ui.js |
src/SitemapGenerator.php |
Index and child sitemaps: rendering, <lastmod> of each child, caching |
src/Segment/ |
The segments of the index and their chunks (SegmentRegistry); the URLs of each (SegmentUrls) |
src/Content/ |
Bolt content: records (ContentSource, Doctrine), homepage, routes of records and listings, static routes, images, bolt-seo rules |
src/Url/ |
URLs: per-locale route URLs, canonical origin, normalization, the SitemapUrl value |
src/State/StateStore.php |
When a set of URLs last changed, for removal-aware <lastmod> |
src/SitemapResponse.php, src/EventSubscriber/ |
Headers, ETag, 304; private when a cookie is set |
src/SitemapCache.php, src/EventListener/ContentChangeListener.php |
Server-side cache; dropped on content changes |
src/SitemapConfig.php, src/SitemapConfigLoader.php |
Typed config with defaults, loaded like Bolt's ConfigTrait |
templates/ |
Shipped templates (@bolt-sitemap/…) |
License
MIT