Skip to content

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:

LocaleURL
en (default)https://your-app.test/acme/about
frhttps://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 null falls through to the query-param strategy for that locale, so partial overrides work (the demo returns null for non-Team owners, whose sites are served elsewhere).
  • Every consumer follows it: the built-in header switcher, the <x-filamentcraft::locale-switcher> component, and the hreflang alternates all build URLs through FilamentCraft\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();