tomatophp/filament-language-switcher
A configurable language switcher for Filament panels that lets users change the app locale from the topbar or user menu. Supports custom language lists, flags, RTL layouts, and simple Laravel localization integration.
1. Confirm the stack before you composer require anything. v5.0.0 requires Filament v5, Livewire 4, Laravel 12/13, PHP 8.2+. If your panel is still on Filament v4, this package is not a drop-in — the 4.x branch exists but the release notes do not confirm the security fix was backported there.
2. Install with an explicit major pin.
composer require tomatophp/filament-language-switcher:^5.0
The headline change in this major is a security fix, not a feature. A floating constraint lets a routine composer update decide when you cross that line.
3. Run the installer once.
php artisan filament-language-switcher:install
In v5 this publishes the config and migration and runs the migrations in-process. Fine on a local box; read the migration before it runs anywhere automated.
4. Register the plugin in the panel provider.
use TomatoPHP\FilamentLanguageSwitcher\FilamentLanguageSwitcherPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->id('admin')
->path('admin')
->plugin(FilamentLanguageSwitcherPlugin::make());
}
5. Read these four things, in this order:
| Where | What you're confirming |
|---|---|
config/filament-language-switcher.php |
The locale allowlist, translated display names, show_flags — this file is most of your day-to-day work |
| The published migration | Column name, type, and nullability on the users table |
php artisan route:list --path= (grep for the switch route) |
Actual route name, the auth middleware, and that no model/model_id params are accepted |
vendor/tomatophp/filament-language-switcher/ |
It's small — read it end to end before you debug it |
6. Decide where the stored locale gets applied. The package persists a per-user preference; something still has to call app()->setLocale() on each request. Check whether the package ships middleware for this. If it doesn't, add your own and register it after auth:
// app/Http/Middleware/SetLocaleFromUser.php
public function handle($request, Closure $next)
{
$locale = $request->user()?->language ?? config('app.locale');
if (! array_key_exists($locale, config('filament-language-switcher.locales', []))) {
$locale = config('app.locale');
}
app()->setLocale($locale);
return $next($request);
}
The allowlist guard is not redundant. v5 rejects unconfigured locales at the switch route, but a row written by a seeder, an import, or a user who chose a locale you later removed can still hold a stale value.
First use case: an internal admin panel where an operator flips between two or three locales for their own account — no shared demo user, no changing anyone else's session.
Treat the config locale array as an allowlist, not a label map. Anything not in that array is not switchable, and v5 will reject it at the route. That's the design.
// config/filament-language-switcher.php — verify the exact shape after install
'locales' => [
'en' => 'English',
'ar' => 'العربية',
'nl' => 'Nederlands',
],
'show_flags' => true,
If a locale exists in lang/ but not here, users can't reach it. Add the entry or it doesn't exist.
Register per panel, deliberately. Most apps want this on the admin panel only. Dropping it onto a customer-facing panel quietly promotes an internal preference into a customer-visible toggle — decide that on purpose.
Write the switch link the v5 way. The route changes the signed-in user's language. No identifiers in the URL:
{{-- Good --}}
<a href="{{ route('filament-language-switcher.switch', ['locale' => 'ar']) }}">العربية</a>
{{-- Bad: pre-v5 habit. Now silently ignored. --}}
{{-- ?model=App\Models\User&model_id=42 --}}
Confirm the real route name from route:list rather than trusting copy-paste.
Leave the column nullable and don't backfill. A null locale means "inherit app.locale", so changing APP_LOCALE later propagates to everyone who never chose. Backfill only if you explicitly want to freeze today's behavior:
DB::table('users')->whereNull('language')->update(['language' => config('app.locale')]);
RTL is a separate job from locale. The package switches strings; your layout still needs dir="rtl". Drive that from the resolved locale in your blade layout, not from the switcher component.
Mirror the four security behaviors in CI — they're cheap tests and they document the contract:
it('forbids guests')->get('/language/ar')->assertForbidden();
it('ignores a target user', function () {
$victim = User::factory()->create(['language' => 'en']);
$this->actingAs(User::factory()->create(['language' => 'en']))
->get('/language/ar?model=' . urlencode(User::class) . '&model_id=' . $victim->id);
expect($victim->fresh()->language)->toBe('en');
});
it('rejects unconfigured locales')->actingAs($user)->get('/language/zz')->assertStatus(/* 4xx */);
Add UI cases too: current locale absent from config, show_flags on and off, a locale with no translated name, and a user with null.
Localization QA is the highest-value workflow. A reviewer flips language in the topbar and validates their own view immediately. No shared demo account, no "someone changed the language for everyone" incident report.
Override bad labels via vendor:publish, not by hacking a custom view. Dutch ships as a default locale in v5 and its name is now translated in every language file — if another label reads poorly, publish and override the language file.
Add the middleware early, not after the first bug report. Locale resolution is the one piece the package doesn't fully own for you, and every later feature (dates, number formatting, validation messages) depends on it being per-request and predictable.
Version lockstep is the actual risk, not the code. v5 on a Filament v4 app is an unsupported combination. Sequence this after the Filament 5 / Livewire 4 / Laravel 12 upgrade and treat it as a passenger on that migration, not a trigger for it.
Audit for pre-v5 behavior before you upgrade. Older versions instantiated any class named in the request and let one user change another's language. Hunt down guest-facing switch links, generated URLs carrying model/model_id, and bookmarked dashboards. The fix makes those flows stop working — correctly — so find them yourself instead of via a bug report.
Never assume 4.x is patched. The notes say the v4 code is preserved on that branch; they don't say the security fix was backported. Get it in writing, or log every pre-v5 deployment as an open security finding with an owner.
show_flags is respected — including the gap when a flag is missing. Flags are cosmetic; don't let them be the only affordance. Verify the switcher with flags both on and off before shipping.
"Renders when the current locale isn't configured" is not "behaves." v5 fixed the disappearing-switcher case, but normalize the resolved locale to app.locale in your middleware so every request carries a configured locale regardless.
Config labels are user-facing copy. A locale with no translated name falls back to the config label — which is good, but it means en and ar sitting in your config are what users read. Review them.
Treat the install command as pipeline-unsafe until proven. It runs migrations in-process. In a real deploy you want migrations to be their own observable step: publish, read the migration, confirm column name/type/nullability, then let php artisan migrate do the work.
Config cache and long-lived processes. Editing the locale array requires a config:cache refresh. Under Octane or queue workers, locale is process state — set it per request/job and reset at the boundary (app()->setLocale(config('app.locale'))) or one request's language leaks into the next.
Debugging order: route:list (route + middleware) → 403 vs 302 (403 = middleware working; 302 = unauthenticated, a different bug) → the user row → the config array. It's a small package; most "bugs" are config drift.
Confirm the column assumption immediately. A non-default locale column name, or a locale stored on something other than users, is the most likely reason you'll need to read or override the source. Assert it in a test right after install, before you build on it.
Keep it forkable. 37 stars, zero dependents, MIT, effectively single-maintainer. That's fine if the switcher stays a thin topbar dropdown over one column and one config file — and painful if you build tenant negotiation on top of it. This package switches a stored user preference. It does not do tenant locale, session locale, Accept-Language negotiation, or content translation. Add those alongside, not inside.
How can I help you explore Laravel packages today?