A bilingual public site usually carries four lists of the same URLs, and each one is an SEO surface. The routes file holds one. The sitemap builder holds a second. The layout that prints canonical and hreflang tags holds a third. The language switcher in the footer holds a fourth.
Nothing makes those four agree, and they come apart in a specific way. Someone adds a page, the route lands, and the other three get updated by whoever remembers. When they do not, nothing happens. No exception, no failing test, and the page answers 200 to every visitor who has its address. The consequence surfaces weeks later in Search Console, as a page nobody ever crawled or a hreflang tag pointing at a 404. That delay is what makes URL drift an SEO problem rather than a bug: by the time it is visible, the commit that caused it is months old.
We built our public site so those four lists cannot exist. There is one table, keyed by page, and everything a crawler sees is a projection of it. The site itself runs inside the same Laravel application as the product, in Blade, which is what makes a single table plausible in the first place.
31 page keys, two locales, 136 sitemap URLs on the day this was written. One expression decides every SEO surface of the site: the routes, the canonicals, the hreflang pairs, the x-default, the sitemap, the Open Graph card, the language switcher and the navigation. Fifty files in the application read it, and forty nine test files hold it in place.
The unit is a stable key. solutions.agencies is a page. Its French and English addresses are values.
Naming the page rather than the URL is what lets a language switcher point at the same page in the other language instead of falling back to the homepage. That fallback is the most common defect on multilingual sites, and it costs the visitor precisely the page they were reading.
The homepage slug is the empty string, which forces one decision to be explicit.
It returns null, never an empty string as a fallback. An empty string is the legitimate slug of the homepage, so using it as a failure value would silently point every missing page at /.
One group per locale, carrying that locale's literal prefix. A single {locale} group would have been shorter, and it would have answered 200 to every slug under every prefix: /en/notre-approche and /en/our-approach both serving the same page, two URLs per page per language. That is the duplicate content our own SEO product teaches its users to hunt, so the shape of the route file is pinned by a test rather than by a convention.
The routes do get names, marketing.fr.pricing and marketing.en.pricing. No view uses them. Views resolve addresses through the registry, because the registry is the thing that guarantees navigation, hreflang and sitemap agree.
alternates() walks the configured locales and returns only the pairs that exist.
Declaring a hreflang toward a page that does not exist is a crawl error, not a courtesy, so a missing pair is skipped rather than guessed. That single array then feeds three surfaces in the same request: the tags, the og:locale:alternate metas, and the footer language switcher. One read, so the robot and the visitor cannot be looking at different sites.
x-default is derived rather than declared. It points at whichever locale the root path serves when the visitor's Accept-Language names nothing we know, which makes it a property of the fallback rule instead of a fifth list to maintain.
Item pages are appended after that loop: article pages and the AI bot directory entries, each deriving its path from its index page rather than from a path built by hand. On the day this was measured that came to 136 URLs, 62 straight from the registry and 74 derived from it.
There is no priority and no changefreq anywhere in the output. Both have been ignored for years, and they survive in SEO folklore rather than in any engine's behaviour. Emitting them mostly suggests we are steering something.
lastmod is the field worth being strict about. The rule is not "pages that have a date in the database". It is "pages that already declare a dateModified to a robot", read from the same expression that renders the page. Two surfaces qualify today, the article page and the changelog. Static pages get nothing, because a lastmod recomputed on each request is wrong on each request, and that is exactly the kind of signal a search engine learns to discount. Emitting it is a net loss, not a neutral gain.
That rule is an enumeration, and an enumeration drifts, so it is held by parity in both directions.
The test fetches every registry page in both locales, parses the JSON-LD the page actually serves, and requires equivalence. A page that starts declaring a date without gaining a sitemap entry fails the suite, and so does the reverse.
