Appearance
Localization & RTL
A single page is authored once per language, each language kept as an independent content slice — so your French hero can have a different layout, different sections, and different copy from your English one, with neither leaking into the other. Right-to-left rendering and hreflang tags are handled for you. No translation package required.
The per-locale content model
A Site declares the languages it serves:
default_locale— the language served when no locale is requested, and the fallback for any page a translator hasn't filled in yet.locales— every language editors may author in. The default is always included.
Each saved revision stores content as a versioned payload keyed by locale:
php
[
'version' => 2,
'settings' => [/* page-level settings (locale-independent) */],
'locales' => [
'en' => ['order' => [...], 'sections' => [...]],
'fr' => ['order' => [...], 'sections' => [...]],
'ar' => ['order' => [...], 'sections' => [...]],
],
]Buckets are independent: editing fr never touches en. Mutations (add / move / duplicate / hide / settings) are always scoped to the active locale.
Adding languages to a site
Set the default and the active languages on the Site — either from the Sites resource form, or in the editor from the language switcher → Manage languages. The locale picker is backed by FilamentCraft\Support\Locales, which offers the common BCP-47 codes (en, fr, ar, zh-CN, …) with human labels via the PHP intl extension.
The editor language switcher
When a site has more than one locale, a language switcher appears in the editor top bar. It is a fully keyboard-accessible menu (Arrow keys, Home / End, type-ahead, Escape to close and return focus) and shows, at a glance, which languages are still untranslated:
- the active language is marked with
aria-currentand a check; - any language with no content yet is tagged Empty;
- a header counts how many of your languages are translated (
2 of 3 translated), and an amber pip on the trigger flags that some are still pending.

Right-to-left languages are tagged RTL in the menu — picking one flips the whole canvas, not just the text direction. See Right-to-left (RTL).
Switching language reloads the editor on that locale's bucket (the preview iframe is re-pointed so the canvas always mirrors the exact slice you're editing). If you have unsaved changes, you're asked to confirm before switching.
Seeding a new language
Open a not-yet-translated language and the canvas shows an onboarding card:
- Copy from default — duplicate the default language's layout, then translate in place. Best when the design is shared across languages.
- Start blank — design this language from scratch.
From the switcher's Copy content from another language… action you can copy from any existing language (not just the default), with a confirmation before it replaces the current language's content. Every copy is a single undo away.
How a locale is resolved when rendering
TemplateRenderer resolves the locale for a request in this order:
- an explicit locale passed to the renderer /
<x-filamentcraft::layout :locale>; - the
?locale=query parameter (validated against the site's languages); - the site's
default_locale.
A requested locale is always clamped to one the site opted into — an unknown code falls back to the default rather than rendering a blank page. If a page has a locale bucket but it's empty, the public site serves the default language's content for it (so an in-progress translation never publishes a blank page), while the editor shows the real (blank) bucket you're working on.
Sections render in the requested locale
SectionRenderer sets the app locale for the duration of every section render and restores it afterwards, so __() calls inside section views resolve against the requested locale — and per-locale fragment caches store the right strings instead of freezing whatever language the app booted with. The built-in sections' own UI strings ("Most popular", nav aria labels, cart labels, …) ship translated in seven languages (ar, de, en, es, fr, ja, pt).
Multilingual SEO: hreflang
On published FilamentCraft templates, hreflang is automatic — the SEO pipeline emits one <link rel="alternate" hreflang="…"> per language plus an hreflang="x-default", alongside the inline alternates in /sitemap.xml. You don't need to add anything.
Pages you render yourself are different — including pages wrapped in <x-filamentcraft::layout> (cart, checkout, account). The layout gives them the site's theme shell and site-level SEO defaults, but it can't know what a page's translations are, so it emits no alternates. Add them yourself:
blade
@push('fc-head')
<x-filamentcraft::hreflang :site="$site" :current="$locale" />
@endpushOn a custom layout that doesn't wrap the FilamentCraft one, drop the same component straight into your <head>.
It emits one <link rel="alternate" hreflang="…"> per language plus an hreflang="x-default" pointing at the default language, and renders nothing for single-locale sites.
A visitor-facing language switcher
Give visitors a way to change language with the storefront switcher:
blade
<x-filamentcraft::locale-switcher :site="$site" :current="$locale" />It renders an accessible <nav> of native language names (Français, العربية, 日本語), marks the current one with aria-current, carries the right hreflang/lang on each link, flips itself to dir="rtl" when the active language is right-to-left, and is styled entirely from your theme tokens via the .fc-locale-switcher primitive. It also renders nothing for single-locale sites.
URL strategies
Everything defaults to a query-parameter strategy (?locale=fr), matching FilamentCraft's built-in catch-all routing — including the built-in Header section's language switcher. If you own your routing and prefer path-prefixed URLs (/fr/about) — the SEO-recommended approach — register a locale URL resolver on the plugin:
php
use FilamentCraft\FilamentCraftPlugin;
use FilamentCraft\Models\Site;
FilamentCraftPlugin::make()
->localeUrlsUsing(function (Site $site, string $locale, bool $isDefault): ?string {
$path = ltrim(request()->path(), '/'); // strip any existing prefix as needed
return $isDefault
? url("/{$site->slug}/{$path}")
: url("/{$site->slug}/{$locale}/{$path}");
})Every consumer follows it automatically: the built-in Header section's switcher, LocaleAlternate::forSite(), and therefore the <x-filamentcraft::hreflang> and <x-filamentcraft::locale-switcher> components. Return null for a locale to fall back to the query-parameter strategy.
Two things change once a resolver is registered:
- The Header section opts out of fragment caching, because its switcher links become path-dependent.
/sitemap.xmldrops its inlinexhtml:linkalternates. Your resolver reads the current request, and the sitemap is a single request that would build every alternate from/sitemap.xmlitself. The per-pagehreflangtags still carry the correct URLs, which is what search engines read anyway.
For one-off cases you can still build alternates by hand with a per-call builder:
php
use FilamentCraft\Support\LocaleAlternate;
$path = ltrim(request()->path(), '/'); // strip any existing locale prefix as needed
$alternates = LocaleAlternate::forSite(
$site,
currentLocale: $locale,
urlFor: fn (string $code, bool $isDefault): string => $isDefault
? url("/{$site->slug}/{$path}")
: url("/{$site->slug}/{$code}/{$path}"),
);Then pass them to either component:
blade
<x-filamentcraft::hreflang :alternates="$alternates" />
<x-filamentcraft::locale-switcher :alternates="$alternates" />LocaleAlternate::forSite() returns one entry per site language with its url, native label, and current / rtl / isDefault flags — so you never re-derive the language list, labels, or text direction by hand.
See the multi-locale site example for a complete host application wiring locale-prefixed routes end to end.
Right-to-left (RTL)
RTL is automatic. The rendered <html> carries the correct lang and dir for the served locale, and Site::isRtl() / Site::isRtlLocale() expose the same decision to your own templates. Arabic, Hebrew, Persian and Urdu (and their regional variants) resolve to dir="rtl"; everything else to ltr. The storefront switcher mirrors that automatically, and the built-in section primitives use logical properties so they reflow without extra work.
Worked example
blade
{{-- A storefront layout serving per-locale content with full multilingual SEO --}}
<x-filamentcraft::layout :site="$site" :locale="$locale">
@push('fc-head')
<x-filamentcraft::hreflang :site="$site" :current="$locale" />
@endpush
{{ $slot }}
<x-filamentcraft::locale-switcher
:site="$site"
:current="$locale"
class="fixed bottom-5 end-5"
/>
</x-filamentcraft::layout>