Skip to main content

Multilingual slugs and hreflang

A model carries a slug per locale. Set a translated slug explicitly:

$page->setSlug('de', 'Hallo Welt'); // store the German slug
$page->currentSlug('de'); // "hallo-welt"
$page->slugLocales(); // ['en', 'de']

When one slug serves several addresses

slugLocales() answers which locales hold a slug. The hreflang set and the sitemap need a different list: which addresses this record is served at. They are the same list for most models, and they come apart when a project pins each slug to one locale on purpose and still serves every record under a locale prefix — /u/lena and /de/u/lena for a single slug.

Say so by implementing ProvidesAddressLocales:

public function polyslugAddressLocales(): array
{
return ['en', 'de'];
}

Both the hreflang set and polyslug:sitemap then announce both addresses, and the locale without a slug of its own reuses the default locale's — one slug, two addresses. A model that does not implement it is unaffected.

One resolver for URLs and hreflang

Build the localized URLs and the hreflang set from a single resolver — the canonical URL and the hreflang alternates come from the same source, so they can never disagree:

$resolver = fn (string $locale, string $key) => route('pages.show', [
'locale' => $locale,
'page' => $key,
]);

$page->polyslugUrls($resolver); // ['en' => 'https://…/en/…', 'de' => 'https://…/de/…']
$page->hreflangLinks($resolver); // the above + a reciprocal, self-referential 'x-default'

A drifting hreflang set is the classic cause of the wrong language ranking in search results; sourcing both from one callable is what removes the chance.

Rendering the tags

Render the tags in the page <head> — on every localized version, so the set stays reciprocal — and the <xhtml:link> alternates inside a sitemap <url> entry:

{{ $page->hreflangTags($resolver) }}
{{ $page->sitemapAlternateTags($resolver) }}

{{-- or the directive shorthand for the hreflang tags: --}}
@polyslugHreflang($page, $resolver)

Using laravel/head? Let it own the <head> instead — Head::polyslug($page) writes the same set plus the canonical URL and the Open Graph locales. Pick one of the two paths; rendering both puts two identical alternate sets in one page.

By default x-default points at the fallback locale, with a graceful fallback to the first available locale. Which locale that is has two sources, checked in order: polyslug.locale.fallback_locale when it is set, and app.fallback_locale otherwise. So setting the Polyslug key moves x-default as well as the missing-slug behavior it is more obviously about. Override it per call:

$page->hreflangLinks($resolver, 'de');

Only locales that actually have a current slug are emitted, and polyslugIsRoutable() can drop individual locales from the set.

Locale-aware routing

On a /{locale}/… route, set polyslug.locale.source = 'route' so the canonical-redirect middleware compares against — and redirects to — the slug for the locale in the URL, even when the app locale differs (in CLI, queues, or before a locale-setting middleware runs). This is what prevents wrong-language redirect loops.

// config/polyslug.php
'locale' => [
'source' => 'route', // 'app' (default) reads the active application locale
'route_param' => 'locale', // the route parameter holding the locale
'missing' => 'fallback',
'fallback_locale' => null, // null uses the application's fallback locale
],

When you build URLs off a request cycle — sitemaps, queued jobs, feeds — use $model->polyslugRouteKeyForLocale($locale). It never reads the ambient app locale, which is exactly the failure mode that produces English slugs inside a German sitemap.

If a locale has no slug, polyslug.locale.missing chooses between falling back to the default locale's slug (fallback, the default) or emitting a slug-less id-only key (id-only).

See the configuration reference for the full set of locale keys.