bezhansalleh / filament-language-switch
Zero config Language Switch(Changer/Localizer) plugin for filamentphp admin
Package info
github.com/bezhanSalleh/filament-language-switch
pkg:composer/bezhansalleh/filament-language-switch
Fund package maintenance!
Requires
- php: ^8.2
- filament/filament: ^4.0|^5.0
- illuminate/contracts: ^11.28|^12.0|^13.0
- illuminate/support: ^11.28|^12.0|^13.0
- spatie/laravel-package-tools: ^1.9
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.8|^4.0
- pestphp/pest-plugin-laravel: ^3.2|^4.0
- pestphp/pest-plugin-livewire: ^3.0|^4.0
- pestphp/pest-plugin-type-coverage: ^3.6|^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.0|^12.0
- rector/jack: ^0.4.0
- rector/rector: ^2.2
- spatie/laravel-ray: ^1.43
README
Language Switch
Zero-config language switching for Filament Panels. Drop it in, provide your locales, and you're done. The plugin auto-detects your panel layout and renders in the right place with the right design — topbar icon button, sidebar nav item, user menu item — without any manual wiring.
Table of Contents
- Installation
- Quick Start
- Display Modes
- Trigger
- Flags
- Labels
- Appearance
- Placement
- Outside Panels
- Inline Embedding
- Panel Exclusion
- User Preferred Locale
- Customization
- Event
- Control Panel
- Full Example
- Version Support
Installation
composer require bezhansalleh/filament-language-switch
Important
The plugin follows Filament's theming rules. So, to use the plugin create a custom theme if you haven't already, and add the following line to your theme.css file:
@source '../../../../vendor/bezhansalleh/filament-language-switch/**';
Now build your theme using:
npm run build
Build your theme:
npm run build
Quick Start
Add this to any service provider's boot() method:
use BezhanSalleh\LanguageSwitch\LanguageSwitch; public function boot(): void { LanguageSwitch::configureUsing(function (LanguageSwitch $switch) { $switch->locales(['en', 'fr', 'ar']); }); }
That's it. The switch appears in your topbar automatically. When the panel has no topbar, it moves to the sidebar.
Display Modes
Dropdown (default)
The trigger opens a dropdown with your locales. The list is scrollable by default (max-height: 18rem), so it stays usable with many locales:
$switch->locales(['en', 'fr', 'ar']);
Modal
Opens a modal dialog instead. Better for many languages:
use BezhanSalleh\LanguageSwitch\Enums\DisplayMode; $switch ->locales(['en', 'fr', 'ar', 'de', 'es', 'pt', 'ja', 'ko', 'zh']) ->displayMode(DisplayMode::Modal) ->modalWidth('lg');
Modal with Grid
Arrange locales in columns:
$switch ->displayMode(DisplayMode::Modal) ->columns(2) ->modalWidth('lg');
Slide-over
$switch ->displayMode(DisplayMode::Modal) ->modalSlideOver();
Modal Heading & Icon
$switch ->displayMode(DisplayMode::Modal) ->modalHeading('Choose Language') ->modalIcon('heroicon-o-language') ->modalIconColor('primary');
Trigger
The trigger is configured through a single unified trigger() method that takes a style and/or an icon. Both are optional — pass whichever you want to override.
Trigger Style
Use the TriggerStyle enum to control what the trigger shows. The default adapts to the render context automatically (e.g. flag if you've set flags, otherwise icon in topbar; icon+label in sidebar/user menu), so you only need to call this when you want to override it:
use BezhanSalleh\LanguageSwitch\Enums\TriggerStyle; // Visual only $switch->trigger(style: TriggerStyle::Icon); // language icon $switch->trigger(style: TriggerStyle::Flag); // flag image (requires ->flags()) $switch->trigger(style: TriggerStyle::Avatar); // locale abbreviation (EN, FR, AR) // Visual with label $switch->trigger(style: TriggerStyle::IconLabel); // icon + locale name $switch->trigger(style: TriggerStyle::FlagLabel); // flag + locale name $switch->trigger(style: TriggerStyle::AvatarLabel); // abbreviation + locale name // Label only — no visual $switch->trigger(style: TriggerStyle::Label);
Trigger Icon
The trigger uses the language icon by default. Pass any icon from your installed Blade Icons packages, or a Heroicon enum case:
use Filament\Support\Icons\Heroicon; // Just change the icon (style stays as default) $switch->trigger(icon: Heroicon::GlobeAlt); // Any Blade Icons string works too $switch->trigger(icon: 'heroicon-o-globe-alt'); $switch->trigger(icon: 'phosphor-translate'); // Or change both in one call $switch->trigger( style: TriggerStyle::IconLabel, icon: Heroicon::GlobeAlt, );
You can also override the icon globally via Filament's icon alias system:
use Filament\Support\Facades\FilamentIcon; FilamentIcon::register([ 'language-switch::trigger' => 'heroicon-o-globe-alt', ]);
Flags
Associate each locale with a flag image:
$switch->flags([ 'en' => asset('flags/us.svg'), 'fr' => asset('flags/fr.svg'), 'ar' => asset('flags/sa.svg'), ]);
Item Style
Control what each locale item shows in the dropdown or modal. The default auto-detects: FlagWithLabel when flags are configured, AvatarWithLabel otherwise.
use BezhanSalleh\LanguageSwitch\Enums\ItemStyle; $switch->itemStyle(ItemStyle::FlagOnly); // flag images only, tooltips on hover $switch->itemStyle(ItemStyle::AvatarOnly); // abbreviations only (EN, FR), tooltips on hover $switch->itemStyle(ItemStyle::LabelOnly); // text labels only, no visual $switch->itemStyle(ItemStyle::FlagWithLabel); // flag + locale name (default with flags) $switch->itemStyle(ItemStyle::AvatarWithLabel); // abbreviation + locale name (default without flags)
Compact styles (FlagOnly, AvatarOnly) use the same tight cell layout with tooltips. In modal mode, FlagOnly renders as a showcase grid with radio-card selection; AvatarOnly falls back to label-only cards since the abbreviation is too small for a showcase.
$switch ->flags([...]) ->itemStyle(ItemStyle::FlagOnly) ->displayMode(DisplayMode::Modal) ->columns(3) ->modalWidth('lg');
Labels
Custom Labels
Override the auto-generated locale names:
$switch->labels([ 'pt_BR' => 'Brasileiro', 'pt_PT' => 'Europeu', ]);
Native Labels
Show each locale name in its own language (e.g., "Français" instead of "French"):
$switch->nativeLabel();
Display Locale
Change the language used for auto-generated labels:
// Labels generated in French (e.g., "Anglais" for English) $switch->displayLocale('fr');
Appearance
Circular
Make flags and avatars fully rounded:
$switch->circular();
Flag & Avatar Sizing (Modal)
Control sizes in modal radio cards:
$switch ->displayMode(DisplayMode::Modal) ->flagHeight('h-20') // Default: 'h-16' ->avatarHeight('size-10'); // Default: 'size-8'
Placement
Render Hook
Place the switch at any Filament render hook:
use Filament\View\PanelsRenderHook; $switch->renderHook(PanelsRenderHook::SIDEBAR_NAV_END);
The trigger automatically adapts its design to match the surrounding UI:
| Hook Location | Trigger Design |
|---|---|
GLOBAL_SEARCH_*, TOPBAR_* |
Icon button (matches topbar icons) |
SIDEBAR_LOGO_* |
Icon button (compact, hides when sidebar collapses) |
SIDEBAR_START, SIDEBAR_NAV_*, SIDEBAR_FOOTER |
Sidebar nav item (matches Dashboard, Welcome, etc.) |
USER_MENU_BEFORE/AFTER (topbar on) |
Icon button inside user menu area |
USER_MENU_BEFORE/AFTER (topbar off) |
Sidebar footer button (matches notifications) |
USER_MENU_PROFILE_* |
Dropdown list item (matches user menu items) |
SIMPLE_LAYOUT_START/END |
Floating button (see Outside Panels) |
Any other hook works too. If you pass a hook the plugin doesn't explicitly classify, it still registers and renders a default icon button — but the visual fit is on you. Use the stable CSS hooks (
fi-ls,fi-ls-trigger, etc.) from your own stylesheet, or publish the views for structural changes.
Smart Defaults
When you don't set a render hook, the plugin picks the best one based on your panel:
| Panel Config | Default Hook | Where it appears |
|---|---|---|
| Topbar enabled | GLOBAL_SEARCH_AFTER |
Topbar, after search bar |
| Topbar disabled | SIDEBAR_LOGO_AFTER |
Sidebar header, next to the logo |
To render inside the user menu as a menu item, set the render hook explicitly:
$switch->renderHook(PanelsRenderHook::USER_MENU_PROFILE_AFTER);
Dropdown Placement
Control which direction the dropdown opens:
$switch->dropdownPlacement('top-end');
Max Height
The dropdown is scrollable by default (18rem). Override it for a different limit, or pass 'max-content' to disable scrolling:
$switch->maxHeight('30rem'); $switch->maxHeight('max-content'); // no scroll, grows to fit content
Outside Panels
Filament's unauthenticated pages (login, register, password reset, email verification) render in a simple layout — no sidebar, no topbar, just the centered form card. You can show the language switcher on these pages so visitors can translate the UI before they sign in.
use BezhanSalleh\LanguageSwitch\Enums\Placement; $switch ->visible(outsidePanels: true) ->outsidePanelPlacement(Placement::TopEnd);
visible() takes two independent toggles — insidePanels (default true) and outsidePanels (default false) — so you can enable either context on its own, both, or neither. For example, to render only on the simple-layout pages and hide the switcher once the user is inside the panel:
$switch->visible(insidePanels: false, outsidePanels: true);
By default the switcher renders as a content-sized pill anchored to the chosen Placement. All six placements are RTL-aware — start/end auto-flip for right-to-left locales:
| Placement | Position |
|---|---|
Placement::TopStart |
Top-left (LTR) / top-right (RTL) |
Placement::TopCenter |
Top-center |
Placement::TopEnd |
Top-right (LTR) / top-left (RTL) |
Placement::BottomStart |
Bottom-left (LTR) / bottom-right (RTL) |
Placement::BottomCenter |
Bottom-center |
Placement::BottomEnd |
Bottom-right (LTR) / bottom-left (RTL) |
Placement mode
outsidePanelPlacement() accepts an optional second argument that controls how the switcher is attached to the page. Three modes, each with predictable CSS semantics — the names are chosen so they match what you'd read in the published view:
use BezhanSalleh\LanguageSwitch\Enums\Placement; use BezhanSalleh\LanguageSwitch\Enums\PlacementMode; $switch->outsidePanelPlacement(Placement::TopCenter, PlacementMode::Pinned);
| Mode | CSS | Behavior |
|---|---|---|
PlacementMode::Static (default) |
position: static |
Renders in the document flow inside .fi-simple-layout, above the form card for Top* placements or below it for Bottom*. Scrolls with the page. Content-sized pill aligned horizontally via w-fit + mx-auto / ms-auto. |
PlacementMode::Pinned |
position: fixed |
Stays visible while scrolling — pinned to the viewport at the chosen corner/edge as a content-sized pill. Use this when the switcher should always be reachable (e.g. long registration forms). |
PlacementMode::Relative |
position: relative |
Same visual as Static out of the box, but the element is positioned, so you can offset it via custom CSS (top: 1rem, inset-inline-start: 2rem, etc.) in your theme file without publishing the view. |
Naming note: we use
Pinnedinstead ofFixedon purpose. In CSS,position: fixedmeans "always visible, stays on screen during scroll" — which in the dev brain is usually called "sticky". To avoid that collision every time someone opens the blade file, the mode is named after the intent (Pinned= pinned to viewport), whileStaticandRelativeuse the literal CSS names because those map 1:1 to their CSS behavior.
Which routes it shows on
By default the switcher appears on Filament's standard auth routes. Override the list if your panel uses different route names:
$switch->outsidePanelRoutes([ 'auth.login', 'auth.register', 'auth.password-reset.request', 'auth.password-reset.reset', ]);
The default list is ['auth.login', 'auth.profile', 'auth.register']. The profile route is automatically excluded from the match unless the current panel uses a simple profile page (->profile(isSimple: true)), since a full-layout profile page already has a topbar/sidebar where the switcher lives.
Render hook anchor (auto-derived)
The anchor hook is derived from both the placement and the mode — you don't pick it manually:
| Mode | Top placement | Bottom placement | Why |
|---|---|---|---|
Pinned |
BODY_START |
BODY_END |
Pinned uses position: fixed, so the element needs a parent that gives reliable viewport-relative positioning. Direct body child is ideal — no flex-parent or transform containing block in the ancestor chain. |
Static / Relative |
SIMPLE_LAYOUT_START |
SIMPLE_LAYOUT_END |
In-flow elements are anchored inside .fi-simple-layout (which is min-h-dvh flex-col). This way the element shares the viewport with the centered form card instead of extending the body height and adding an unwanted page scroll. |
You can still override explicitly — pass any hook name to outsidePanelsRenderHook():
use Filament\View\PanelsRenderHook; // Dock as a profile item inside the user menu dropdown $switch->outsidePanelsRenderHook(PanelsRenderHook::USER_MENU_PROFILE_AFTER); // Or force body-level anchoring for an in-flow mode $switch ->outsidePanelPlacement(Placement::BottomCenter, PlacementMode::Static) ->outsidePanelsRenderHook(PanelsRenderHook::BODY_END);
Auto-docking into the user menu
When all of these are true, TopEnd automatically docks into the user menu via USER_MENU_BEFORE instead of anchoring as a pinned overlay:
PlacementMode::Pinned- The current route is in the outside-panel list (e.g. a simple profile page)
- The user is authenticated
- The panel has a user menu
This avoids the collision between the pinned pill and Filament's own .fi-simple-layout-header (also anchored at top-0 end-0). Static and Relative modes don't trigger auto-docking because they're in flow and can't collide with the header.
To force a pinned overlay regardless, pin a different hook explicitly:
use Filament\View\PanelsRenderHook; // Force a body-start overlay even when a user menu is present $switch ->outsidePanelPlacement(Placement::TopEnd, PlacementMode::Pinned) ->outsidePanelsRenderHook(PanelsRenderHook::BODY_START);
Inline embedding in custom pages
The render-hook system handles the common cases automatically — topbar, sidebar, user menu, simple layout. If you need to drop the switcher into a custom page section or arbitrary location in your own views, use the Blade component:
<x-language-switch::inline />
It mounts the same <livewire:language-switch-component> directly at the tag's position — no render hook required. The trigger uses whatever styling your current configuration resolves to (for example, if your effective render hook is in the topbar, you'll get a topbar-style icon button inline in your view). If you need a different visual in your custom location, override with ->renderHook(...) in a dedicated configureUsing callback or publish the views for full control.
Passing an explicit key is recommended when embedding in a parent component that re-renders, or when placing multiple instances on the same page:
<x-language-switch::inline key="switch-header" /> ... <x-language-switch::inline key="switch-footer" />
If omitted, a unique key is auto-generated via uniqid() on every render, which is fine for static pages but can cause the component to re-mount unnecessarily on dynamic parent updates.
Panel Exclusion
Hide the switch from specific panels:
$switch->excludes(['admin']);
User Preferred Locale
Resolve the user's preferred locale from their profile or any source:
$switch->userPreferredLocale(fn () => auth()->user()?->locale);
The locale resolution order is:
- Session
- Query parameter (
?locale=) - User preferred locale (this setting)
- Browser
Accept-Languageheader - Cookie
app.localeconfig
Customization
For deep customization, publish the plugin's views and edit them in your app:
php artisan vendor:publish --tag="language-switch-views"
Published views live in resources/views/vendor/language-switch/ and override the package versions automatically. For small tweaks, prefer targeting the plugin's stable CSS hooks (fi-ls, fi-ls-trigger, fi-ls-item, etc.) from your own stylesheet rather than publishing views.
Event
A LocaleChanged event fires whenever the locale switches:
use BezhanSalleh\LanguageSwitch\Events\LocaleChanged; use Illuminate\Support\Facades\Event; Event::listen(function (LocaleChanged $event) { auth()->user()?->update(['locale' => $event->locale]); });
Control Panel
A floating developer configurator that lets you hot-swap every configuration option live in the browser — handy for previewing trigger styles, testing render-hook placements, and checking the outside-panel modes without editing your service provider.
Enabling it
Opt in explicitly via controlPanel():
LanguageSwitch::configureUsing(function (LanguageSwitch $switch) { $switch ->locales(['en', 'fr', 'ar']) ->controlPanel(); });
Disabled by default. When you call controlPanel() (or controlPanel(true)) and the app is running with APP_ENV=local + APP_DEBUG=true, a small language-icon button appears at the bottom-end of every panel page. The local + debug requirement is a safety guardrail — even if you leave controlPanel(true) committed, it won't render in production.
To disable it explicitly (e.g. in a shared configuration):
$switch->controlPanel(false);
Live vs manual mode
controlPanel() takes two arguments: controlPanel(bool $enabled = true, bool $live = true). By default every change (toggle, select) is applied immediately via a Livewire round-trip. If you'd rather stage multiple changes and commit them together, pass live: false:
$switch->controlPanel(live: false); // enabled + staged (Apply button) $switch->controlPanel(true, live: false); // same thing, explicit
In manual mode, changes are accumulated in the session and the panel footer shows an Apply button (only enabled when you have pending changes). Click it to commit everything in one reload. Reset still works the same way — it wipes every accumulated override and restores your configured defaults.
Sections
The panel is organized as an accordion — click a section to expand it; opening one collapses the others, so it stays compact on both mobile and desktop:
- Trigger — topbar on/off, trigger style, trigger icon, render hook
- Display — dropdown / modal mode, modal width, columns, slide-over
- Appearance — circular, native labels, use flags, item style
- Outside Panels — enable toggle, placement, placement mode (
Pinned/Static/Relative), and an explicit render hook override (defaults to auto, with user-menu docking targets as alternatives)
Full Example
use BezhanSalleh\LanguageSwitch\Enums\DisplayMode; use BezhanSalleh\LanguageSwitch\Enums\TriggerStyle; use BezhanSalleh\LanguageSwitch\LanguageSwitch; use Filament\Support\Icons\Heroicon; LanguageSwitch::configureUsing(function (LanguageSwitch $switch) { $switch ->locales(['en', 'fr', 'ar', 'de', 'es']) ->flags([ 'en' => asset('flags/us.svg'), 'fr' => asset('flags/fr.svg'), 'ar' => asset('flags/sa.svg'), 'de' => asset('flags/de.svg'), 'es' => asset('flags/es.svg'), ]) ->displayMode(DisplayMode::Modal) ->columns(2) ->modalWidth('lg') ->circular() ->nativeLabel() ->trigger( style: TriggerStyle::FlagLabel, icon: Heroicon::GlobeAlt, ) ->excludes(['admin']) ->userPreferredLocale(fn () => auth()->user()?->locale); });
Version Support
Only the latest major plugin line receives active development. Older lines stay installable for legacy projects but get no new features; they only receive security fixes while their Filament target is still supported upstream.
| Plugin | Filament | Status |
|---|---|---|
5.x |
v5 | Active — new features + bug fixes (lives on main) |
4.x |
v4 & v5 | Security fixes only |
3.x |
v3 | End of life — follows Filament v3 EOL |
1.x |
v2 | End of life |
See CHANGELOG for migration notes between majors.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Credits
License
The MIT License (MIT). Please see License File for more information.