5. Internationalisation
Une application internationalisée affiche ses textes dans la langue de l'utilisateur. L'étude de cas est en français et en anglais. Le principe est simple : les vues ne contiennent plus de textes, mais des clés ([MENU.ACCUEIL]), et un dictionnaire par langue associe à chaque clé son texte. La langue choisie est mémorisée dans un cookie.
[Laravel] possède son propre système de traduction, déjà rencontré à l'exemple 09 pour les messages de validation. Il accepte deux formes de dictionnaires, rangés dans le dossier [lang] : des fichiers [PHP], un par thème et par langue ([lang/fr/validation.php]), qui rendent un tableau de clés ; et des fichiers JSON, un par langue ([lang/fr.json]), dont chaque clé est une chaîne quelconque. Nous utilisons la seconde forme : ce sont, à la syntaxe des paramètres près, les mêmes dictionnaires que ceux des versions [ASP.NET Core MVC], [NestJS], [Spring MVC] et [Flask] de l'étude de cas, et leur format se lit et s'écrit sans outil particulier. La mise en forme des dates et des montants reste celle de l'exemple 16.
5.1. Exemple 22 : une application en deux langues
| 22-i18n/
├── app/
│ ├── Aides/
│ │ └── Formats.php
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── LangueController.php
│ │ │ └── PagesController.php
│ │ └── Middleware/
│ │ └── DefinirLangue.php
│ └── Models/
│ └── Produit.php
├── lang/
│ ├── en.json
│ └── fr.json
├── resources/
│ └── views/
│ ├── fragments/
│ │ └── navbar.blade.php
│ ├── layout.blade.php
│ └── pages/
│ ├── accueil.blade.php
│ └── produits.blade.php
└── ...
|
5.1.1. Les dictionnaires
Le fichier [lang/fr.json] :
| {
"MENU.ACCUEIL": "Accueil",
"MENU.PRODUITS": "Produits",
"ACCUEIL.TITRE": "Bienvenue",
"ACCUEIL.BONJOUR": "Bonjour :nom !",
"ACCUEIL.DATE": "Nous sommes le :date.",
"PRODUITS.TITRE": "Nos produits",
"PRODUITS.NOM": "Produit",
"PRODUITS.PRIX": "Prix",
"PRODUITS.NOMBRE": ":n produit(s) au catalogue."
}
|
Un dictionnaire JSON est un objet dont chaque couple associe une clé à son texte. Les clés sont regroupées par thème grâce à leur préfixe : le texte « Accueil » du menu a pour clé [MENU.ACCUEIL]. Un texte peut contenir des paramètres, écrits [:nom], remplacés au moment de la traduction. (Les dictionnaires des autres versions de l'étude de cas imbriquent les objets JSON — [{"MENU": {"ACCUEIL": "Accueil"}}] — et écrivent les paramètres [{nom}] ; ceux de [Laravel] sont « à plat », et utilisent la syntaxe [:nom].) Le fichier [lang/en.json] a exactement les mêmes clés :
| {
"MENU.ACCUEIL": "Home",
"MENU.PRODUITS": "Products",
"ACCUEIL.TITRE": "Welcome",
"ACCUEIL.BONJOUR": "Hello :nom!",
"ACCUEIL.DATE": "Today is :date.",
"PRODUITS.TITRE": "Our products",
"PRODUITS.NOM": "Product",
"PRODUITS.PRIX": "Price",
"PRODUITS.NOMBRE": ":n product(s) in the catalogue."
}
|
Une clé absente du dictionnaire JSON de la langue courante est cherchée dans les fichiers [PHP] du dossier de cette langue ([lang/fr/*.php], exemple 09), puis dans ceux de la langue de secours ([APP_FALLBACK_LOCALE], l'anglais par défaut). Il n'y a pas de dictionnaire JSON de secours : une clé absente du dictionnaire JSON de la langue courante, et de ces fichiers, est affichée telle quelle. Un oubli se voit ainsi tout de suite à l'écran.
5.1.2. La langue de chaque requête : app/Http/Middleware/DefinirLangue.php
| <?php
// app/Http/Middleware/DefinirLangue.php : la langue de chaque requête
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class DefinirLangue
{
// les langues gérées ; la première est la langue par défaut
public const LANGUES = ['fr', 'en'];
public const COOKIE = 'langue';
// la langue du cookie [langue] si elle est gérée, le français sinon
public function handle(Request $request, Closure $next): Response
{
$langue = $request->cookie(self::COOKIE);
App::setLocale(in_array($langue, self::LANGUES, true) ? $langue : self::LANGUES[0]);
return $next($request);
}
}
|
- lignes 14-16 : les langues gérées, et le nom du cookie : des constantes de classe publiques, accessibles depuis les autres classes ([DefinirLangue::LANGUES]) ;
- lignes 21-22 : avant chaque action, la langue de la requête : celle du cookie [langue] s'il en existe un et qu'elle est gérée, le français sinon. [$request->cookie(nom)] rend la valeur d'un cookie envoyé par le navigateur, ou [null]. [in_array], avec [true] en troisième paramètre, compare aussi les types ([===]). [App::setLocale] (exemple 16) fixe la langue de l'application pour la requête : c'est celle qu'utilisent les traductions et les mises en forme.
5.1.3. La configuration : bootstrap/app.php
| <?php
// bootstrap/app.php : l'application de l'exemple 22
use App\Http\Middleware\DefinirLangue;
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',
// la route qui sert Bootstrap (cf. exemple 15)
then: fn () => require __DIR__.'/../../commun/bootstrap.php')
->withMiddleware(function (Middleware $middleware): void {
// le cookie [langue] n'est pas chiffré : il ne contient rien de secret, et
// le serveur n'accepte que les langues gérées
$middleware->encryptCookies(except: [DefinirLangue::COOKIE]);
// la langue de chaque requête : celle du cookie [langue], le français sinon
$middleware->web(append: DefinirLangue::class);
})
->withExceptions()
->booted(function () {
// dans les vues : @monnaie($prix) (cf. exemple 16)
Blade::directive('monnaie', fn (string $expression) =>
"<?php echo e(\\App\\Aides\\Formats::monnaie($expression)); ?>");
})
->create();
|
- ligne 19 : par défaut, [Laravel] chiffre tous les cookies qu'il dépose, et déchiffre ceux qu'il reçoit (c'est le rôle du middleware [EncryptCookies] du groupe [web], cf. exemple 20) : l'utilisateur ne peut ni lire, ni fabriquer un cookie chiffré. Le cookie [langue] ne contient rien de secret, et le serveur n'accepte que les langues gérées : nous l'écartons du chiffrement, ce qui permet de l'observer (et de le fabriquer) avec [curl]. L'exemple suivant reviendra sur ce chiffrement ;
- ligne 21 : le middleware de la langue, à la fin du groupe [web] (exemple 16) : il s'exécute après [EncryptCookies], et lit donc des cookies déjà déchiffrés ;
- lignes 26-27 : la directive [@monnaie] de l'exemple 16.
La classe [App\Aides\Formats] est celle de l'exemple 16, à une différence près : la date est mise en forme au format « long » de chaque langue :
| <?php
// app/Aides/Formats.php : dates et montants, formatés dans la langue de la requête (cf. exemple 16)
namespace App\Aides;
use Brick\Math\BigDecimal;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Number;
class Formats
{
// un montant en euros
public static function monnaie(BigDecimal $montant): string
{
return Number::currency($montant->toFloat(), in: 'EUR', locale: App::getLocale());
}
// une date en toutes lettres. Le format "dddd LL" : le jour de la semaine, puis la
// date au format "long" de la langue : "mercredi 23 septembre 2026" en français,
// "Wednesday September 23, 2026" en anglais
public static function dateLongue(CarbonInterface $jour): string
{
return $jour->locale(App::getLocale())->isoFormat('dddd LL');
}
}
|
- ligne 24 : [LL] est un format localisé : chaque langue a le sien ([23 septembre 2026] en français, [September 23, 2026] en anglais), là où [D MMMM YYYY] imposerait l'ordre français à toutes les langues. [dddd] ajoute le jour de la semaine.
5.1.4. Le choix de la langue : app/Http/Controllers/LangueController.php
| <?php
// app/Http/Controllers/LangueController.php : le choix de la langue
namespace App\Http\Controllers;
use App\Http\Middleware\DefinirLangue;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class LangueController
{
// GET /langue/en : mémorise le choix dans un cookie, puis revient à la page précédente
public function changer(Request $request, string $code): RedirectResponse
{
abort_unless(in_array($code, DefinirLangue::LANGUES, true), 404);
// le cookie : un an de vie (en minutes), illisible par le JavaScript de la
// page (httpOnly), jamais envoyé avec une requête POST venue d'un autre site (sameSite)
return redirect(self::pageDOrigine($request))
->withCookie(cookie(DefinirLangue::COOKIE, $code, 365 * 24 * 60, httpOnly: true, sameSite: 'lax'));
}
// la page d'où l'on vient (entête Referer), dont on ne garde que le chemin :
// rediriger vers une adresse complète venue de la requête permettrait
// d'envoyer l'utilisateur sur un autre site (faille "open redirect")
private static function pageDOrigine(Request $request): string
{
// parse_url découpe une URL en ses composants (chemin, paramètres...)
$url = parse_url($request->header('Referer', ''));
$chemin = $url['path'] ?? '';
// "//site.com/..." serait compris par le navigateur comme une adresse
// sur un AUTRE site : refusé aussi
if (! str_starts_with($chemin, '/') || str_starts_with($chemin, '//')) {
return '/';
}
return isset($url['query']) ? "$chemin?{$url['query']}" : $chemin;
}
}
|
- ligne 15 : un code de langue inconnu : 404 ;
- lignes 18-19 : l'action répond par une redirection vers la page d'où vient l'utilisateur : celui-ci retrouve sa page, dans la nouvelle langue. La réponse porte un cookie, que le navigateur renverra à chaque requête : la fonction [cookie] le fabrique, et la méthode [withCookie] l'ajoute à la réponse. Ses paramètres : le nom, la valeur, la durée de vie en minutes (un an), puis deux paramètres nommés : [httpOnly: true] le rend invisible au [JavaScript] de la page ; [sameSite: 'lax'] interdit au navigateur de l'envoyer avec une requête POST venue d'un autre site ;
- lignes 25-36 : la page d'origine est donnée par l'entête [Referer] de la requête (l'orthographe fautive, avec un seul [r], est celle de la norme HTTP), que le navigateur remplit avec l'URL de la page qui contenait le lien. Mais cet entête vient du client, qui peut y mettre n'importe quoi. Rediriger aveuglément vers cette adresse ferait de notre application un redirecteur ouvert (*open redirect*) : un lien piégé, qui porte notre nom de domaine, pourrait envoyer l'utilisateur sur un site malveillant. On ne garde donc que le chemin et les paramètres de l'URL (la fonction [parse_url] de [PHP] la découpe en ses composants, dans un tableau associatif), et l'on refuse un chemin qui commence par [//], que le navigateur comprendrait comme l'adresse d'un autre site. [Laravel] offre aussi [redirect()->back()] (ou [back()]), qui redirige vers la page précédente mémorisée dans la session (exemple 20), et non vers l'entête [Referer] : nous l'utiliserons dans l'étude de cas.
5.1.5. Les actions : app/Http/Controllers/PagesController.php
| <?php
// app/Http/Controllers/PagesController.php : les actions ne savent rien de la langue :
// elles fournissent des données, les vues les traduisent et les formatent
namespace App\Http\Controllers;
use App\Models\Produit;
use Brick\Math\BigDecimal;
use Carbon\CarbonImmutable;
use Illuminate\View\View;
class PagesController
{
public function accueil(): View
{
return view('pages.accueil', ['nom' => 'Marie',
'aujourdhui' => CarbonImmutable::create(2026, 9, 23)]);
}
public function produits(): View
{
return view('pages.produits', ['produits' => [
new Produit('Clavier', BigDecimal::of('25')),
new Produit('Souris', BigDecimal::of('12.5')),
new Produit('Écran', BigDecimal::of('1149')),
]]);
}
}
|
Les actions ne savent rien de la langue : elles fournissent des données brutes (un nom, une date, des montants). La traduction et la mise en forme sont l'affaire des vues.
5.1.6. Traduire dans les vues
| <!doctype html>
{{-- app()->getLocale() : la langue de la requête, "fr" ou "en" --}}
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
{{-- le titre : la section [titre] de la page, déjà traduite --}}
<title>@yield('titre')</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('contenu')
</main>
</div>
</body>
</html>
|
- ligne 3 : l'attribut [lang] de la page indique sa langue aux navigateurs, aux moteurs de recherche et aux lecteurs d'écran ;
- ligne 7 : le titre de la page est fourni par la page elle-même, déjà traduit (section [titre]).
| <nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
<div class="navbar-nav me-auto">
{{-- __('CLE') : la traduction d'une clé, dans la langue de la requête --}}
<a class="nav-link" href="/">{{ __('MENU.ACCUEIL') }}</a>
<a class="nav-link" href="/produits">{{ __('MENU.PRODUITS') }}</a>
</div>
{{-- la langue courante est mise en évidence --}}
<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="/langue/{{ $code }}">{{ strtoupper($code) }}</a>
@endforeach
</div>
</nav>
|
- lignes 4-5 : la fonction [__] (deux soulignés) rend la traduction d'une clé, dans la langue de la requête ;
- lignes 9-12 : un bouton par langue, qui mène à l'action [/langue/{code}]. La langue courante est mise en évidence ([btn-primary], bouton plein) : [app()->isLocale($code)] teste la langue de la requête.
| @extends('layout')
@section('titre', __('ACCUEIL.TITRE'))
@section('contenu')
<h1>{{ __('ACCUEIL.TITRE') }}</h1>
{{-- un paramètre : le second paramètre de __, un tableau --}}
<p>{{ __('ACCUEIL.BONJOUR', ['nom' => $nom]) }}</p>
{{-- la date, formatée d'abord, puis passée comme paramètre [date] --}}
<p>{{ __('ACCUEIL.DATE', ['date' => App\Aides\Formats::dateLongue($aujourdhui)]) }}</p>
@endsection
|
- ligne 2 : la section [titre], traduite, pour le gabarit ;
- ligne 7 : les paramètres d'un texte sont donnés dans un tableau associatif, second paramètre de [__] ; [:nom] est remplacé par la valeur de la clé [nom] ;
- ligne 9 : la date, mise en forme dans la langue de la requête par la méthode [dateLongue], puis passée comme paramètre. Une directive ([@dateLongue]) ne peut pas servir ici : elle écrit directement dans la page, et ne rend pas de valeur.
| @extends('layout')
@section('titre', __('PRODUITS.TITRE'))
@section('contenu')
<h1>{{ __('PRODUITS.TITRE') }}</h1>
<table class="table table-sm w-auto">
<thead><tr><th>{{ __('PRODUITS.NOM') }}</th><th class="text-end">{{ __('PRODUITS.PRIX') }}</th></tr></thead>
<tbody>
@foreach ($produits as $produit)
<tr>
<td>{{ $produit->nom }}</td>
<td class="text-end">@monnaie($produit->prix)</td>
</tr>
@endforeach
</tbody>
</table>
<p>{{ __('PRODUITS.NOMBRE', ['n' => count($produits)]) }}</p>
@endsection
|
- ligne 17 : un paramètre calculé. Les noms des produits, eux, ne sont pas traduits : ce sont des données, qui, dans une vraie application, viendraient d'une base de données.
5.1.7. Exécution
La page d'accueil, en français (la langue par défaut), puis en anglais après un clic sur le bouton « EN » :


La page des produits, dans les deux langues. Les montants suivent les conventions de chaque langue :


Le changement de langue, observé avec [curl]. L'option [-H] fournit l'entête [Referer] qu'enverrait le navigateur :
| curl -i -H "Referer: http://localhost:8000/produits" http://localhost:8000/langue/en
|
| HTTP/1.1 302 Found
Location: http://localhost:8000/produits
Set-Cookie: langue=en; expires=Sun, 26 Sep 2027 16:18:24 GMT; Max-Age=31536000; path=/; httponly; samesite=lax
...
|
La redirection ramène à la page d'origine, et le cookie est déposé pour un an, en clair. Un [Referer] qui désigne un autre site ne permet pas d'y être redirigé : seul son chemin est conservé.
| curl -i -H "Referer: https://pirate.example/piege" http://localhost:8000/langue/en
curl -i -H "Referer: http://localhost:8000//pirate.example/x" http://localhost:8000/langue/fr
|
| HTTP/1.1 302 Found
Location: http://localhost:8000/piege
...
HTTP/1.1 302 Found
Location: http://localhost:8000
...
|
La page [/piege] n'existe pas sur notre site : l'utilisateur obtiendra une erreur 404, mais restera chez nous. Enfin, la page d'accueil, demandée avec le cookie [langue=en] (option [-b] de [curl]) :
| curl -b "langue=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="/produits">Products</a>
...
<h1>Welcome</h1>
<p>Hello Marie!</p>
<p>Today is Wednesday September 23, 2026.</p>
...
|
Une autre façon de choisir la langue aurait été de lire l'entête [Accept-Language] du navigateur (exemple 12). Les deux se combinent souvent : la langue du navigateur à la première visite, puis celle que l'utilisateur a choisie. L'étude de cas s'en tient au cookie, avec le français par défaut.