5. Internationalization
An internationalized application displays its text in the user’s language. The case study is in French and English. The principle is simple: the views no longer contain text, but keys ([MENU.HOME]), and a dictionary for each language associates a text with each key. The selected language is stored in a cookie.
[Laravel] has its own translation system, which we already encountered in Example 09 for validation messages. It accepts two types of dictionaries, stored in the [lang] folder: [PHP] files, one per theme and per language ([lang/fr/validation.php]), which return an array of keys; and JSON files, one per language ([lang/fr.json]), where each key is an arbitrary string. We use the second format: apart from the parameter syntax, these are the same dictionaries as those in versions [ASP.NET Core MVC], [NestJS], [Spring MVC], and [Flask] from the case study, and their format can be read and written without any special tools. The formatting of dates and amounts remains the same as in Example 16.
5.1. Example 22: A Bilingual Application
| 22-i18n/
├── app/
│ ├── Helpers/
│ │ └── Formats.php
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── LanguageController.php
│ │ │ └── PagesController.php
│ │ └── Middleware/
│ │ └── SetLanguage.php
│ └── Models/
│ └── Product.php
├── lang/
│ ├── en.json
│ └── fr.json
├── resources/
│ └── views/
│ ├── fragments/
│ │ └── navbar.blade.php
│ ├── layout.blade.php
│ └── pages/
│ ├── home.blade.php
│ └── products.blade.php
└── ...
|
5.1.1. The Dictionaries
The file [lang/fr.json]:
| {
"MENU.HOME": "Accueil",
"MENU.PRODUCTS": "Produits",
"HOME.TITLE": "Bienvenue",
"HOME.HELLO": "Bonjour :name !",
"HOME.DATE": "Nous sommes le :date.",
"PRODUCTS.TITLE": "Nos produits",
"PRODUCTS.NAME": "Produit",
"PRODUCTS.PRICE": "Prix",
"PRODUCTS.COUNT": ":n produit(s) au catalogue."
}
|
A JSON dictionary is an object in which each pair associates a key with its text. Keys are grouped by theme based on their prefix: the menu text “Accueil” has the key [MENU.HOME]. A text may contain placeholders, written as [:name], which are replaced during translation. (The dictionaries in the other versions of the case study nest the objects JSON — [{"MENU": {"HOME": "Accueil"}}] — and write the parameters as [{name}]; those in [Laravel] are “flat” and use the [:name] syntax.) The [lang/en.json] file has exactly the same keys:
| {
"MENU.HOME": "Home",
"MENU.PRODUCTS": "Products",
"HOME.TITLE": "Welcome",
"HOME.HELLO": "Hello :name!",
"HOME.DATE": "Today is :date.",
"PRODUCTS.TITLE": "Our products",
"PRODUCTS.NAME": "Product",
"PRODUCTS.PRICE": "Price",
"PRODUCTS.COUNT": ":n product(s) in the catalogue."
}
|
If a key is missing from the JSON dictionary for the current language, it is searched for in the [PHP] files in that language’s folder ([lang/fr/*.php], example 09), and then in those for the fallback language ([APP_FALLBACK_LOCALE], English by default). There is no backup dictionary (JSON): a key missing from the current language’s dictionary (JSON) and from these files is displayed as-is. This way, any omission is immediately visible on the screen.
5.1.2. The language of each request: app/Http/Middleware/SetLanguage.php
| <?php
// app/Http/Middleware/SetLanguage.php: the language of each request
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class SetLanguage
{
// the supported languages; the first one is the default language
public const LANGUAGES = ['en', 'fr'];
public const COOKIE = 'language';
// the language of the [language] cookie if it is supported, English otherwise
public function handle(Request $request, Closure $next): Response
{
$language = $request->cookie(self::COOKIE);
App::setLocale(in_array($language, self::LANGUAGES, true) ? $language : self::LANGUAGES[0]);
return $next($request);
}
}
|
- lines 14–16: the supported languages and the cookie name: public class constants, accessible from other classes ([SetLanguage::LANGUAGES]);
- lines 21–22: Before each action, the request language: the language specified in the [language] cookie if one exists and is supported; otherwise, English. [$request->cookie(name)] returns the value of a cookie sent by the browser, or [null]. [in_array], with [true] as the third parameter, also compares the types ([===]). [App::setLocale] (example 16) sets the application language for the request: this is the language used for translations and formatting.
5.1.3. The configuration: bootstrap/app.php
| <?php
// bootstrap/app.php: the application of example 22
use App\Http\Middleware\SetLanguage;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Blade;
(require __DIR__.'/../../vendor/autoload.php')->addPsr4('App\\', __DIR__.'/../app/');
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
// the route that serves Bootstrap (see example 15)
then: fn () => require __DIR__.'/../../common/bootstrap.php')
->withMiddleware(function (Middleware $middleware): void {
// the [language] cookie is not encrypted: it contains nothing secret, and
// the server accepts only the supported languages
$middleware->encryptCookies(except: [SetLanguage::COOKIE]);
// the language of each request: that of the [language] cookie, English otherwise
$middleware->web(append: SetLanguage::class);
})
->withExceptions()
->booted(function () {
// in the views: @money($price) (see example 16)
Blade::directive('money', fn (string $expression) =>
"<?php echo e(\\App\\Helpers\\Formats::money($expression)); ?>");
})
->create();
|
- Line 19: By default, [Laravel] encrypts all cookies it sets and decrypts those it receives (this is the role of the [EncryptCookies] middleware in the [web] group; see Example 20): The user cannot read or create an encrypted cookie. The [language] cookie contains no secret information, and the server accepts only supported languages: we exclude it from encryption, which allows us to view (and create) it using [curl]. The following example will revisit this encryption;
- line 21: the language middleware, at the end of the [web] group (Example 16): it runs after [EncryptCookies] and therefore reads cookies that have already been decrypted;
- lines 26–27: the [@money] directive from Example 16.
The class [App\Helpers\Formats] is the one from Example 16, with one difference: the date is formatted in the “long” format for each language:
| <?php
// app/Helpers/Formats.php: dates and amounts, formatted in the language of the request (see example 16)
namespace App\Helpers;
use Brick\Math\BigDecimal;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Number;
class Formats
{
// an amount in euros
public static function money(BigDecimal $amount): string
{
return Number::currency($amount->toFloat(), in: 'EUR', locale: App::getLocale());
}
// a date in full words. The "dddd LL" format: the day of the week, then the
// date in the "long" format of the language: "mercredi 23 septembre 2026" in French,
// "Wednesday September 23, 2026" in English
public static function longDate(CarbonInterface $day): string
{
return $day->locale(App::getLocale())->isoFormat('dddd LL');
}
}
|
- Line 24: [LL] is a localized format: each language has its own ([23 septembre 2026] in French, [September 23, 2026] in English), whereas [D MMMM YYYY] would impose the French order on all languages. [dddd] adds the day of the week.
5.1.4. Language selection: app/Http/Controllers/LanguageController.php
| <?php
// app/Http/Controllers/LanguageController.php: the choice of the language
namespace App\Http\Controllers;
use App\Http\Middleware\SetLanguage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class LanguageController
{
// GET /language/fr: stores the choice in a cookie, then goes back to the previous page
public function change(Request $request, string $code): RedirectResponse
{
abort_unless(in_array($code, SetLanguage::LANGUAGES, true), 404);
// the cookie: one year of life (in minutes), unreadable by the JavaScript of the
// page (httpOnly), never sent with a POST request coming from another site (sameSite)
return redirect(self::originPage($request))
->withCookie(cookie(SetLanguage::COOKIE, $code, 365 * 24 * 60, httpOnly: true, sameSite: 'lax'));
}
// the page we come from (Referer header), of which we keep only the path:
// redirecting to a complete address coming from the request would allow
// sending the user to another site ("open redirect" flaw)
private static function originPage(Request $request): string
{
// parse_url splits a URL into its components (path, parameters...)
$url = parse_url($request->header('Referer', ''));
$path = $url['path'] ?? '';
// "//site.com/..." would be understood by the browser as an address
// on ANOTHER site: refused too
if (! str_starts_with($path, '/') || str_starts_with($path, '//')) {
return '/';
}
return isset($url['query']) ? "$path?{$url['query']}" : $path;
}
}
|
- line 15: an unknown language code: 404;
- lines 18–19: the action responds with a redirect to the page the user came from; the user is taken back to that page in the new language. The response includes a cookie, which the browser will send back with every request: the function [cookie] creates it, and the method [withCookie] adds it to the response. Its parameters: the name, the value, the lifetime in minutes (one year), and two named parameters: [httpOnly: true] makes it invisible to the [JavaScript] on the page; [sameSite: 'lax'] prevents the browser from sending it with a POST request originating from another site;
- lines 25–36: the original page is specified by the [Referer] header in the request (the misspelled version, with a single [r], is the one used in the HTTP standard), which the browser fills in with the URL from the page that contained the link. But this header comes from the client, which can put anything in it. Blindly redirecting to this address would turn our application into an *open redirect*: a malicious link bearing our domain name could send the user to a malicious site. Therefore, we retain only the path and parameters from URL (the [parse_url] function from [PHP] breaks it down into its components, storing them in an associative array), and we reject any path that begins with [//], which the browser would interpret as the address of another site. [Laravel] also provides [redirect()->back()] (or [back()]), which redirects to the previous page stored in the session (example 20), and not to the header [Referer]: we will use this in the case study.
5.1.5. Actions: app/Http/Controllers/PagesController.php
| <?php
// app/Http/Controllers/PagesController.php: the actions know nothing about the language:
// they provide data, the views translate and format them
namespace App\Http\Controllers;
use App\Models\Product;
use Brick\Math\BigDecimal;
use Carbon\CarbonImmutable;
use Illuminate\View\View;
class PagesController
{
public function home(): View
{
return view('pages.home', ['name' => 'Marie',
'today' => CarbonImmutable::create(2026, 9, 23)]);
}
public function products(): View
{
return view('pages.products', ['products' => [
new Product('Keyboard', BigDecimal::of('25')),
new Product('Mouse', BigDecimal::of('12.5')),
new Product('Screen', BigDecimal::of('1149')),
]]);
}
}
|
Actions are language-agnostic: they provide raw data (a name, a date, amounts). Translation and formatting are handled by the views.
5.1.6. Translate in the views
| <!doctype html>
{{-- app()->getLocale(): the language of the request, "en" or "fr" --}}
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
{{-- the title: the [title] section of the page, already translated --}}
<title>@yield('title')</title>
<link rel="stylesheet" href="{{ route('bootstrap', 'css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
<div class="container py-3">
@include('fragments.navbar')
<main>
@yield('content')
</main>
</div>
</body>
</html>
|
- line 3: the page’s [lang] attribute specifies its language to browsers, search engines, and screen readers;
- line 7: the page title is provided by the page itself, already translated (section [title]).
| <nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
<div class="navbar-nav me-auto">
{{-- __('KEY'): the translation of a key, in the language of the request --}}
<a class="nav-link" href="/">{{ __('MENU.HOME') }}</a>
<a class="nav-link" href="/products">{{ __('MENU.PRODUCTS') }}</a>
</div>
{{-- the current language is highlighted --}}
<div class="btn-group btn-group-sm">
@foreach (['fr', 'en'] as $code)
<a @class(['btn', 'btn-primary' => app()->isLocale($code), 'btn-outline-primary' => ! app()->isLocale($code)])
href="/language/{{ $code }}">{{ strtoupper($code) }}</a>
@endforeach
</div>
</nav>
|
- lines 4–5: The function [__] (with two underscores) returns the translation of a key in the language of the request;
- Lines 9–12: One button per language, which triggers the [/language/{code}] action. The current language is highlighted ([btn-primary], solid button): [app()->isLocale($code)] checks the language of the request.
| @extends('layout')
@section('title', __('HOME.TITLE'))
@section('content')
<h1>{{ __('HOME.TITLE') }}</h1>
{{-- a parameter: the second parameter of __, an array --}}
<p>{{ __('HOME.HELLO', ['name' => $name]) }}</p>
{{-- the date, formatted first, then passed as the [date] parameter --}}
<p>{{ __('HOME.DATE', ['date' => App\Helpers\Formats::longDate($today)]) }}</p>
@endsection
|
- Line 2: The [title] section, translated, for the template;
- line 7: the parameters for a text are provided in an associative array, the second parameter of [__]; [:name] is replaced by the value of the key [name];
- line 9: the date, formatted in the query language using the [longDate] method, then passed as a parameter. A directive ([@longDate]) cannot be used here: it writes directly to the page and does not return a value.
| @extends('layout')
@section('title', __('PRODUCTS.TITLE'))
@section('content')
<h1>{{ __('PRODUCTS.TITLE') }}</h1>
<table class="table table-sm w-auto">
<thead><tr><th>{{ __('PRODUCTS.NAME') }}</th><th class="text-end">{{ __('PRODUCTS.PRICE') }}</th></tr></thead>
<tbody>
@foreach ($products as $product)
<tr>
<td>{{ $product->name }}</td>
<td class="text-end">@money($product->price)</td>
</tr>
@endforeach
</tbody>
</table>
<p>{{ __('PRODUCTS.COUNT', ['n' => count($products)]) }}</p>
@endsection
|
- Line 17: a calculated parameter. The product names, however, are not translated: they are data that, in a real application, would come from a database.
5.1.7. Execution
The home page, in English (the default language), then in French after clicking the “FR” button:


The products page, in both languages. The amounts follow the conventions of each language:


The language switch, as seen with [curl]. The option [-H] provides the header [Referer] that the browser would send:
| curl -i -H "Referer: http://localhost:8000/products" http://localhost:8000/language/en
|
| HTTP/1.1 302 Found
Location: http://localhost:8000/products
Set-Cookie: language=en; expires=Sun, 26 Sep 2027 16:18:24 GMT; Max-Age=31536000; path=/; httponly; samesite=lax
...
|
The redirect takes you back to the original page, and the cookie is set for one year, in plain text. A [Referer] that points to another site does not redirect you there: only its path is retained.
| curl -i -H "Referer: https://pirate.example/trap" http://localhost:8000/language/en
curl -i -H "Referer: http://localhost:8000//pirate.example/x" http://localhost:8000/language/fr
|
| HTTP/1.1 302 Found
Location: http://localhost:8000/trap
...
HTTP/1.1 302 Found
Location: http://localhost:8000
...
|
The page [/trap] does not exist on our site: the user will receive a 404 error but will remain on our site. Finally, the home page, requested with the cookie [language=en] (option [-b] of [curl]); since English is the default language, the page is the same as without the cookie:
| curl -b "language=en" http://localhost:8000/
|
| <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Welcome</title>
...
<a class="nav-link" href="/">Home</a>
<a class="nav-link" href="/products">Products</a>
...
<h1>Welcome</h1>
<p>Hello Marie!</p>
<p>Today is Wednesday September 23, 2026.</p>
...
|
Another way to select the language would have been to read the browser’s [Accept-Language] header (Example 12). The two are often combined: the browser’s language on the first visit, followed by the language the user has selected. This case study focuses on the cookie, with English as the default.