bezhansalleh/filament-language-switch
Zero-config language switching for Filament Panels. Auto-detects your panel layout and renders in the right place: topbar icon, sidebar nav item, or user menu. Offers flags, labels, appearance and placement options, plus user-preferred locale support.
1. Install the package
composer require bezhansalleh/filament-language-switch
2. Wire up your theme (this is not optional)
The plugin ships its own views/CSS and follows Filament's Tailwind source-scanning rules. If you don't already have a custom theme, create one, then add the vendor path to resources/css/filament/{panel}/theme.css:
@source '../../../../vendor/bezhansalleh/filament-language-switch/**';
Adjust the ../ depth to match your theme file's real location, then rebuild:
npm run build
Skipping this step is the #1 cause of "the switcher renders but looks broken/unstyled." Always do it before you start tweaking appearance.
3. Configure it in a service provider boot()
use BezhanSalleh\LanguageSwitch\LanguageSwitch;
public function boot(): void
{
LanguageSwitch::configureUsing(function (LanguageSwitch $switch) {
$switch->locales(['en', 'fr', 'ar']);
});
}
That's the whole first use case: the switch auto-detects your panel layout and lands in the topbar (or the sidebar if the topbar is disabled). No render hook, no Blade include, no panel registration.
Where to look first
Enums namespace (DisplayMode, TriggerStyle, ItemStyle, Placement, PlacementMode) — everything is strongly typed, so your IDE autocompletes the valid surface area.Pick display mode by locale count. Dropdown is fine up to ~6 locales. Past that, switch to the modal — it's scrollable and purpose-built:
use BezhanSalleh\LanguageSwitch\Enums\DisplayMode;
$switch
->locales(['en', 'fr', 'ar', 'de', 'es', 'pt', 'ja', 'ko', 'zh'])
->displayMode(DisplayMode::Modal)
->columns(3)
->modalWidth('lg')
->modalHeading('Choose Language')
->modalIcon('heroicon-o-language');
For 15+ locales, ->modalSlideOver() keeps the list readable without a cramped grid.
Flags are a content decision, not just decoration. Once you supply ->flags([...]), the item style auto-switches to FlagWithLabel and the trigger defaults to a flag. Be explicit when flags are ambiguous (e.g. en → US vs UK):
$switch
->flags([
'en' => asset('flags/us.svg'),
'fr' => asset('flags/fr.svg'),
'ar' => asset('flags/sa.svg'),
])
->itemStyle(ItemStyle::FlagOnly) // compact grid, tooltips on hover
->circular();
ItemStyle::FlagOnly + DisplayMode::Modal + columns(3) is the nicest "showcase" pattern for travel/i18n-heavy admin panels.
Combine nativeLabel() with displayLocale() correctly. They solve different problems:
$switch->nativeLabel(); // "Français", "العربية" — best default UX
// or, for labels generated in a fixed language:
$switch->displayLocale('fr');
// or hard overrides:
$switch->labels(['pt_BR' => 'Brasileiro', 'pt_PT' => 'Europeu']);
nativeLabel() is what you want for a language picker — users recognize their own language name faster than a translated one.
Move it into the user menu when the topbar is busy. Explicit render hook wins over the smart default:
use Filament\View\PanelsRenderHook;
$switch->renderHook(PanelsRenderHook::USER_MENU_PROFILE_AFTER);
The trigger design adapts automatically (icon button in topbar hooks, nav item in sidebar hooks, dropdown item in profile hooks). You only need ->trigger(style: ...) when you want to override that adaptation.
Show it on auth pages via visible() + placement. Keep the insidePanels/outsidePanels toggles independent so you can scope it per context:
use BezhanSalleh\LanguageSwitch\Enums\{Placement, PlacementMode};
$switch
->visible(outsidePanels: true)
->outsidePanelPlacement(Placement::TopEnd, PlacementMode::Static)
->maxHeight('30rem');
Use PlacementMode::Pinned for long registration forms where the switcher must stay reachable, and PlacementMode::Relative when you want to nudge it with custom CSS (top: 1rem) without publishing the view.
Multi-panel setups. Configure globally in one provider using configureUsing once; use Panel Exclusion for panels that should not show it, rather than duplicating the closure per panel. Inside the closure you can branch on Filament::getCurrentPanel() if two panels genuinely need different locale lists — just remember the closure runs on render, so keep it cheap.
Global icon override instead of per-call. If you want a consistent custom icon across every trigger:
use Filament\Support\Facades\FilamentIcon;
FilamentIcon::register([
'language-switch::trigger' => 'phosphor-translate',
]);
Inline embedding and the Control Panel are both documented — read those sections when you need the switcher inside a custom page or want per-panel controls exposed through Filament's UI. They're the extension points beyond the fluent config.
@source path is the most common failure. Four levels of ../ assumes resources/css/filament/<panel>/theme.css. If you put the theme elsewhere (or use a root-level theme.css), recount. Symptom of a wrong path: markup renders, styles don't. Fix by checking the built CSS for fi-ls class rules.npm run build (the README shows it twice — a docs duplication, not two steps). The plugin's classes won't be in a stale build.TriggerStyle::Flag / FlagLabel requires ->flags(). Without a flags map, the trigger has nothing to render. Either supply flags or stick to Icon, Avatar, or Label. The default auto-detects this, so only override when you know flags exist.ItemStyle::AvatarOnly degrades in modal mode — the abbreviation is too small for the showcase card, so it silently falls back to label-only cards. If you specifically want the compact look, use FlagOnly in modal.maxHeight('max-content') disables scrolling. Great for 3 locales on desktop; painful for 20 locales on a laptop. Prefer the default 18rem or an explicit 30rem unless you've tested the worst case.config/app.php and your lang/ directory exactly. pt_BR (underscore) and pt-BR (hyphen) are different strings; if the switcher renders a label but the app doesn't translate, that mismatch is usually the cause. Also check that labels() keys match locales() verbatim.outsidePanelRoutes() excludes the profile route by default unless the panel's profile page is simple (->profile(isSimple: true)). A full-layout profile page already has the switcher inside the panel, so this avoids double rendering — but it surprises people when they explicitly add auth.profile and it doesn't show.Placement values flip start/end automatically. Add ar (or he) to your locales during development and eyeball the topbar and simple-layout placements; the flip is where layout bugs surface.configureUsing is global, not per-panel. If you call it in two providers expecting isolation, the last registration wins. Centralize it, or branch on the current panel inside the single closure.composer.json. The README badges claim Filament 4.x and 5.x, but the API shown (Heroicon enum in trigger(icon: ...), Enums\*, PanelsRenderHook) is the newer surface. Before upgrading Filament, check the package's release notes — with a small dependent base and a recent release cadence, pinning a minor version and reading the CHANGELOG before bumping is the safe move. heroicon-o-globe-alt as a plain string is the most version-portable icon argument.locales() non-empty? (2) is the panel excluded? (3) does the resolved render hook exist in your panel layout (e.g. a USER_MENU_* hook on a panel with no user menu)? (4) inspect DOM for the fi-ls / fi-ls-trigger classes — present means styling/@source problem, absent means config/hook problem.fi-ls* classes in your theme, or publish the views for structural changes. That's the intended extension point, so don't fight the defaults.users.preferred_locale, listen to the documented Event, and make sure a SetLocale middleware applies the stored value on subsequent requests.How can I help you explore Laravel packages today?