Software localization

The Ultimate Guide to Laravel Localization

We dive deep into Laravel localization, covering routing, strings, and more.
Software localization blog category featured image | Phrase

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.

The image is a screenshot of a demo web app showcasing a book listing. It includes a header with the app name "bookavel" and two navigation links: "Books" and "About us." The section shown is titled "Recently added" and displays four books with their titles, authors, added dates, and prices in USD. The screenshot reflects the state of the app before localization is applied. The footer notes the app is a demo showcasing Laravel localization, created as a companion to a Phrase blog article.

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 quickly
  • kkomelin/laravel-translatable-string-exporter v1.22.0 — provides translation extraction from our code into message files
  • tailwindcss 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.

  1. Clone the GitHub repo and switch to the start branch (alternatively, download the ZIP of the start branch and unzip it)
  2. If you’re using Laravel Herd, make sure to place the project directory inside your Herd directory
  3. From the project root, run composer install
  4. Run npm install
  5. Duplicate the .env.example file and rename the copy to .env
  6. Run php artisan key:generate
  7. Run php artisan migrate --seed and select “Yes” when asked to create the SQLite database
  8. 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)
  9. 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 image is a screenshot of a demo web app showcasing a book listing. It includes a header with the app name "bookavel" and two navigation links: "Books" and "About us." The section shown is titled "Recently added" and displays four books with their titles, authors, added dates, and prices in USD. The screenshot reflects the state of the app before localization is applied. The footer notes the app is a demo showcasing Laravel localization, created as a companion to a Phrase blog article.

The app is simple:

  • A Book model is used by a BookController to expose a book index (under /books) and a single book show (under /books/{id}).
  • The root route (/) exposes BookController@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.

  1. Replacing hard-coded strings with translated strings using the Laravel () function.
  2. Extracting these translated strings from code into translation files.
  3. Localizing routes.
  4. Building a language switcher UI.
  5. Handling dynamic values in translations.
  6. Working with plurals in translations.
  7. Formatting localized numbers and dates.
  8. 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)
The navigation bar of our app with the brand name shown in Arabic.
Et voila! Our app name in Arabic!

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)

Our navigation links shown in Arabic.

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)

Our navigation links shown in Arabic, except the "About us" links, which is in English.

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)

Our navigation links shown in Arabic, except the "About us" link, which reads "nav.about.

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.

The image shows a comparison between two versions of the same web app header, one for the `en-US` locale and the other for `ar-EG`. The `en-US` version displays the app name "bookavel" with navigation links "Books" and "About us." The `ar-EG` version shows the same content in Arabic, with the app name and navigation links translated. The text explains that the `en-US` version uses keys provided to the translation function `__()`, while the `ar-EG` version uses corresponding values from an `ar-EG.json` file.

🗒️ 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_EGCode 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-routesCode 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.

Our app's home page shown with English nav links; the browser's address bar reads "localhost/en_US"

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.

Our app's home page shown with Arabic nav links; the browser's address bar reads "localhost/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.

The image is a screenshot of console output from running the `php artisan route:list` command in a Laravel application. It displays a list of 10 routes for two locales: `ar_EG` and `en_US`. The routes include paths like `/about`, `/books`, and `/books/{book}`, mapped to the `BookController` methods (`index`, `show`). There is also a fallback route `{fallbackPlaceholder}` mapped to the `FallbackController`. Each route is prefixed by either `GET|HEAD`, indicating the HTTP methods supported, and is followed by the corresponding localized route name (e.g., `ar_EG.root`, `en_US.books.show`).

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 call route('books.index') in one of our views, it will output `/en_US/books`.
  • If we’re on /ar_EG and call route('books.index') in one of our views, it will output `/ar_EG/books`.
  • If we’re on /en_US and call route('books.show', 1) in one of our views, it will output `/en_US/books/1`.
  • If we’re on /ar_EG and call route('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.

  1. A new user visits our site’s root route (/).
  2. No locale slug is on the / route, so the RouteActionDetector and UrlDetector have nothing to detect; they cascade down.
  3. This is the user’s first visit, so we haven’t stored their locale preference with the CookieStore yet. The CookieDetector has nothing to detect; it cascades down.
  4. 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.
  5. The AppDetector uses the locale configured in config/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-languageCode 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)
Our home page shown with Arabic content, laid out right-to-left.
Our Arabic routes now correctly show content laid out right-to-left.

🔗 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.

Animation showing a user clicking on the locale switcher dropdown to switch between English and Arabic versions of a page.

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.

The upper part of the English home page, showing an alert that reads "Hey Nunu, A Brief History of Time just arrived :)"

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_EGCode 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)

The upper part of the Arabic home page, showing an alert that reads, in Arabic, "Hey Nunu, A Brief History of Time just arrived :)"

🔗 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_EGCode 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.

The image compares pluralization rules in English (en-US) and Arabic (ar-EG). On the left, the English rules show two forms: "one" for singular ("One book") and "other" for plural ("2 books"). On the right, the Arabic rules display multiple plural forms: "zero" for no books, "one" for one book, "two" for two books, "few" for numbers like 3, "many" for numbers like 11, and "other" for larger numbers like 100.

🔗 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 FormatSetLocaleCode 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.

The image shows a table comparing number and currency formatting between the `en_US` and `ar_EG` locales. For `en_US`, formatting 1000 results in "1,000", while currency formatting for 1234.56 renders as "$1,234.56". In the `ar_EG` locale, formatting 1000 shows "١٠٠٠", and currency formatting for 1234.56 renders as "١٬٢٣٤٫٥٦ US$". The table illustrates differences in how numbers and currencies are displayed based on locale settings.

🔗 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:

The image shows three date and time formats in English. The first is "September 24th 2024, 2:37:06 pm" displaying the full date with time. The second is "September 24, 2024" showing the date only. The third is "52 minutes from now" expressing time in a relative format.

And when it’s ar_EG, we get:

The image shows three date and time formats in Arabic. The first is "٢:٣٧:٠٦ م, ٢٤ سبتمبر ٢٠٢٤" displaying the full date with time. The second is "٢٤ سبتمبر ٢٠٢٤" showing the date only. The third is "٥٢ دقيقة من الآن" expressing time in a relative format.

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 use ar.
  • 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.

Animation showing a few pages of our fully localized app in English, and the same pages in Arabic.

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.