Appearance
Multi-Locale Site
A site opts into locales in the editor (see Localization); this example covers the public-facing half: how visitors reach each language version, how the language switcher and hreflang tags point at the right URLs, and how to swap the default query-param strategy for locale-prefixed paths like /acme/fr/about.
Two URL strategies
Default: ?locale=xx (zero config). The package's own catch-all public route resolves the visitor locale from a locale query parameter. The built-in header section's language switcher and the hreflang helpers default to that same strategy: they keep the current URL and swap only the locale parameter — present for every non-default locale, absent for the default. If you use the package's public routing, the multilingual storefront works out of the box.
Host-owned: register localeUrlsUsing(). The moment your app owns its routing — locale-prefixed paths, sub-domains — the query-param links would point at URLs your routes never read. You register a closure that builds the real URL, and every built-in switcher link and hreflang alternate follows it.
Locale-prefixed routes
The demo serves each tenant site at /{teamSlug}/{locale}/{path}, with the default locale prefix-free. The route definition has a non-obvious constraint:
php
use App\Http\Controllers\PublicSiteController; // your own, not the package's
// Optional route parameters nest in the compiled regex, so `{path}` can only
// match when `{locale}` matched — a locale-less subpage URL like /cascade/menu
// would 404. Each site therefore gets two routes: a locale-prefixed one and a
// locale-less fallback (the controller treats a null locale as the default).
Route::get('/site/{locale}/{path?}', [PublicSiteController::class, 'single'])
->where('locale', '[a-z]{2}(-[A-Z]{2})?')
->where('path', '.*')
->name('site.public');
Route::get('/site/{path?}', [PublicSiteController::class, 'singleUnlocalised'])
->where('path', '.*')
->name('site.public.unlocalised');You cannot express "optional locale, then optional path" as one route: Laravel compiles optional parameters nested, so the second optional segment only exists inside the first. Two routes per site, with the locale-less one delegating, is the reliable shape. The same pair exists for the tenant catch-alls (/{teamSlug}/{locale}/{path?} + /{teamSlug}/{path?} — see Multi-Tenant SaaS).
The controller needs its own locale-less entry point too, because route parameters bind positionally:
php
/**
* Route parameters bind to controller arguments positionally, so the
* locale-less route needs its own entry point — otherwise the path
* segment would land in $locale and silently render the homepage.
*/
public function tenantUnlocalised(string $teamSlug, string $path = ''): Response
{
return $this->tenant($teamSlug, null, $path);
}Validating and applying the locale
Never trust the URL segment. The demo validates it against the site's opted-in locales and falls back to the default — /cascade/zz renders the English homepage instead of 404ing:
php
private function renderSite(Site $site, string $baseSegment, ?string $locale, string $path): Response
{
app()->instance(Site::class, $site);
$resolvedLocale = ($locale !== null && $site->hasLocale($locale))
? $locale
: $site->default_locale;
app()->setLocale($resolvedLocale);
$template = $this->resolver->resolve($site, $path);
abort_if($template === null, 404);
$html = $this->renderer->render($template, 'published', $resolvedLocale);
// … hreflang + switcher injection trimmed, shown below
return response($html);
}The pieces: Site::hasLocale() checks the site's opted-in codes, app()->setLocale() makes the whole render (translations, __() calls inside sections) speak that language, and the renderer's third argument selects which per-locale setting values to render.
Untranslated content degrades gracefully: outside the editor, a locale with nothing authored yet falls back to the default locale's content instead of rendering an empty page (the editor deliberately skips that fallback so translators see the gap) — see Localization.
localeUrlsUsing() — pointing the built-in switcher at your URLs
The built-in header section renders a language switcher. By default its links use the ?locale=xx strategy — which the routes above never read. The panel provider registers the host's URL strategy on the plugin (this closure presumes the multi-tenant setup, where each Team owns a site served at /{slug}):
php
use App\Models\Team;
use FilamentCraft\Models\Site;
FilamentCraftPlugin::make()
// …
->localeUrlsUsing(function (Site $site, string $locale, bool $isDefault): ?string {
$owner = $site->owner;
if (! $owner instanceof Team) {
return null;
}
$segments = request()->segments();
if (($segments[0] ?? null) === $owner->slug) {
array_shift($segments);
}
if ($segments !== [] && in_array($segments[0], $site->localeCodes(), true)) {
array_shift($segments);
}
$path = implode('/', $segments);
$prefix = $owner->slug.($isDefault ? '' : '/'.$locale);
return url($path === '' ? $prefix : "{$prefix}/{$path}");
})The closure receives the site, the target locale, and whether that locale is the site's default (so the default gets the clean, prefix-free URL). It strips the current request's tenant-slug and locale segments, then rebuilds the URL with the target locale — so switching languages keeps you on the same page.
Expected result: on the acme team's about page, the switcher (and the hreflang alternates) emit:
| Locale | URL |
|---|---|
en (default) | https://your-app.test/acme/about |
fr | https://your-app.test/acme/fr/about |
Semantics, per FilamentCraft\Rendering\LocaleUrlResolver:
- Resolvers are keyed by panel id (like
publicUrlUsing()); outside any panel — i.e. during storefront rendering — the most recently registered closure acts as the default. - Returning
nullfalls through to the query-param strategy for that locale, so partial overrides work (the demo returnsnullfor non-Teamowners, whose sites are served elsewhere). - Every consumer follows it: the built-in header switcher, the
<x-filamentcraft::locale-switcher>component, and thehreflangalternates all build URLs throughFilamentCraft\Support\LocaleAlternate, which consults the resolver first.
The Blade components
Two storefront components ship with the package; both render nothing for single-locale sites, so they are safe to leave in a layout unconditionally.
<x-filamentcraft::locale-switcher> — a visitor-facing switcher with native language labels, the active locale flagged with aria-current, and each link carrying the hreflang of the locale it points at. Token-styled via the .fc-locale-switcher primitive so it inherits the active theme.
blade
<x-filamentcraft::locale-switcher :site="$site" :current="$locale" />Props: site + current (locale code) build the alternates for you; or pass a pre-built :alternates list when you own the URL strategy in Blade. Optional :rtl and :label override the auto-detected direction and the accessible label.
<x-filamentcraft::hreflang> — emits <link rel="alternate" hreflang> tags plus an x-default pointing at the default locale, in the document <head>:
blade
<x-filamentcraft::hreflang :site="$site" :current="$locale" />Same props: site + current, or a pre-built :alternates list.
The demo injects equivalents into the rendered HTML post-render (its controller owns the response), but inside your own Blade layout the components are the direct path. Note that the demo builds alternates with its ownApp\Support\LocaleAlternate value object (a four-argument variant tailored to its URL shape) — not the package's FilamentCraft\Support\LocaleAlternate shown earlier:
php
private function withLocaleChrome(string $html, Site $site, string $baseSegment, string $resolvedLocale, string $path): string
{
$alternates = LocaleAlternate::forSite($site, $baseSegment, $resolvedLocale, $path);
if (count($alternates) < 2) {
return $html;
}
// … view rendering trimmed: hreflang partial into </head>,
// floating switcher partial into </body>
}RTL
Direction is per locale, not per site. Site::isRtl($locale) answers for any code (Arabic, Hebrew, Farsi, …), and the rendered page's dir attribute flips accordingly — the built-in sections are written with logical properties (inset-inline-end, margin-inline-start) so they mirror for free. The switcher components carry their own dir, so an RTL locale's switcher reads right-to-left even while listing LTR languages:
php
$switcher = $this->views->make('demo.locale-switcher', [
'alternates' => $alternates,
'rtl' => $site->isRtl($resolvedLocale),
])->render();Related
- Localization — authoring per-locale content in the editor
- Public Routing — the package catch-all and the
?locale=default - Multi-Tenant SaaS — the tenant routes these locale routes extend
- E-commerce Store — a session-persisted locale variant of the same idea
