Laravel SEO: one registry for every URL a crawler sees
A bilingual Laravel site usually keeps four lists of the same URLs: the routes, the sitemap, the hreflang tags and the language switcher. Nothing makes them agree, and when they drift nothing breaks, which is what turns URL drift into an SEO defect rather than a bug. Ours are one table of 31 page keys, projected into every SEO surface, with the parity held in both directions by tests.
Tarek Morgene (CTO, NessFlow) · · 13 min read
Laravel 13, Blade, two locales, no SEO package.
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.
A page is a key, not a URL
The unit is a stable key. solutions.agencies is a page. Its French and English addresses are values.
'solutions.agencies' => [
'fr-FR' => 'solutions/agences-seo',
'en-US' => 'solutions/seo-agencies',
],
'hub.engineering' => [
'fr-FR' => 'ingenierie',
'en-US' => 'engineering',
],
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.
public static function slug(string $key, string $locale): ?string
{
return self::map()[$key][$locale] ?? null;
}
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 /.
Routes are a projection of the table
The routes file contains no literal slug at all.
foreach (MarketingLocale::all() as $localeCode => $localeAttributes) {
Route::prefix($localeAttributes['prefix'])
->middleware([SetMarketingLocale::class.':'.$localeCode, CacheMarketingResponse::class])
->name('marketing.'.$localeAttributes['prefix'].'.')
->group(function () use ($localeCode) {
Route::get(SlugRegistry::slug('pricing', $localeCode), PricingController::class)
->name('pricing');
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.
it('refuses one locale slug under another locale prefix', function () {
$this->get('/en/notre-approche')->assertNotFound();
$this->get('/fr/our-approach')->assertNotFound();
});
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.
Canonical, hreflang and x-default: three SEO tags, one read
The layout resolves two values, once, at the top.
$canonical = $canonicalOverride ?? SlugRegistry::url($pageKey, $marketingLocale);
$alternates = $alternatesOverride ?? SlugRegistry::alternates($pageKey);
Those two values are everything the head prints on the subject.
<link rel="canonical" href="{{ $canonical }}">
@foreach ($alternates as $hreflang => $url)
<link rel="alternate" hreflang="{{ $hreflang }}" href="{{ $url }}">
@endforeach
alternates() walks the configured locales and returns only the pairs that exist.
public static function alternates(string $key): array
{
$alternates = [];
foreach (MarketingLocale::all() as $code => $attributes) {
$url = self::url($key, $code);
if ($url !== null) {
$alternates[$attributes['hreflang']] = $url;
}
}
return $alternates;
}
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 <link rel="alternate"> 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.
The SEO sitemap is a loop, not a list
foreach (SlugRegistry::keys() as $key) {
foreach (MarketingLocale::codes() as $locale) {
$url = SlugRegistry::url($key, $locale);
if ($url === null) {
continue;
}
$entries[] = [
'url' => $url,
'alternates' => SlugRegistry::alternates($key),
'lastmod' => $this->pageLastmod($key, $locale),
];
}
}
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.
$declared = declaredDateModified(
(string) $this->get(SlugRegistry::path($key, $locale))->assertOk()->getContent(),
);
$sitemap = $lastmods[$url] ?? null;
// The fact must coincide, not its precision of writing.
$left = $declared === null ? null : substr($declared, 0, 10);
$right = $sitemap === null ? null : substr($sitemap, 0, 10);
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.
It also carries a witness, which is the part that took a second pass to get right.
expect($dated)->toBe(1, 'No registry page declares a date: the test proves nothing.');
"No divergence" is equally true of a sitemap with no lastmod at all and pages that declare none. The fixture seeds exactly one dated page, so the test insists on finding exactly one before it is allowed to conclude.
The defect a single source does not prevent
Item pages cannot live in the registry. There are as many of them as there are rows in a table. They derive their path from their index instead.
public static function childPath(string $indexKey, string $locale, string $childSlug): ?string
{
$parent = self::path($indexKey, $locale);
return $parent === null ? null : rtrim($parent, '/').'/'.$childSlug;
}
Which means an item page has no key of its own, and for a while the layout resolved its canonical from the index key it was handed. Every article on the site declared its section listing as its canonical URL, and de-indexed itself in favour of a list. It is the most expensive SEO defect we have shipped, and it shipped green.
Nothing about that was visible. The page rendered, the content was right, the tags were well formed, and every piece we published was asking search engines to ignore it.
The fix is two override props, and the distinction between them is the part worth keeping.
'canonicalOverride' => null,
'alternatesOverride' => null,
null means no override. An empty array means no twin exists. A piece written natively in one language has no twin, and there is no correct hreflang for it. Collapsing the two values into one would make the empty case inherit the index alternates and send English readers to a French page that does not contain the article.
That distinction is observable from outside. Of the 136 sitemap URLs measured for this piece, 132 carried two xhtml:link entries and four carried one. Those four are the engineering notes, which are written in English and not translated. This one makes five.
One predicate for the page and for its absence
Our data processing agreement is written and seeded, and it is not published. The text is done; what is missing is the set of clauses that legally commit the company. It enters the table only when its gate is open.
if (TrustPack::dpaIsPublished()) {
$map['trust.dpa'] = [
'fr-FR' => 'accord-de-traitement-des-donnees',
'en-US' => 'data-processing-agreement',
];
}
And the route file reads the result rather than the gate.
$dpaSlug = SlugRegistry::slug('trust.dpa', $localeCode);
if ($dpaSlug !== null) {
Route::get($dpaSlug, [TrustController::class, 'dpa'])->name('trust.dpa');
}
No slug means no route, which means a 404. And since the navigation, the sitemap, the hreflang tags and the language switcher all read the same table, none of them can cite it. Every SEO surface goes dark with the page, in the same gesture. A dead link and a phantom sitemap URL are structurally impossible, because the two faces switch on and off together.
Note what is not there: an abort(404) in the controller. Two guards saying the same thing mask each other under mutation testing. Each one is sufficient, so each survives alone, and neither is genuinely tested. The absence of the route is the 404.
Eight of our nine module pillars carry a landing page on the same mechanism. Closing a pillar's gate closes its page, its sitemap entry, its hreflang, its Open Graph card and its megamenu link in one gesture.
Both directions, or it is not a source of truth
One direction sweeps the registry and serves every page it names.
it('serves every registry page in every locale', function () {
$keys = SlugRegistry::keys();
expect($keys)->not->toBeEmpty('The slug registry is empty.');
foreach ($keys as $key) {
foreach (MarketingLocale::codes() as $locale) {
$path = SlugRegistry::path($key, $locale);
expect($path)->not->toBeNull("Page [{$key}] has no slug in [{$locale}].");
$this->get($path)->assertOk("Page [{$key}] does not respond in [{$locale}].");
}
}
});
That is 62 HTTP requests in one test. The emptiness witness matters more than it looks, because a gate can empty the registry, and an empty sweep otherwise reads as a complete one.
The other direction walks the router.
foreach (app('router')->getRoutes() as $route) {
$name = $route->getName();
if ($name === null || ! str_starts_with($name, 'marketing.')) {
continue;
}
// marketing.fr.solutions.agencies becomes solutions.agencies
$logical = preg_replace('/^marketing\.[a-z]{2}\./', '', $name) ?? '';
expect($logical)->toBeIn($keys, "Route [{$name}] has no slug registry key.");
}
This is the one that catches the mistake people actually make, which is adding a route and forgetting the table. A page served without a registry key has no hreflang, no twin in the language switcher and no sitemap entry. It has no SEO surface at all, and it looks perfect in a browser.
The trap that cost us our datasets
SlugRegistry::keys() was a static array for months, and Pest datasets consumed it, one case per page, with per page reporting in the output.
Then a gated pillar received a landing page. The registry now calls PillarCatalog::isPublished(), which reads config('marketing.pillars.*'). A Pest dataset is resolved when the test case is constructed, before the application exists, container included. Wrapping it in a closure does not help, because the closure is resolved at that same moment.
The symptom has nothing to do with the cause. First expects 1 argument, but no dataset was provided, then Target class [config] does not exist, raised about 150 lines away from the change that caused it.
Keys are now iterated inside the test body, with the subject name in every failure message so the granular reporting is not lost. The corollary is repository wide: no dataset here can read configuration, and a registry that becomes gate dependent silently breaks every with() that consumes it.
What it costs
- The suite grows with the site. One registry sweep is 62 requests. The routing and SEO files together are 35 tests, 995 assertions and 16.0 seconds. The whole public site feature suite is 850 tests and 153,711 assertions in 4 minutes 3 seconds. Adding a page adds work to roughly forty nine files, and that is the deal: the SEO guarantee is paid for in wall clock.
- Datasets are unavailable, permanently. Loops with named failure messages recover the reporting, and they are more verbose than the
with()they replaced. - One central file everyone edits. The registry is 537 lines, mostly comment, and every new page touches it. It is a reliable source of merge conflicts, and we consider a conflict there a feature, because it is a conversation about a URL.
- It is rebuilt, not cached.
map()costs 3.83 microseconds andalternates()14.03, measured over 10,000 calls. A page render calls them a handful of times, so the total stays under a tenth of a millisecond, and the response cache in front of it hides even that. It is still a rebuild on every call, with nothing memoising it. We would rather pay microseconds than own a cache invalidation. - Item pages sit outside the guarantee. Everything derived from the registry is proven in both directions. Everything derived from
childPath()is only as good as the overrides passed with it, which is exactly where the canonical defect lived, and exactly where the next one will.
What transfers
Not the registry. The registry is small, specific to a two locale Blade site, and would be the wrong shape for a site with regional variants or a translation workflow. It also only works because the public site is not rendered by the single page application that serves the product.
What transfers is the question that produced it. For any SEO fact a crawler can read from your site, ask how many places would have to be edited, in step, for that fact to stay true. If the answer is more than one, you do not have a source of truth. You have copies that agree today.
The follow up is just as cheap. Once there is one place, write the test that walks it in both directions, and give it a witness so an empty sweep cannot pass for a complete one. Ours has been red exactly when it should be, which is the only evidence that matters.
Measurements taken 26 August 2026. Registry size, key list and micro benchmarks from the application on PHP 8.4.23, Apple M5 Max, 10,000 iterations per benchmark. Sitemap composition parsed from the live production sitemap on the same day, 136 URLs, 268 xhtml:link elements, 38 lastmod elements. Test counts and durations from a local Pest 5 run, single process, SQLite in memory.