atannex / localization
Complete localization for Laravel: locale registry with a built-in catalog, request negotiation (URL prefix, domain, query, user, session, cookie, Accept-Language, geo), localized URLs and hreflang, a language switcher, translatable Eloquent attributes with driver-aware JSON queries, locale-aware fo
Requires
- php: ^8.2
- illuminate/console: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/routing: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- illuminate/translation: ^11.0|^12.0
- illuminate/view: ^11.0|^12.0
- nesbot/carbon: ^3.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.5
Suggests
- ext-intl: Native number, currency, list and language-name formatting in every locale.
- filament/filament: For Atannex\Localization\Filament\LocalizationPlugin (^4.0).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-25 04:49:15 UTC
README
Localization for Laravel in one package: which languages you support, how a request picks one, how URLs carry it, how the choice is remembered, how content and numbers are translated, and how you know what is still untranslated.
// config/localization.php 'locales' => ['en', 'fr', 'ar'], // names, scripts, direction, currency: from the built-in catalog Localization::current(); // Locale { code: "fr", native: "Français", dir: "ltr", … } Localization::using('ar', fn () => $mail->render()); Localization::url('fr'); // this page in French Localization::format()->currency(1234.5); // "1 234,50 €" $post->title; // current language, falling back along the chain Post::whereTranslationLike('title', 'élection')->orderByTranslation('title')->get();
What you get:
- Locale registry with a catalog of ~110 locales (English and native names, script, direction, regional/Open Graph code, currency, flag).
['en', 'fr', 'pt_BR']is a complete configuration; any key can be overridden and custom keys are kept. - Tag matching the way browsers and people write tags:
pt-br,PT_BR,fr-CM→fr,zh-Hant-HK→zh_Hant,pt-AO→pt_BR. - Negotiation through an ordered chain of detectors: route prefix, domain,
?lang=, the user's preference, session, cookie,Accept-Language(q-values), a CDN country header, or your own. - Three URL strategies: one address for every language,
/fr/…prefixes (Route::localized(), hidden default, canonical redirects), or one domain per language.Localization::url(),alternates()andlocalized_route()work the same in all three. - A language switcher route (POST, no JavaScript needed, never redirects off-site, JSON-aware) and Blade components for the switcher,
hreflanglinks andog:localetags. - Persistence of explicit choices in a cookie, the session and/or the user (
HasPreferredLocale), which also makes Laravel's notifications and mail use it. - Translatable Eloquent attributes (
HasTranslations) without another dependency: fallback chains, legacy plain values, mutators and accessors, events, completeness reports, and query scopes compiled for MySQL, MariaDB, PostgreSQL, SQLite and SQL Server. API-compatible with spatie/laravel-translatable for the common methods. - Formatting per locale: numbers, currency, percentages, compact numbers, file sizes, ordinals, spelled-out numbers, date presets, relative times, durations, month and weekday names, and lists ("A, B et C"). Uses ext-intl when loaded; falls back to built-in rules when it isn't.
- Queues run each job in the locale it was dispatched in.
- Missing keys: track untranslated
__()keys at runtime (log, record, throw, event). - Tooling:
localization:status(coverage),missing(with--fix),unused(dynamic keys respected),export/import(CSV/JSON for translators) andlocales. - Filament plugin: a topbar switcher, a "you are editing the French version" banner, no fallback in forms, and translated relationship labels.
- Validation rules:
SupportedLocale,Translations.
Install
composer require atannex/localization php artisan vendor:publish --tag=localization-config
The service provider is auto-discovered. SetLocale is added to the web middleware group (middleware_groups), the locale cookie is excluded from encryption, and the switcher route is registered at POST /locale/{locale}.
Configure
return [ 'locales' => [ 'en', 'fr' => ['currency' => 'XAF'], // override catalog values 'bri' => ['name' => 'Bakweri'], // codes outside the catalog need a name ], 'default' => null, // null: the first locale 'fallback' => null, // null: the default 'fallback_chains' => ['fr_CA' => ['fr', 'en']], 'strategy' => 'none', // none | prefix | domain 'detectors' => ['route', 'domain', 'query', 'user', 'session', 'cookie', 'header'], 'persist' => ['cookie', 'user'], ];
Every option is documented in config/localization.php.
Locales
use Atannex\Localization\Facades\Localization; Localization::codes(); // ['en', 'fr', 'ar'] Localization::default(); // Locale Localization::current(); // the active Locale Localization::others(); // every locale but the current one Localization::match('fr-CM'); // 'fr' Localization::fallbacks('fr_CA'); // ['fr', 'en'] $locale = Localization::get('ar'); $locale->native; // "العربية" $locale->tag(); // "ar" (BCP 47, for lang="" and hreflang) $locale->regional(); // "ar_SA" (Open Graph) $locale->isRtl(); // true $locale->flag(); // "🇸🇦" $locale->currency; // "SAR"
Switching:
Localization::set('fr'); // translator, its fallback, Carbon, Number Localization::using('ar', fn () => view('mail')->render()); // restored afterwards, even on exceptions Localization::each(fn ($locale) => route('feed')); // ['en' => …, 'fr' => …]
set() also points the translator's fallback at the locale's chain, so a missing fr_CA string comes from fr before en.
How a request picks a locale
The detectors in detectors run in order; the first one that names a supported locale wins, otherwise the default applies.
| Detector | Reads |
|---|---|
route |
the {locale} parameter, or a supported first path segment under the prefix strategy |
domain |
the host, through domains |
query |
?lang=fr (query_parameter) |
user |
$request->user()->preferredLocale() (Laravel's HasLocalePreference) |
session |
the session value (session_key) |
cookie |
the switcher's cookie |
header |
Accept-Language, honouring q-values, with region and script matching |
geo |
a country header from your CDN (CF-IPCountry…), through geo.countries |
Add your own:
Localization::extendDetector('subdomain', fn (Request $request) => explode('.', $request->getHost())[0]); // or a class implementing Atannex\Localization\Contracts\Detector, listed by class name
SetLocale (alias localize) applies the result and sets Content-Language. Under the none strategy it also adds Vary: Accept-Language, Cookie, because one address answers in several languages. ->middleware('localize:fr') forces a locale.
Events: LocaleNegotiated (with the detector that answered), LocaleChanged, LocaleSwitched.
URLs
One address (none)
The page has the same URL in every language. If the query detector is enabled, Localization::url('fr') returns ?lang=fr links, which gives search engines and shared links a URL per language.
Prefixes (prefix)
Route::middleware('web')->group(fn () => Route::localized(function () { Route::get('/', HomeController::class)->name('home'); Route::get('/about', AboutController::class)->name('about'); })); // Laravel 12 can chain it: Route::middleware('web')->localized(fn () => …);
hide_default => true (default) |
hide_default => false |
|
|---|---|---|
| English | /about (name about) |
/en/about (name about) |
| French | /fr/about (name localized.about) |
/fr/about (name about) |
/en/aboutredirects to/aboutwith a 301 when the default is hidden./aboutredirects to/fr/aboutwith a 302 for visitors who prefer French (redirect_to_preferred), on GET and HEAD only.URL::defaults()fills{locale}, soroute('about')works inside prefixed pages.localized_route('about')andLocalization::route('about', [], 'fr')link correctly in every setup.- An unprefixed localized URL always means the default locale. The URL is authoritative there.
Domains (domain)
'strategy' => 'domain', 'domains' => ['example.com' => 'en', 'example.fr' => 'fr'], 'detectors' => ['domain', 'user', 'cookie', 'header'],
In any strategy
Localization::url('fr'); // the current page in French Localization::url('fr', 'https://example.com/about?x=1#top'); Localization::alternates(); // ['en' => …, 'fr' => …] Localization::neutralUrl(); // the x-default address localized_url('ar');
URLs on other hosts are returned unchanged.
Switcher and Blade
<html lang="{{ localization()->current()->tag() }}" dir="{{ locale_direction() }}"> {{-- or: <html @localeAttributes> --}} <head> <x-localization::meta /> {{-- og:locale, og:locale:alternate and hreflang links --}} <x-localization::hreflang /> {{-- hreflang links only --}} </head> <x-localization::switcher /> {{-- a button per language --}} <x-localization::switcher variant="select" flags /> <x-localization::switcher variant="details" codes /> @locale('fr') {{ __('Welcome') }} @endlocale {{-- renders the block in French --}} @rtl … @endrtl @multilingual … @endmultilingual
The switcher posts to POST /locale/{locale} (or POST /locale with a locale field). The route:
- saves the choice in the
persiststores, - sends the visitor back to the page they were on, at its URL in the new language, never to another site,
- returns
{"locale": "fr", "redirect": "…"}to JSON requests.
Publish the views to restyle them (--tag=localization-views), or build your own with Localization::switchUrl($code).
Remembering a choice
use Atannex\Localization\Concerns\HasPreferredLocale; use Illuminate\Contracts\Translation\HasLocalePreference; class User extends Authenticatable implements HasLocalePreference { use HasPreferredLocale; // a column ('locale' by default) or a JSON path protected string $preferredLocaleAttribute = 'settings->locale'; } $user->setPreferredLocale('pt-br'); // stored as 'pt_BR'; unsupported values are ignored $user->preferredLocale(); // read by the user detector, notifications and mail Localization::persist('fr'); // runs the configured stores and fires LocaleSwitched
Translatable models
use Atannex\Localization\Concerns\HasTranslations; class Post extends Model { use HasTranslations; public array $translatable = ['title', 'body']; } // or: #[Translatable('title', 'body')]
Each column stores JSON ({"en": "…", "fr": "…"}). A json or text column works.
$post->title; // current locale, then its fallbacks $post->title = 'Bonjour'; // sets the current locale $post->title = ['en' => 'Hello', 'fr' => 'Bonjour']; $post->setTranslation('title', 'fr', 'Bonjour'); $post->getTranslation('title', 'fr', false); // exact, no fallback ('' if missing) $post->getTranslations('title'); // ['en' => …, 'fr' => …] $post->in('fr')->title; // a copy that reads French $post->forgetTranslation('title', 'fr'); $post->replaceTranslations('title', ['en' => 'Only English']); $post->missingTranslations('fr'); // ['body'] $post->isFullyTranslated('fr'); $post->translationProgress(); // ['en' => 1.0, 'fr' => 0.5]
- Values as values. A list (
[['type' => 'p', …]]) or an array whose keys aren't locales is a value, not a set of translations. Assigning[]stores an empty value for the current locale. - Existing data. Plain text or a JSON list written before a column became translatable reads as the default locale, so nothing goes blank between deploying and migrating.
- Fallback. Configured globally (
translatable.fallback,fallback_any), per model ($useFallbackLocale), or per block:Localization::withoutFallback(fn () => …).Localization::strictWhen($condition)empties serialisation (forms) when the condition holds; passreads: trueto cover attribute reads too. - Serialisation.
toArray()gives the current locale's value, or every translation withtranslatable.serialize => 'all'or$translationsInArray. - Mutators and accessors apply to each translation. A
TranslationSetevent is dispatched when a value changes.
Queries
Post::whereTranslation('title', 'Bonjour'); // in any supported locale Post::whereTranslation('title', 'Bonjour', 'fr'); // in one locale Post::orWhereTranslation('title', 'Hello', 'en'); Post::whereTranslationLike(['title', 'body'], 'élect'); // case-insensitive; % and _ are literal Post::orderByTranslation('title'); // current locale with fallback; '' counts as missing Post::selectTranslation('title', as: 'label'); Post::whereHasTranslation('title', 'fr'); Post::whereMissingTranslation('title', 'fr'); // the translation queue $post->translatedColumnSql('title'); // for raw SQL
The SQL follows the connection: json_extract on MySQL, MariaDB and SQLite, ->> on PostgreSQL, json_value on SQL Server. MySQL comparisons use utf8mb4_unicode_ci, as a plain text column would.
Formatting
$format = Localization::format('fr'); // or format() for the current locale $format->number(1234.5); // 1 234,5 $format->currency(1234.5, 'EUR'); // 1 234,50 € $format->percent(0.256); // 25,6 % $format->compact(1_234_000); // 1,2M $format->fileSize(1536); // 1,5 KB $format->ordinal(1); // 1er $format->spell(42); // quarante-deux (intl) $format->date($date, 'long'); // 24 octobre 2026 (presets in formatting.dates, or any isoFormat) $format->relative($date); // il y a 3 heures $format->duration(3725); // 1 heure et 2 minutes $format->list(['A', 'B', 'C']); // A, B et C $format->months(); // [1 => 'janvier', …] Localization::languageName('de', 'fr'); // Allemand
Number::useLocale() and Carbon::setLocale() follow the active locale, so Laravel's own helpers agree.
Queues
With queue => true, each job payload carries the locale it was dispatched in, and the job runs in that locale. The previous locale is restored afterwards, including for nested sync jobs.
Missing translations
'missing' => ['enabled' => true, 'log' => true, 'record' => true, 'throw' => false],
Every __() key with no translation dispatches MissingTranslationKey once per request and locale. It can also be logged, recorded to a file (read with localization:missing --recorded) or thrown, which is useful in tests. In the default locale, text keys like __('Sign in') are the text itself, so only group keys such as nav.home count as missing there.
Commands
php artisan localization:status # coverage per locale php artisan localization:missing fr # keys used in code or other locales but untranslated php artisan localization:missing fr --fix --placeholder=source php artisan localization:unused --locale=en # keys no code uses (keys built at runtime are kept) php artisan localization:export fr --missing --output=fr.csv php artisan localization:import fr.csv fr # key,source,translation or {"key": "translation"} php artisan localization:locales [--catalog] php artisan about # includes a Localization section
The scanner reads __(), trans(), trans_choice(), @lang, @choice, Lang::get(), $t() and i18n.t() in PHP, Blade and JS. Keys built at runtime (__("desks.{$desk}"), __('menu.'.$item)) are recorded as prefixes, so unused never removes them.
Front-end translations
'frontend' => ['enabled' => true, 'path' => 'localization', 'groups' => ['js']],
GET /localization/fr.json returns the JSON strings plus the listed groups as dot keys, with an ETag and Cache-Control.
Filament
use Atannex\Localization\Filament\LocalizationPlugin; $panel ->plugin(LocalizationPlugin::make()) ->middleware([/* … */, SubstituteBindings::class, \Atannex\Localization\Http\Middleware\SetLocale::class, DispatchServingFilamentEvent::class]);
The plugin adds:
- a topbar language switcher,
- a banner above create and edit pages of translatable resources saying which language version is open,
- no fallback in forms, so an untranslated field is empty and saving never copies one language into another (
->strict(reads: true)extends this to tables), - relationship selects and filters that show translated titles instead of raw JSON,
- case-insensitive table search.
Each part can be turned off (->switcher(false), ->editingBanner(false)…). ->setLocale() appends the middleware for you.
Validation
use Atannex\Localization\Rules\SupportedLocale; use Atannex\Localization\Rules\Translations; 'locale' => ['required', new SupportedLocale], // or new SupportedLocale(loose: true) 'title' => ['required', Translations::make()->requireDefault()->max(255)], 'name' => [Translations::make()->require('*')],
Messages are translated into English, French, Spanish, German, Portuguese (pt and pt_BR) and Arabic.
Testing
composer test
The suite runs on PHP 8.2–8.4 with Laravel 11 and 12.
License
MIT