goodcat / laravel-l10n
An opinionated Laravel package for app localization
Requires
- php: ^8.2
- illuminate/http: ^12.44.0|^13.0
- illuminate/routing: ^12.44.0|^13.0
- illuminate/support: ^12.44.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.5
- laravel/pint: ^1.22
- laravel/wayfinder: ^0.1.20
- orchestra/testbench: ^10.4|^11.0
- pestphp/pest: ^3.8|^4.4
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
- laravel/wayfinder: Required to use the l10n TypeScript route() helper (^0.1)
- tightenco/ziggy: Required to use the l10n JavaScript route() helper (^2.0)
README
An opinionated Laravel package for app localization.
Table of Contents
- Quickstart
- Localized Routes
- URL Generation
- Route Caching
- Locale Preference
- Helpers
- Components
- Localized Views
- JavaScript URL Generation
Quickstart
Get started with laravel-l10n in three steps.
- Download the package via Composer.
composer require goodcat/laravel-l10n
- Add the locale middlewares to your
bootstrap/app.phpfile.return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->web([ \Goodcat\L10n\Middleware\SetLocale::class, \Goodcat\L10n\Middleware\SetPreferredLocale::class, ]); });
- Define localized routes using the
lang()method.Route::get('/example', Controller::class) ->lang(['fr', 'de', 'it', 'es']);
That's it. You're all set to start using laravel-l10n.
Localized Routes
Defining Localized Routes
Use the lang() method to define which locales a route should support:
Route::get('/example', Controller::class) ->lang(['es', 'fr', 'it']);
This will generate:
/example(fallback locale)/es/ejemplo(Spanish, translated via language file)/fr/example(French, no translation defined)/it/example(Italian, no translation defined)
Listing the fallback locale in lang() is harmless: the canonical route already serves it, so no extra route is registered.
Route groups
To avoid repetitive language definitions on every single route, you can use Route::lang()->group():
Route::lang(['es', 'it'])->group(function () { Route::get('/example', fn () => 'Hello, World!'); Route::get('/another', fn () => 'Another route'); });
All routes inside the group will inherit the locale definitions. A route can also extend the group's locales with its own lang() call — the locales are merged:
Route::lang(['es', 'it'])->group(function () { Route::get('/example', fn () => 'Hello, World!'); // es, it Route::get('/another', fn () => 'Another route') ->lang(['fr']); // es, it, fr });
Route Strategy
The route_strategy option in config/l10n.php controls how locale prefixes are applied:
prefix_except_default(default) keeps the fallback locale unprefixed (e.g./example,/es/ejemplo).prefixprefixes every locale and does not register an unprefixed route (e.g./en/example,/es/ejemplo).no_prefixuses translated URIs without locale prefixes (e.g./example,/ejemplo).
Note
config/l10n.php is created by publishing the package config: php artisan vendor:publish --tag=l10n-config.
Translating Route URIs
Manage route translations in dedicated language files. The expected file structure is as follows:
/lang ├── /es │ └── routes.php ├── /fr │ └── routes.php ├── /it │ └── routes.php
Inside your routes.php file, map the original route URI to a translated slug:
// lang/es/routes.php return [ 'example' => 'ejemplo', ];
If no translation is provided for a given locale, the original URI is used as-is.
Warning
With the no_prefix strategy, a translation that shares its domain and URI with the canonical route or an earlier translation is skipped and falls back to the canonical route.
Note
The key should be the route URI without the leading slash. For example, for Route::get('/example'), the key should be example.
Domain Translations
If your application uses domain-based routing, you can translate domains in the same routes.php language files. The key is the original domain string:
// lang/es/routes.php return [ 'example' => 'ejemplo', 'example.com' => 'es.example.com', ];
URL Generation
The package automatically replaces Laravel's default URL generator with LocalizedUrlGenerator, ensuring that the route() helper generates the correct URLs for the current locale without any extra configuration.
Note
If you need to use a custom URL generator, you can override it in your AppServiceProvider by aliasing your own implementation to the url service.
Using the route() and action() Helpers
Once the generator is registered, the route() helper will intelligently create URLs based on the current application locale.
- For the current locale: The helper automatically generates the correct URL based on the active language.
- For a specific locale: You can explicitly request a URL for a different language by passing the
langparameter to theroute()helper.
// Assuming the current locale is 'en' route('example'); // Returns "/example" // To generate a URL for a different locale route('example', ['lang' => 'fr']); // Returns "/fr/example" // If a translation exists for 'es' in lang/es/routes.php, the translated slug is used route('example', ['lang' => 'es']); // Returns "/es/ejemplo"
The action() helper works the same way:
action(Controller::class, ['lang' => 'es']); // Returns "/es/ejemplo"
Warning
lang is a reserved parameter name. The URL generator consumes it to select the locale, so it never reaches the route: a route defining its own {lang} parameter (e.g. /translate/{lang}/text) cannot be generated via route() or action().
Route Caching
Localized routes are fully compatible with Laravel's route caching, with no custom cache setup required. When routes are cached, the package skips route generation at runtime (the localized variants are already included in the cache).
php artisan route:cache
Locale Preference
This package provides a mechanism for automatically detecting a user's preferred language.
The SetPreferredLocale middleware is responsible for populating the preferred locale. It does this by checking a series of configurable preferred locale resolvers.
By default, the package checks the following sources in order:
- SessionLocale: Checks if a locale was set in the session.
- UserLocale: Checks if the authenticated user has a preferred locale (the user model must implement Laravel's
Illuminate\Contracts\Translation\HasLocalePreferenceinterface). - BrowserLocale: Falls back to the browser's
Accept-Languageheader.
Customizing Resolvers
You can customize the resolvers by setting the static property on the L10n class. Do this in the boot() method of a service provider, such as AppServiceProvider:
use Goodcat\L10n\L10n; use Goodcat\L10n\Resolvers\BrowserLocale; L10n::$preferredLocaleResolvers = [ new BrowserLocale, ];
Creating a Custom Resolver
Implement the LocaleResolver interface to create your own resolver:
use Goodcat\L10n\Resolvers\LocaleResolver; use Illuminate\Http\Request; class CookieLocale implements LocaleResolver { public function resolve(Request $request): ?string { return $request->cookie('locale'); } }
Then add it to the resolver chain:
L10n::$preferredLocaleResolvers = [ new CookieLocale, new SessionLocale, new UserLocale, new BrowserLocale, ];
Helpers
This package adds several helper methods to your Laravel application.
Application Helpers
// Get the user's preferred locale app()->getPreferredLocale(); // Returns ?string // Set the user's preferred locale (dispatches PreferredLocaleUpdated event) app()->setPreferredLocale('es'); // Check if a locale is the fallback locale app()->isFallbackLocale('en'); // Returns bool
Route Helpers
// Get the locale served by a route. A route without l10n // metadata counts as the fallback locale. $request->route()->locale(); // Returns string
Route Matching
Use L10n::is() to check if the current route matches a given pattern, regardless of the locale:
L10n::is('dashboard'); // Matches /dashboard, /es/dashboard, /it/bacheca, etc. L10n::is('admin.*'); // Wildcard patterns are supported, just like Route::is()
This is the localized equivalent of Route::is(). It resolves the canonical route behind any localized variant and delegates to Route::named().
Components
The package provides Blade components for common localization needs.
Alternate Hreflang Links
The package provides a Blade component that generates <link rel="alternate" hreflang="..."> tags following Google's guidelines for localized versions.
Add the component to the <head> of your layout:
<head> <x-l10n::alternate /> </head>
For a route with es and it translations, this will render:
<link rel="alternate" hreflang="en" href="https://example.com/products/42" /> <link rel="alternate" hreflang="es" href="https://example.com/es/productos/42" /> <link rel="alternate" hreflang="it" href="https://example.com/it/products/42" /> <link rel="alternate" hreflang="x-default" href="https://example.com/products/42" />
The component includes all localized variants, the fallback locale, and an x-default entry pointing to the canonical route. It renders nothing for routes without translations.
Locale Switcher
The package provides a Blade component that renders a <select> element for switching between available locales. When the user selects a different locale, the page reloads to the corresponding localized URL.
<x-l10n::switcher />
For a route with es and it translations, this will render:
<select onchange="window.location = this.value"> <option value="https://example.com/products/42" selected>en</option> <option value="https://example.com/es/productos/42">es</option> <option value="https://example.com/it/products/42">it</option> </select>
The current locale is automatically selected. The component renders nothing for routes without translations.
You can pass any HTML attribute to the component:
<x-l10n::switcher class="locale-select" id="locale" />
Customizing the Templates
To customize the HTML output of the Blade components, publish the views:
php artisan vendor:publish --tag=l10n-views
This copies the templates to resources/views/vendor/l10n/components/. Each template documents the available variables in a docblock at the top of the file.
Localized Views
The application's view loader is configured to automatically search for a localized version of a view before falling back to the generic one.
When you render a view, the system follows a specific search order based on the current application locale.
- Locale-specific path: The application first tries to find the view within a folder that matches the current locale. For example, if the locale is set to
it, it will look for theexampleview inresources/views/it/example.blade.php. - Generic path: If the view is not found in the locale-specific folder, it will then fall back to the generic
resources/views/example.blade.php.
This makes it straightforward to organize your views with a clean, language-based folder structure, like the one below.
/resources/views
├── example.blade.php
├── /it
│ └── example.blade.php
└── /es
└── example.blade.php
The example.blade.php file in the root views folder can serve as your default template, while the localized versions (it/example.blade.php, es/example.blade.php) contain language-specific content or layouts.
JavaScript URL Generation
This package provides helper functions for generating localized URLs in your JavaScript/TypeScript frontend. Two stubs are available: one for Wayfinder and one for Ziggy. Given the following route definition:
Route::get('/foo/{id}', Controller::class) ->lang(['it', 'es']) ->name('foo');
Wayfinder
If you're using Wayfinder, publish the TypeScript helper:
php artisan vendor:publish --tag=l10n-wayfinder
Note
The route() helper only works with named routes, actions are not supported.
This creates a resources/js/l10n.ts file exporting a route(routes, args?) helper that selects the appropriate localized route based on the current locale.
Import the route helper and pass Wayfinder's generated route functions:
import { route } from '@/l10n'; import foo from '@/routes/foo'; const esUrl = route(foo, { id: 1, lang: 'es' }).url;
The locale is resolved in the following order:
- The
langparameter, if provided. - The
langattribute of the<html lang="en">element, normalized to Laravel's locale format (pt-BRmatches apt_BRroute). - The canonical route, when no localized route matches.
In the generated files the canonical route is exported under the __canonical key instead of the fallback locale name, while translations keep their locale keys (it, es). You only need the marker when calling the canonical route directly, without the route() helper: foo.__canonical({ id: 1 }).
Ziggy
If you're using Ziggy, publish the JavaScript helper:
php artisan vendor:publish --tag=l10n-ziggy
This creates a resources/js/l10n.js file with a route function.
Use route() as a drop-in replacement for Ziggy's route() function:
import { route } from '@/l10n'; route('foo', { id: 1, lang: 'it' });
The function automatically looks for a localized route by appending the locale to the route name (e.g., foo.es).
If a localized route exists, it uses that; otherwise, it falls back to the original route name.