Software localization
The Ultimate Guide to Laravel Localization
I remember using Laravel years ago to kick off two tech startups. I loved Laravel’s elegance, developer experience, and down-to-earth community. This was in the days of Laravel 3. We’re at version 11 of the framework as I write this, and to say that its ecosystem has matured would be an understatement. There is a quality Laravel package (often first-party) for almost any common dev problem, from authentication to deployment. The care and commitment the community continues to pour into Laravel is inspiring.
When it comes to Laravel internationalization (i18n), a good built-in foundation is complemented by third-party packages. In this guide, we’ll use these features to localize a Laravel app, covering translations, localized routing, formatting, localizing models, and more.
🔗 Resource » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.
Our demo
Our starting point will be the fictional bookavel, a used bookstore app.
We won’t cover any e-commerce functionality here, since we want to focus on i18n.
Packages and versions used
We’ll use the following technologies and packages when building this app.
- PHP v8.3.10 — our programming language
- Laravel v11.21.0 — the framework for web artisans
codezero/laravel-localized-routes
v4.0.1 — allows us to localize our routes quicklykkomelin/laravel-translatable-string-exporter
v1.22.0 — provides translation extraction from our code into message filestailwindcss
v3.4.10 — for styling; largely optional for our purposes
🗒️ Note » We’ll cover the installation of laravel-localized-routes
and laravel-translatable-string-exporter
a bit later. All other packages are included with our starter app.
The starter app
Normally, we build demo apps from scratch in our tutorials. However, we have lots to cover here, so we’ll work from a pre-built starter for brevity. Here’s how to install the starter app:
🗒️ Heads up » You’ll need PHP 8+, Composer, and Node.js 20+ installed to continue.
- Clone the GitHub repo and switch to the
start
branch (alternatively, download the ZIP of thestart
branch and unzip it) - If you’re using Laravel Herd, make sure to place the project directory inside your
Herd
directory - From the project root, run
composer install
- Run
npm install
- Duplicate the
.env.example
file and rename the copy to.env
- Run
php artisan key:generate
- Run
php artisan migrate --seed
and select “Yes” when asked to create the SQLite database - Run
php artisan serve
to start the dev server (you don’t need to do this if you’re using Laravel Herd; just find the app under Sites and visit its URL) - In a separate terminal tab, run
npm run dev
to start Vite
🗒️ Note » If you’re using Laravel Sail with Docker, we’ve provided a docker-compose.yml
file in the repo. However, Sail/Docker instructions are a bit outside the scope of this article.
If all goes well, you should see the demo app in all its glory when you visit the local URL.
The app is simple:
- A
Book
model is used by aBookController
to expose a bookindex
(under/books
) and a single bookshow
(under/books/{id}
). - The root route (
/
) exposesBookController@index
, presenting the book listing above. - An
/about
route shows a simple text view.
// routes/web.php
<?php
use App\Http\Controllers\BookController;
use Illuminate\Support\Facades\Route;
Route::get('/', [BookController::class, 'index'])->name('root');
Route::resource('books', BookController::class)->only('index', 'show');
Route::get('/about', fn () => view('about'))->name('about');
Code language: PHP (php)
This should be bread-and-butter Laravel for you. Let’s localize!
How do I localize a Laravel app?
Localizing a Laravel app involves the following steps.
- Replacing hard-coded strings with translated strings using the Laravel
()
function. - Extracting these translated strings from code into translation files.
- Localizing routes.
- Building a language switcher UI.
- Handling dynamic values in translations.
- Working with plurals in translations.
- Formatting localized numbers and dates.
- Localizing database models.
We’ll go through these steps in detail. Let’s start with basic string translation.
How do I translate strings?
Laravel has solid built-in localization features; we can use them for basic translations. First, let’s configure some localization settings in our .env
file.
We’ll support English – United States (en_US
) and Arabic – Egypt (ar_EG
) in this article. Let’s start by updating our active and fallback locales to be en_US
.
# .env
APP_NAME=bookavel
APP_ENV=local
APP_KEY=yourappkeyliveshere
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost
- APP_LOCALE=en
+ APP_LOCALE=en_US
- APP_FALLBACK_LOCALE=en
+ APP_FALLBACK_LOCALE=en_US
APP_FAKER_LOCALE=en
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
# ...
Code language: PHP (php)
The APP_LOCALE
environment variable determines the active locale. When a translation message is missing, the APP_FALLBACK_LOCALE
is used. We’ll see how both these variables work momentarily.
A note on locales
A locale defines a language, a region, and sometimes more. Locales typically use IETF BCP 47 language tags, like en
for English, fr
for French, and es
for Spanish. Adding a region with the ISO Alpha-2 code (e.g., BH
for Bahrain, CN
for China, US
for the United States) is recommended for accurate date and number localization. So a complete locale might look like en_US
for American English or zh_CN
for Chinese as used in China.
🔗 Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.
Now to add some translation strings. By default, Laravel expects translation files to sit in the lang
directory under the project root. Let’s create this directory, along with a subdirectory for each locale.
.
└── lang
├── ar_EG
│ └── # We'll place Arabic translation files here.
└── en_US
└── # We'll place English translation files here.
Code language: PHP (php)
We can have as many locale subdirectories under lang
as we want; Laravel will automatically pick them up. Under each locale subdirectory, we can have one or more translation files. Let’s create our first.
// lang/en_US/global.php
<?php
return [
'app_name' => 'bookavel',
];
Code language: PHP (php)
// lang/ar_EG/global.php
<?php
return [
'app_name' => 'بوكاڤِل',
];
Code language: PHP (php)
Translation files return PHP arrays with simple key/value pairs. To use them, we can call the __()
translation function in our code. Let’s translate the app name in our header component to demonstrate.
{{-- resources/views/components/layout/header.blade.php --}}
<header class="...">
<x-layout.logo />
<p class="...">
<a href="{{ route('root') }}">
- bookavel
+ {{ __('global.app_name') }}
</a>
</p>
<nav class="...">
<!-- ... -->
</nav>
</header>
Code language: HTML, XML (xml)
The __()
function takes a translation key in the form file.key
.
🗒️ Note » We omit CSS styles here for brevity, unless they relate directly to i18n.
Since we set the APP_LOCALE
value to en_US
in our .env
file, the lang/en_US/global.php
file will be loaded and used. If we refresh our app now, it should look the same.
Now let’s set our APP_LOCALE
to Arabic and reload the app.
# .env
# ...
APP_TIMEZONE=UTC
APP_URL=http://localhost
- APP_LOCALE=en_US
+ APP_LOCALE=ar_EG
APP_FALLBACK_LOCALE=en_US
APP_FAKER_LOCALE=en_US
# ...
Code language: PHP (php)
That’s basic string translation in a nutshell. We can use as many files as we want under our locale directories. Let’s add a couple for navigation links.
// lang/en_US/nav.php
<?php
return [
'books' => 'Books',
'about' => 'About us',
];
Code language: PHP (php)
// lang/ar_EG/nav.php
<?php
return [
'books' => 'الكتب',
'about' => 'نبذة عنا',
];
Code language: PHP (php)
In Blade templates, the @lang('foo.bar')
directive is a shortcut for {{ ('foo.bar) }}
.
{{-- resources/views/components/layout/header.blade.php --}}
<header class="...">
<x-layout.logo />
<p class="...">
<a href="{{ route('root') }}">
{{ __('global.app_name') }}
</a>
</p>
<nav class="...">
<ul class="...">
<li>
<x-layout.nav-link :href="route('books.index')">
- Books
+ @lang('nav.books')
</x-layout.nav-link>
</li>
<li>
<x-layout.nav-link :href="route('about')">
- About us
+ @lang('nav.about')
</x-layout.nav-link>
</li>
</ul>
</nav>
</header>
Code language: HTML, XML (xml)
Missing translations
If a translation is missing from our Arabic file, Laravel will fall back to the locale specified in APP_FALLBACK_LOCALE
. Let’s test this by removing the Arabic translation for “about.”
// lang/ar_EG/nav.php
<?php
return [
'books' => 'الكتب',
- 'about' => 'نبذة عنا',
];
Code language: PHP (php)
The en_US
value is used as a fallback for our missing translation.
What if the en_US
value itself is missing? In that case, Laravel shows us the translation key.
// lang/en_US/nav.php
<?php
return [
'books' => 'Books',
- 'about' => 'About us',
];
Code language: PHP (php)
Translation strings as keys
Using translation strings as keys can clarify our views. To do this, we need to change how we define our translations. First, we’ll pass English translation strings (verbatim) to __()
and @lang()
.
{{-- resources/views/components/layout/header.blade.php --}}
<header class="...">
<x-layout.logo />
<p class="...">
<a href="{{ route('root') }}">
- {{ __('global.app_name') }}
+ {{ __('bookavel') }}
</a>
</p>
<nav class="...">
<ul class="...">
<li>
<x-layout.nav-link :href="route('books.index')">
- @lang('nav.books')
+ @lang('Books')
</x-layout.nav-link>
</li>
<li>
<x-layout.nav-link :href="route('about')">
- @lang('nav.about')
+ @lang('About')
</x-layout.nav-link>
</li>
</ul>
</nav>
</header>
Code language: HTML, XML (xml)
Instead of PHP files under locale directories, we use a single JSON translation file per locale.
// lang/ar_EG.json
{
"bookavel": "بوكاڤِل",
"Books": "الكتب",
"About us": "نبذة عنا"
}
Code language: JSON / JSON with Comments (json)
🗒️ Note » We don’t need to add an en_US.json
file. Laravel’s fallback logic will ensure enUS
translations are shown when en_US
is the active locale.
When not given a file prefix like global.
or nav.
, the ()
function will pull translations from the {locale}.json
file (where {locale}
is the active locale). If the file or translation isn’t found, Laravel will default to the given key itself.
🗒️ Note » We’ll use the translation values as keys (JSON) method moving forward. However, JSON files can be used along with PHP files. If you call ('foo.bar')
, Laravel will look for a lang/{locale}/foo.php
file with a bar
key inside. A call like ('Baz')
will cause Laravel to look for a lang/{locale}.json
file with a Baz
key inside. Both methods can work side-by-side.
🔗 Resource » Read more about Defining Translation Strings in the Laravel docs.
How do I extract translations from my code?
Instead of manually adding each translation to each locale file (and we might have many), we can use a CLI tool to do it automatically.
Konstantin Komelin’s laravel-translatable-string-exporter is a popular choice for string extraction. Using this package, a simple Artisan command will scan our code and place translated strings into locale files. Let’s install it as a dev dependency.
composer require kkomelin/laravel-translatable-string-exporter --dev
Code language: JavaScript (javascript)
Now let’s hop into our book index view and mark a string for translation to see the new CLI tool in action.
{{-- resources/views/books/index.blade.php --}}
<x-layout.main title="Books">
<h1 class="...">
- Recently added
+ {{ __('Recently added') }}
</h1>
<section class="...">
@foreach ($books as $book)
{{-- ... --}}
@endforeach
</section>
</x-layout.main>
Code language: HTML, XML (xml)
Now to run the Artisan command.
php artisan translatable:export ar_EG
Code language: JavaScript (javascript)
As you’ve probably guessed, the ar_EG
argument tells translatable:export
to dump new translations into our lang/ar_EG.json
file.
🗒️ Note » We can have as many comma-separated locales as we want here. For example, php artisan translatable:export ar_EG,de_DE,hi_IN
will export new translations to ar_EG.json
, de_DE.json
and hiI_N.json
, respectively.
// lang/ar_EG.json
{
+ "Recently added": "Recently added",
"Books": "الكتب",
"About us": "بذة عنا",
"bookavel": "بوكاڤِل"
}
Code language: JavaScript (javascript)
The extracted string duplicates the key as its value. We can replace this with the appropriate translation.
// lang/ar_EG.json
{
- "Recently added": "Recently added",
+ "Recently added": "الجديد عندنا",
"Books": "الكتب",
"About us": "بذة عنا",
"bookavel": "بوكاڤِل"
}
Code language: JavaScript (javascript)
🗒️ Note » For larger projects, we can upload our translation files to a software localization platform like Phrase Strings so that translators can work on them.
By default, translatable:export
will scan all PHP and JS files under the app
and resources
directories for any calls to __()
, _t()
, or @lang()
, pulling any keys out of them. You can configure this behavior by publishing the package’s config file. We’ll do this later.
🗒️ Heads up » We haven’t mentioned the trans()
function. ()
is an alias of trans()
. If you’re using trans()
note that translatable:export
doesn’t scan for it by default.
Moving translation strings into as many locale files as our app needs is now significantly streamlined.
🔗 Resource » Check out the laravel-translatable-string-exporter documentation for the details on how the package works.
How do I localize routes?
One of the most common ways to set the app’s active locale is via routes. This could be a locale route param: /en_US/foo
and /ar_EG/foo
, where the first route segment sets the active locale. Visiting en_US/foo
shows the foo
page in English; visiting /ar_EG/foo
shows it in Arabic. We’ll use this localized routing strategy in this guide.
We’ll also grab a package to carry the heavy lifting for localized routing. We could roll our own, but CodeZero’s Laravel Localized Routes does a great job of it and saves us some time.
First, let’s install the package from the command line:
composer require codezero/laravel-localized-routes
Code language: JavaScript (javascript)
🗒️ Note » When asked to trust the package to execute code, enter y
for “yes”.
Next, let’s publish the package’s config file so we can modify it.
php artisan vendor:publish --provider="CodeZero\LocalizedRoutes\LocalizedRoutesServiceProvider" --tag="config"
Code language: JavaScript (javascript)
// config/localized-routes.php
<?php
return [
/**
* The locales you wish to support.
*/
- 'supported_locales' => [],
+ 'supported_locales' => ['en_US', 'ar_EG'],
/**
* The fallback locale to use when generating a route URL
* and the provided locale is not supported.
*/
- 'fallback_locale' => null,
+ 'fallback_locale' => 'en_US',
/**
* If you have a main locale, and you want to omit
* its slug from the URL, specify it here.
*/
'omitted_locale' => null,
/**
* Set this option to true if you want to redirect URLs
* without a locale slug to their localized version.
* You need to register the fallback route for this to work.
*/
- 'redirect_to_localized_urls' => false,
+ 'redirect_to_localized_urls' => true,
'redirect_status_code' => 301,
// ...
];
Code language: PHP (php)
Any locale not present in supported_locales
will trigger a 404 Not Found if its slug is used.
In the above, redirect_to_localized_urls
and fallback_locale
work together; if we visit /about
, we’re automatically redirected to /en_US/about
.
But for any of this to work we need two more pieces: middleware and route config. Let’s add the middleware first:
// bootstrap/app.php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
+ $middleware->web(remove: [
+ \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ ]);
+ $middleware->web(append: [
+ \CodeZero\LocalizedRoutes\Middleware\SetLocale::class,
+ \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ ]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
Code language: PHP (php)
The above ensures that the locale is set from the route before Laravel’s route model binding kicks in. This is important if you want to, say, translate article slugs. We won’t do this in this guide, but it’s good to know.
🔗 Resource » Read the Translate Parameters with Route Model Binding and Translate Hard-Coded URI Slugs sections of the docs for more info.
OK, now let’s update our routes to add localization:
// routes/web.php
<?php
use App\Http\Controllers\BookController;
+ use CodeZero\LocalizedRoutes\Controllers\FallbackController;
use Illuminate\Support\Facades\Route;
+ Route::localized(function () {
Route::get('/', [BookController::class, 'index'])->name('root');
Route::resource('books', BookController::class)->only('index', 'show');
Route::get('/about', fn () => view('about'))->name('about');
+ });
+ Route::fallback(FallbackController::class);
Code language: PHP (php)
Wrapping our routes with Route::localized
adds our locale slugs (en_US
, ar_EG
) before each route. We also specify the FallbackController::class
to handle Laravel’s catch-all fallback
route: This redirects /
to /en_US
.
If we visit the root (/
) route of our app now, we should indeed be redirected to /en_US
.
🗒️ Heads up » The Localized Routes package performs locale auto-detection based on the user’s browser settings. If your browser languages list a locale supported by your app, you might get redirected to that locale URI instead. We’ll tackle locale auto-detection in the next section.
We can manually visit /ar_EG
to see our home page translated into Arabic. Note how the Localized Link package sets the active app locale to match the URL slug behind the scenes. This means our ar_EG.json
strings are used when we hit a route prefixed with ar_EG
.
🗒️ Note » You can re-map the locale URI slugs to Custom Slugs if you prefer.
We can also run the route:list
artisan command to see our new localized routes.
Localized links and URLs
The Localized Routes package works with named routes out of the box: If we’re currently on an en_US
route then route('about')
outputs '/en_US/about'
.
// routes/web.php
<?php
// ...
Route::localized(function () {
// Explicit naming with `name()`
Route::get('/', [BookController::class, 'index'])->name('root');
// Implicit naming with `resource()` generates names:
// `books.index` and `books.show`
Route::resource('books', BookController::class)->only('index', 'show');
Route::get('/about', fn () => view('about'))->name('about');
});
// ...
Code language: PHP (php)
Let’s take the books resource as an example:
- If we’re on
/en_US
and callroute('books.index')
in one of our views, it will output`/en_US/books`
. - If we’re on
/ar_EG
and callroute('books.index')
in one of our views, it will output`/ar_EG/books`
. - If we’re on
/en_US
and callroute('books.show', 1)
in one of our views, it will output`/en_US/books/1`
. - If we’re on
/ar_EG
and callroute('books.show', 1)
in one of our views, it will output`/ar_EG/books/1`
.
If you want to force a route, you can call route('about', [], true, 'ar_EG')
: This outputs '/ar_EG/about'
even if we’re on an en_US
page.
🔗 Resource » See the Generate URLs for a Specific Locale section of the docs.
🔗 Resource » URL slugs are one strategy for localized routes. Another is custom domains e.g. en.example.com
, ar.example.com
. The Localized Routes package supports the domain strategy: See the Custom Domains section of the docs for more info.
How do I detect the user’s locale?
CodeZero’s Localized Routes package has an array of locale detectors, and locale stores, set up for us by default.
// config/localized-routes.php
<?php
return [
// ...
/**
* The detectors to use to find a matching locale.
* These will be executed in the order that they are added to the array!
*/
'detectors' => [
// required for scoped config
CodeZero\LocalizedRoutes\Middleware\Detectors\RouteActionDetector::class,
// required
CodeZero\LocalizedRoutes\Middleware\Detectors\UrlDetector::class,
// required for omitted locale
CodeZero\LocalizedRoutes\Middleware\Detectors\OmittedLocaleDetector::class,
CodeZero\LocalizedRoutes\Middleware\Detectors\UserDetector::class,
CodeZero\LocalizedRoutes\Middleware\Detectors\SessionDetector::class,
CodeZero\LocalizedRoutes\Middleware\Detectors\CookieDetector::class,
CodeZero\LocalizedRoutes\Middleware\Detectors\BrowserDetector::class,
// required
CodeZero\LocalizedRoutes\Middleware\Detectors\AppDetector::class,
],
// ...
/**
* The stores to store the first matching locale in.
*/
'stores' => [
CodeZero\LocalizedRoutes\Middleware\Stores\SessionStore::class,
CodeZero\LocalizedRoutes\Middleware\Stores\CookieStore::class,
// required
CodeZero\LocalizedRoutes\Middleware\Stores\AppStore::class,
],
];
Code language: PHP (php)
The detectors will run in order, and the first detector to find a locale will determine the active locale for the request, bypassing the rest of the detectors. Stores save a matched locale for a user’s subsequent site visits.
🔗 Resource » The Detectors section of the docs also has a table that further explains how they all work.
Before we proceed, let’s remove the detectors and stores we’re not using in this app.
// config/localized-routes.php
<?php
return [
// ...
/**
* The detectors to use to find a matching locale.
* These will be executed in the order that they are added to the array!
*/
'detectors' => [
// required for scoped config
CodeZero\LocalizedRoutes\Middleware\Detectors\RouteActionDetector::class,
// required
CodeZero\LocalizedRoutes\Middleware\Detectors\UrlDetector::class,
- // required for omitted locale
- CodeZero\LocalizedRoutes\Middleware\Detectors\OmittedLocaleDetector::class,
- CodeZero\LocalizedRoutes\Middleware\Detectors\UserDetector::class,
- CodeZero\LocalizedRoutes\Middleware\Detectors\SessionDetector::class,
CodeZero\LocalizedRoutes\Middleware\Detectors\CookieDetector::class,
CodeZero\LocalizedRoutes\Middleware\Detectors\BrowserDetector::class,
// required
CodeZero\LocalizedRoutes\Middleware\Detectors\AppDetector::class,
],
// ...
/**
* The stores to store the first matching locale in.
*/
'stores' => [
- CodeZero\LocalizedRoutes\Middleware\Stores\SessionStore::class,
CodeZero\LocalizedRoutes\Middleware\Stores\CookieStore::class,
// required
CodeZero\LocalizedRoutes\Middleware\Stores\AppStore::class,
],
];
Code language: PHP (php)
We’re not using an omitted locale in this app. An omitted locale would be used for URLs without a locale slug. Since we’re forcing locale slugs in our app, we can safely remove the OmittedLocaleDetector
here.
Since we don’t have authentication, and we’re not using Laravel’s User
model, we can remove the UserDetector
.
The SessionDetector
is redundant since we’ll utilize the CookieDetector
and CookieStore
to save the user’s locale preference. We can safely remove the SessionDetector
and SessionStore
here.
New user visit
Now let’s go through the scenario of a fresh visit to our site, illustrating how the detector cascade would work with our current configuration.
- A new user visits our site’s root route (
/
). - No locale slug is on the
/
route, so theRouteActionDetector
andUrlDetector
have nothing to detect; they cascade down. - This is the user’s first visit, so we haven’t stored their locale preference with the
CookieStore
yet. TheCookieDetector
has nothing to detect; it cascades down. - The
BrowserDetector
kicks in and tries to match one of the user’s configured browser locales with one of our supported locales. If it succeeds, the matched locale becomes the active locale for the request. If it fails, it cascades down. - The
AppDetector
uses thelocale
configured inconfig/app.php
as the final fallback.
Of particular interest to us is the BrowserDetector
: It detects the locale based on the request’s Accept-Language HTTP header, which corresponds to the user’s configured language list in the browser.
🔗 Resource » Our guide, Detecting a User’s Locale in a Web App, goes into Accept-Language and browser locale detection in depth.
Rolling our own browser detector
CodeZero’s built-in BrowserDetector
works reasonably well but doesn’t normalize locales during detection. For example, it doesn’t match ar-eg
in an Accept-Language header to our configured ar_EG
locale. The problem is that Laravel’s documentation instructs us to use ISO 15897 locale naming with underscores (ar_EG
). It’s common for browsers to use a dash to separate locales and regions, e.g. ar-eg
. So we’re between a rock and a hard place here.
The built-in BrowserDetector
doesn’t attempt a best match, either. So it won’t match a user’s configured en-gb
to our en_US
locale, even though British people can read American English just fine.
Thankfully, it’s easy enough to roll our own detector that solves these problems. We’ll begin by installing the popular http-accept-language
PHP package by Baguette HQ. This package will parse the Accept-Language header and provide it as a PHP array.
composer require zonuexe/http-accept-language
Code language: JavaScript (javascript)
We can use this package to write our own browser detector that performs a best match against the Accept-Language header.
// app/Http/Middleware/LocaleDetectors/AcceptLanguageDetector.php
<?php
namespace App\Http\Middleware\LocaleDetectors;
use CodeZero\LocalizedRoutes\Middleware\Detectors\Detector;
use Illuminate\Support\Arr;
use Teto\HTTP\AcceptLanguage;
class AcceptLanguageDetector implements Detector
{
/**
* Detect the locale.
*
* @return string|array|null
*/
public function detect()
{
// Get locales from the Accept-Language header
$accept_locales = AcceptLanguage::get();
// Get the languages parts only
$languages = array_unique(Arr::pluck(
$accept_locales, 'language'));
$supported_locales =
config('localized-routes.supported_locales');
foreach ($languages as $language) {
foreach ($supported_locales as $locale) {
if (str_starts_with(
$locale,
strtolower($language))) {
return $locale;
}
}
}
return null;
}
}
Code language: PHP (php)
We must implement CodeZero’s Detector
interface, which defines a detect
method for our custom detector to work with the Localized Routes package. In our detect()
, we first grab the user’s configured locales using the http-accept-language
parser package and do a best match against our app’s supported locales. Our detector will match the first case-insensitive language code regardless of region. If a user has set ar-SA
(Arabic Saudi-Arabia) in their browser, they will be matched with our supported ar_EG
and shown our site in Arabic.
Let’s wire up our new detector to see it in action.
// config/localized-routes.php
<?php
return [
// ...
'detectors' => [
CodeZero\LocalizedRoutes\Middleware\Detectors\RouteActionDetector::class,
CodeZero\LocalizedRoutes\Middleware\Detectors\UrlDetector::class,
CodeZero\LocalizedRoutes\Middleware\Detectors\CookieDetector::class,
- CodeZero\LocalizedRoutes\Middleware\Detectors\BrowserDetector::class
+ App\Http\Middleware\LocaleDetectors\AcceptLanguageDetector::class,
CodeZero\LocalizedRoutes\Middleware\Detectors\AppDetector::class,
],
// ...
],
];
Code language: PHP (php)
We get our desired behavior by swapping out the built-in BrowserDetector
with our own AcceptLanuageDetector
. If a user has en
, en-us
, or en_GB
as a preferred locale in her browser settings, she will be redirected from /
to /en_US
during her first site visit. If another user has ar
, ar-ma
, or ar_SA
as a preferred locale, he will be redirected from /
to /ar_EG
.
Of course, we need a language switcher UI to let the user override the locale we’ve chosen for them. We also need to cover how the locale is stored in a cookie. We’ll do this shortly.
But first, a quick detour to take care of locale direction.
How do I work with right-to-left languages?
Before we get to the language switcher, let’s deal with right-to-left (RTL) languages. Our Arabic content flows left-to-right, yet Arabic is read right-to-left.
🔗 Resource » We dive into locale direction, writing modes, fonts, and more in our guide to CSS Localization.
We can deal with this by creating a helper function that returns the active locale’s direction, and use this direction in our pages’ <html dir>
attribute. Let’s start by creating a config file to store our custom i18n settings, such as locale directions.
// config/i18n.php
<?php
return [
'rtl_locales' => [
'ar_EG',
],
];
Code language: PHP (php)
Most locales are LTR (left-to-right), so we can list the RTL locales and default to LTR. Currently, we only have one RTL locale, ar_EG
.
We can use this new config in a simple locale_dir()
function, which we’ll place in a new i18n-functions
file.
// app/i18n-functions.php
<?php
function locale_dir() : string
{
$rtl_locales = config('i18n.rtl_locales');
$locale = app()->getLocale();
return in_array($locale, $rtl_locales)
? "rtl"
: "ltr";
}
Code language: PHP (php)
Simple enough. We need to remember to register our new file for autoloading, of course.
// composer.json
{
"name": "laravel/laravel",
// ...
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
},
+ "files": [
+ "app/i18n-functions.php"
+ ]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
// ...
}
Code language: JavaScript (javascript)
For Composer to pick this up we must regenerate our autoload files.
composer dumpautoload
With this setup in place, we can call locale_dir()
in our main layout.
// resources/views/components/layout/main.blade.php
<!DOCTYPE html>
<html
lang="{{ str_replace("_", "-", app()->getLocale()) }}"
+ dir="{{ locale_dir() }}"
>
<head>
<!-- ... -->
</head>
<body class="...">
<!-- ... -->
</body>
</html>
Code language: HTML, XML (xml)
🔗 Resource » We often must ensure our CSS works in both directions, which we cover in CSS Localization.
How do I build a language switcher?
OK, let’s return to setting the active locale. We’re attempting to detect the user’s locale from their browser settings. However, the user should be able to override this selection manually. This is often achieved with a locale switcher UI. Let’s build ours.
First, let’s add an array to our i18n.php
config file that lists our supported locale codes with human-readable text for each.
// config/i18n.php
<?php
return [
+ 'supported_locales' => [
+ 'en_US' => 'English',
+ 'ar_EG' => 'العربية (Arabic)'
+ ],
'rtl_locales' => [
'ar_EG',
]
];
Code language: PHP (php)
We can now use this config in our new locale-switcher.blade.php
component.
// resources/views/components/layout/locale-switcher.blade.php
<div {{ $attributes->merge(["class" => "relative"]) }}>
<select
class="..."
autocomplete="off"
onchange="window.location = this.options[this.selectedIndex].getAttribute('data-url')"
>
@foreach (config("i18n.supported_locales") as $locale => $name)
<option
value="{{ $locale }}"
data-url="{{ Route::localizedUrl($locale) }}"
{{ $locale == app()->getLocale() ? "selected" : "" }}
>
{{ $name }}
</option>
@endforeach
</select>
<div class="...">
<x-layout.icon-chevron-down />
</div>
</div>
Code language: HTML, XML (xml)
Our locale selector is a simple <select>
element. When a user selects a new locale, it redirects them to the current page in the selected language. This is done using the Route::localizedUrl()
macro from the Localized Routes package.
🗒️ Note » We set autocomplete="off"
to work around an issue with Firefox; it doesn’t always select the correct option. See the Stack Overflow question, Firefox ignores option selected=”selected”, for more info.
A note on storage and detectors
Remember how we set up CookieDetector
and CookieStore
in config/localized-routes.php
? These are important for our locale switcher. When a locale is resolved, CookieStore
saves it in the user’s browser. Since CookieDetector
is listed before AcceptLanguageDetector
, it resolves the locale from the user’s cookie and stops the cascade.
When a user selects a new locale from the switcher, the URL updates, changing from something like /en_US/about
to /ar-EG/about
. This prompts the UrlDetector
to recognize the new locale, and the CookieStore
to save it. The next time the user visits, the CookieDetector
will automatically load the locale the user chose last.
How do I add dynamic values to translation messages?
Let’s direct our attention to translation strings and formatting. Laravel’s translation functions let you insert dynamic values into your messages using the :variable
format. For example, ('Hello, :username', ['username' => 'Adam'])
will interpolate ‘Adam’ at runtime, rendering ‘Hello, Adam’ when the code runs. Let’s add a simple user notification and translate it to see how that works.
// resources/views/books/index.blade.php
<x-layout.main title="Books">
+ <p class="...">
+ {{
+ __("👋 Hey :username, :book_title just came in :)", [
+ "username" => "Nunu",
+ "book_title" => "A Brief of History of Time",
+ ])
+ }}
+ </p>
<h1 class="...">{{ __("Recently added") }}</h1>
<!-- ... -->
</x-layout.main>
Code language: HTML, XML (xml)
When we view our home page in English, we see the username
and book_title
variables interpolated correctly.
We can extract this string into our ar_EG.json
file as usual (or copy/paste it manually if preferred).
php artisan translatable:export ar_EG
Code language: JavaScript (javascript)
We can then translate the new string, positioning :username
and :book_title
where appropriate in the translation message.
{
"About us": "بذة عنا",
"bookavel": "بوكاڤِل",
"Books": "الكتب",
"Recently added": "الجديد عندنا",
+ "👋 Hey :username, :book_title just came in :)": "👋 أهلاً :username، لقد وصل للتو :book_title :)"
}
Code language: JavaScript (javascript)
🔗 Resource » See the Laravel docs section, Replacing Parameters in Translation Strings, for more info.
How do I work with plurals?
Plurals often need special treatment in translation messages. It’s not just “one” and “other” forms like in English. Arabic has six plural forms, for example. Some languages only have one. So we need a way to provide different plural forms for each locale. Luckily, Laravel allows for this.
Let’s start simple. We’ll add a book counter next to our “Recently added” title.
// resources/views/books/index.blade.php
<x-layout.main title="Books">
<p class="...">
{{ __("👋 Hey :username, :book_title just came in :)", ["username" => "Nunu", "book_title" => "A Brief of History of Time"]) }}
</p>
<h1 class="...">{{ __("Recently added") }}</h1>
+ <p>
+ {{ trans_choice("One book|:count books", $books->count()) }}
+ </p>
<!-- ... -->
</x-layout.main>
Code language: HTML, XML (xml)
trans_choice
is Laravel’s function for handling plural translations, taking a message and an integer. The message is split into plural forms using the pipe character (|
). In English, when $books->count()
equals 1, it will render “One book.” Any other value, like 3, will show “3 books.”
We can control this behavior further by specifying ranges.
{{
trans_choice(
"{0} No books|{1} One book|[2,*] :count books",
$books->count(),
)
}}
Code language: PHP (php)
Now we’ll get these renders:
$books->count() == 0
renders'No books'
$books->count() == 1
renders'One book'
$books->count() == 38
renders'38 books'
Let’s tackle the Arabic translation. First, another quick detour.
Adding trans_choice to the extraction function list
The Translatable String Exporter package doesn’t scan for trans_choice
out of the box, but we can tell it to. Let’s publish the package’s config to do that.
php artisan vendor:publish --provider="KKomelin\TranslatableStringExporter\Providers\ExporterServiceProvider"
Code language: JavaScript (javascript)
The command should have created a laravel-translatable-string-exporter.php
file in our config
directory. We can add trans_choice
under functions
to tell the exporter to track it.
// config/laravel-translatable-string-exporter.php
<?php
return [
'directories' => ['app', 'resources'],
'excluded-directories' => [],
'patterns' => [
'*.php',
'*.js',
],
'allow-newlines' => false,
'functions' => [
'__',
'_t',
'@lang',
+ 'trans_choice',
],
'sort-keys' => true,
// ...
];
Code language: PHP (php)
Now let’s run our translation export command as usual.
php artisan translatable:export ar_EG
Code language: JavaScript (javascript)
Our plural string should get added to lang/ar_EG.json
. As mentioned earlier, Arabic has six translation forms, and we can add them separated by the |
character.
{
"About us": "بذة عنا",
"bookavel": "بوكاڤِل",
"Books": "الكتب",
"Recently added": "الجديد عندنا",
+ "{0} No books|{1} One book|[2,*] :count books": "لا توجد كتب|كتاب واحد|كتابين|:count كتب|:count كتاب|:count كتاب",
"👋 Hey :username, :book_title just came in :)": "👋 أهلاً :username، لقد وصل للتو :book_title :)"
}
Code language: JavaScript (javascript)
🔗 Resource » The canonical source for languages’ plural forms is the CLDR Language Plural Rules listing.
🗒️ Note » Even though it’s not documented, Laravel’s trans_choice
supports CLDR plural rules, so specifying six plural forms for Arabic works out of the box.
🔗 Resource » Read our Guide to Localizing Plurals for a deeper dive.
How do I format numbers?
Laravel has built-in number-formatting helpers that are locale-aware.
// In our views
{{ Number::format(1000) }}
// => '1,000'
{{ Number::currency(1234.56) }}
// => '$1,234.56'
Code language: PHP (php)
We can add a locale
param when calling these functions.
// In our views
{{ Number::format(1000, locale: 'ar_EG') }}
// => '١٬٠٠٠'
{{ Number::currency(1234.56, locale: 'ar_EG') }}
// => '١٬٢٣٤٫٥٦ US$'
Code language: PHP (php)
🗒️ Note » Numbers and dates are often formatted differently in each region, so it’s a good idea to use region qualifiers when setting our locales e.g. using en_US
instead of en
.
However, instead of manually adding the active locale, we can write a custom middleware that sets the locale used by all number formatters.
php artisan make:middleware FormatSetLocale
Code language: CSS (css)
// app/Http/Middleware/FormatSetLocale.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Number;
use Symfony\Component\HttpFoundation\Response;
class FormatSetLocale
{
public function handle(Request $request, Closure $next) : Response
{
// Set the locale to use for all number formatters
// as the active locale.
Number::useLocale(app()->getLocale());
return $next($request);
}
}
Code language: PHP (php)
We need to register this middleware after the SetLocale
middleware to ensure the Localized Routes package has set the app locale before we use it.
// bootstrap/app.php
<?php
+ use App\Http\Middleware\FormatSetLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->web(remove: [
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
$middleware->web(append: [
\CodeZero\LocalizedRoutes\Middleware\SetLocale::class,
+ FormatSetLocale::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
Code language: PHP (php)
Now we can call the Number
functions without an explicit locale, and they will use the app’s active locale.
🔗 Resource » Our Concise Guide to Number Localization goes into numeral systems, separators, currency, and more.
How do I format dates?
Under the hood, Laravel uses the PHP Carbon library to represent and format datetimes. And, unlike numbers, Laravel’s Carbon instances will automatically use the app’s active locale.
// In our views
<?php $datetime = Illuminate\Support\Carbon::create(2024, 9, 24, 14, 37, 6); ?>
<p>{{ $datetime->isoFormat("MMMM Do YYYY, h:mm:ss a") }}</p>
<p>{{ $datetime->isoFormat("LL") }}</p>
<p>{{ $datetime->diffForHumans() }}</p>
Code language: PHP (php)
When the active locale is en_US
, the above will output:
And when it’s ar_EG
, we get:
Some notes on date localization
- The
isoFormat()
function is compatible with Moment.js formats. - See the Lang directory in the Carbon source code for all supported locales. Note that Carbon will fall back ie. if it can’t find
ar_XX
it will usear
. - The default timezone that Laravel sets for our app is UTC, which is often what we want. You can change this in
config/app.php
, however. - Laravel’s Eloquent models’ datetimes, e.g.
timestamps
, return Carbon instances.
🔗 Resource » Our Guide to Date and Time Localization dives into formatting, time zones, calendars, and more.
How do I localize my Eloquent models?
This guide is getting a bit long, and model/database localization could take up its own article. So while we won’t get into the details of model localization in this tutorial, we’ve created a separate branch in our GitHub repo that should help. Here’s how to use it:
1. Clone or download the model-l10n branch from our GitHub repo. (Alternatively, check out the branch if you’ve already cloned the repo).
2. Run composer install
3. Run php artisan migrate:refresh --seed
4. Run your dev server as usual
We’re using the spatie/laravel-translatable package, which is great for localizing Eloquent models in apps with a few locales. The laravel-translatable package requires refactors to both our migrations and our model classes.
🔗 Resource » Peruse the main → model-l10n diff to see this refactor.
🔗 Resource » See the official laravel-translatable docs for more info.
And with that, our demo is complete.
Power up your Laravel localization
We hope you enjoyed this journey through Laravel localization. When you’re all set to start the translation process, rely on the Phrase Localization Platform to handle the heavy lifting. A specialized software localization platform, the Phrase Localization Platform comes with dozens of tools designed to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket.
With its user-friendly strings editor, translators can effortlessly access your content and transfer it into your target languages. Once your translations are set, easily integrate them back into your project with a single command or automated sync.
This way, you can stay focused on what you love—your code. Sign up for a free trial and see for yourself why developers appreciate using The Phrase Localization Platform for software localization.