The rules, in order of how hard they bite
Every rule exists because a real sitemap eventually violates it. In order:
- Only upcoming events count. Past shows 404 on the production router, confirmed against the real
error_404_log. An event is upcoming if its date is>= today. - A venue without an upcoming event is never listed. This is the whole ballgame: the sitemap undercounts rather than ever pointing Google at a 404. A wrong URL can never get in; a right one can only get left out.
- Venue matching is slug-normalized on both sides. Venue slugs are matched against the slugified event venue name, with a fallback attempt on the venue’s own name. Pseudo-venues sitting in event names match nothing and drop out — which is correct: those pages were noise anyway.
- Cities are derived; curated-only is a flag. By default any city with an upcoming event gets listed. Flip
cityIncludeDynamicoff to list only cities with a curated row. - Every slug passes
^[a-z0-9-]+$before it enters XML. Bad slugs are counted and reported, never emitted. <lastmod>means something. Artists get the latest upcoming tour date; venues and cities get their freshest upcoming event date. Only the sitemap index itself gets today.- Never generate from partial data. If any source file is missing or unparseable, the run fails loudly and writes nothing. Half a sitemap is worse than a stale one.
Wire it up
One config file, one CLI run, one cron line. No Composer, no framework, no config DSL:
// config.php - the whole surface
return array(
'base' => 'https://www.example.com', // no trailing slash
'dataDir' => __DIR__ . '/data', // where the JSON lives
'outDir' => __DIR__, // where XML is written
'cityIncludeDynamic' => true, // list event-derived cities with no curated row
'includeShows' => false, // /show/ segment: off until the router provably
// handles expired event URLs
'genres' => array('techno', 'house'),
'staticPages' => array('/' => '2026-10-16'),
'jsonReport' => true, // also write report.json next to the XML
// 'today' => '2026-10-16', // optional override for deterministic runs
);
SITEMAP_OUTDIR=/tmp/out php bin/generate-sitemaps.php config.php # one run
# 0 5 * * * php /path/to/bin/generate-sitemaps.php /path/to/config.php # cron
Input is four tolerant JSON shapes (djs.json, events.json, venues.json, cities.json) — bare arrays and single-key wrapper objects both load. If your site renders from MySQL, export into these shapes and keep the property that matters: the sitemap is built from exactly what the site serves. Submit /sitemap.xml once in Search Console; re-run on cron after every data sync — each run rewrites the full set atomically per file, so a partially written run can never publish a torn file.
Design decisions
| Decision | Why |
|---|---|
| JSON-native, no database | The PDO predecessor was retired: reading the site’s own export files meant zero collation fights, zero credentials in the script, and a data source guaranteed identical to what the site renders from. |
| Web runner and CLI share one library | The production endpoint is token-gated so a curl one-liner can refresh the set; the CLI is the same library behind a config file. No secrets ship in the repo. |
| Undercount, never 404 | A sitemap that sometimes lists a dead page is worse than one that lists fewer pages than exist. |
| Mojibake-proof slugification | Real venue data arrives double-encoded. Slugification tries iconv translit first and falls back to an explicit map that handles the double-encoded pairs. Output is guard-clean ASCII either way. |
| Zero dependencies | Plain PHP. No Composer, no framework, no config DSL — drops into shared hosting. |
What changed from production
The repo is production code with the private parts removed, and it says so plainly. The production web endpoint, its auth token, and the site’s data exports stay on the server; the bundled sample dataset stands in for them, and the demo pages serve that sample run verbatim. The rules, the slug guard, the report format, and the fail-loudly path are production code, unchanged.
Try it locally
No network, no API keys, no database:
php tests/run-tests.php # 47 contract checks - what CI runs
SITEMAP_OUTDIR=/tmp/out php bin/generate-sitemaps.php examples/sample-config.php
The 47 checks cover both directions: slug guard accepts and rejects, diacritics and mojibake slugification, JSON wrapper tolerance, the upcoming filter, the undercount-never-404 behavior, curated-only mode, the shows toggle, index composition, XML well-formedness, the production report format, and the fail-loudly path. Fixtures are built at runtime with dates relative to today, so the suite never goes stale.
Related projects
Frequently asked questions
What does segmented-sitemaps do?
It builds a segmented sitemap set — an index plus one file per segment — in which every listed URL is proven live by construction: only entities with upcoming events are listed, and every lastmod is derived from real data.
How does it prevent dead URLs in the sitemap?
A venue without an upcoming event is never listed. The library deliberately undercounts rather than ever pointing Google at a 404: a wrong URL can never get in, a right one can only be left out.
Does it need a database?
No. It is JSON-native: it reads the site’s own export files, so the sitemap is built from exactly what the site renders from. Tolerant loaders accept bare arrays and wrapper objects alike.
What happens if a source file is missing or broken?
The run fails loudly and writes nothing. Half a sitemap is worse than a stale one, so no partial output is ever published.
How do I run it on a schedule?
Wire the CLI runner to cron after each data sync; each run rewrites the full set atomically per file. Submit /sitemap.xml once in Search Console.
Messy data? That's the fun part.
Bryan Hamilton is a full-stack web engineer with 25+ years in PHP and JavaScript, available immediately for remote, hybrid or on-site roles in the NYC metro area.