Chapter 14: Localization
1. One App, Many Languages
Your app goes live. Users arrive from Germany, Brazil, Japan. Everything is in English. They bounce.
Localization (L10n) makes the same app speak every language. UI strings, error messages, date formats, and number formatting adapt to the user's locale. The code stays the same.
Tina4 provides a built-in I18n class that loads JSON locale files, interpolates variables, falls back to a default locale when a key is missing, and requires no external packages.
2. Locale Files
Store translations as JSON files. One file per locale. Place them in src/locales/ by convention.
src/locales/en.json:
{
"welcome": "Welcome, {name}!",
"goodbye": "Goodbye, {name}.",
"errors": {
"required": "The {field} field is required.",
"min_length": "The {field} must be at least {min} characters.",
"not_found": "The requested resource was not found."
},
"orders": {
"count": "You have {count} order(s).",
"empty": "You have no orders yet."
},
"nav": {
"home": "Home",
"products": "Products",
"account": "My Account",
"logout": "Log Out"
}
}src/locales/de.json:
{
"welcome": "Willkommen, {name}!",
"goodbye": "Auf Wiedersehen, {name}.",
"errors": {
"required": "Das Feld {field} ist erforderlich.",
"min_length": "Das Feld {field} muss mindestens {min} Zeichen lang sein.",
"not_found": "Die angeforderte Ressource wurde nicht gefunden."
},
"orders": {
"count": "Sie haben {count} Bestellung(en).",
"empty": "Sie haben noch keine Bestellungen."
},
"nav": {
"home": "Startseite",
"products": "Produkte",
"account": "Mein Konto",
"logout": "Abmelden"
}
}src/locales/pt.json:
{
"welcome": "Bem-vindo, {name}!",
"goodbye": "Até logo, {name}.",
"errors": {
"required": "O campo {field} é obrigatório.",
"not_found": "O recurso solicitado não foi encontrado."
},
"nav": {
"home": "Início",
"products": "Produtos",
"account": "Minha Conta",
"logout": "Sair"
}
}3. Loading and Using I18n
<?php
use Tina4\I18n;
// Load locale files
$i18n = new I18n('src/locales');
// Set the active locale
$i18n->setLocale('de');
// Translate a key
echo $i18n->t('welcome', ['name' => 'Alice']);
// Output: Willkommen, Alice!
echo $i18n->t('errors.not_found');
// Output: Die angeforderte Ressource wurde nicht gefunden.
echo $i18n->t('orders.count', ['count' => 3]);
// Output: Sie haben 3 Bestellung(en).Dot notation navigates nested keys. 'errors.required' accesses $translations['errors']['required'].
4. Fallback Locale
When a key exists in en.json but not in the active locale file, I18n falls back to the default locale automatically.
<?php
use Tina4\I18n;
$i18n = new I18n('src/locales', defaultLocale: 'en');
$i18n->setLocale('pt');
// 'orders.count' missing from pt.json -- falls back to en.json
echo $i18n->t('orders.count', ['count' => 2]);
// Output: You have 2 order(s).
// 'nav.home' exists in pt.json
echo $i18n->t('nav.home');
// Output: InícioFallback prevents blank strings from appearing in partially translated apps. Ship the English version first. Translations can come later without breaking anything.
5. Locale Switching in API Routes
Detect the locale from the Accept-Language header, a query parameter, or the user's profile. Then set it on the I18n instance.
<?php
use Tina4\Router;
use Tina4\I18n;
$i18n = new I18n('src/locales', defaultLocale: 'en');
function resolveLocale($request, I18n $i18n): string {
// 1. Query parameter: ?lang=de
if (!empty($request->params['lang'])) {
return $request->params['lang'];
}
// 2. Accept-Language header: Accept-Language: de-DE,de;q=0.9
$header = $request->headers['Accept-Language'] ?? 'en';
$primary = explode(',', $header)[0];
$lang = explode('-', $primary)[0];
// 3. Fall back to default
return $i18n->hasLocale($lang) ? $lang : 'en';
}
/**
* @noauth
*/
Router::get('/api/greeting', function ($request, $response) use ($i18n) {
$locale = resolveLocale($request, $i18n);
$i18n->setLocale($locale);
$name = $request->params['name'] ?? 'stranger';
return $response->json([
'locale' => $locale,
'message' => $i18n->t('welcome', ['name' => $name]),
'nav' => [
'home' => $i18n->t('nav.home'),
'products' => $i18n->t('nav.products'),
'account' => $i18n->t('nav.account')
]
]);
});curl "http://localhost:7146/api/greeting?name=Alice&lang=de"{
"locale": "de",
"message": "Willkommen, Alice!",
"nav": {
"home": "Startseite",
"products": "Produkte",
"account": "Mein Konto"
}
}curl "http://localhost:7146/api/greeting?name=Alice&lang=pt"{
"locale": "pt",
"message": "Bem-vindo, Alice!",
"nav": {
"home": "Início",
"products": "Produtos",
"account": "Minha Conta"
}
}6. Interpolation
Placeholders use {key} syntax. Pass values as an associative array.
<?php
use Tina4\I18n;
$i18n = new I18n('src/locales');
$i18n->setLocale('en');
// Single value
echo $i18n->t('welcome', ['name' => 'Bob']);
// Welcome, Bob!
// Multiple values
echo $i18n->t('errors.min_length', [
'field' => 'password',
'min' => 8
]);
// The password must be at least 8 characters.
// Missing placeholder value -- left as-is
echo $i18n->t('welcome');
// Welcome, {name}!No special escaping needed. Values are inserted as-is.
7. Checking Available Locales
<?php
use Tina4\I18n;
$i18n = new I18n('src/locales');
// List all loaded locales
$locales = $i18n->getLocales();
// ['en', 'de', 'pt']
// Check if a locale exists
$has = $i18n->hasLocale('fr'); // false
$has = $i18n->hasLocale('de'); // true
// Get the current active locale
$current = $i18n->getLocale(); // 'en' (default)Expose available locales via API for frontend locale switchers:
Router::get('/api/locales', function ($request, $response) use ($i18n) {
return $response->json([
'available' => $i18n->getLocales(),
'default' => 'en'
]);
});8. Localized Validation Errors
Return validation errors in the user's language:
<?php
use Tina4\Router;
use Tina4\I18n;
$i18n = new I18n('src/locales', defaultLocale: 'en');
Router::post('/api/users', function ($request, $response) use ($i18n) {
$lang = $request->params['lang'] ?? 'en';
$i18n->setLocale($lang);
$body = $request->body;
$errors = [];
if (empty($body['name'])) {
$errors[] = $i18n->t('errors.required', ['field' => 'name']);
}
if (empty($body['email'])) {
$errors[] = $i18n->t('errors.required', ['field' => 'email']);
}
if (!empty($body['password']) && strlen($body['password']) < 8) {
$errors[] = $i18n->t('errors.min_length', ['field' => 'password', 'min' => 8]);
}
if (!empty($errors)) {
return $response->json(['errors' => $errors], 422);
}
return $response->json(['message' => 'User created'], 201);
});curl -X POST "http://localhost:7146/api/users?lang=de" \
-H "Content-Type: application/json" \
-d '{"password": "abc"}'{
"errors": [
"Das Feld name ist erforderlich.",
"Das Feld email ist erforderlich.",
"Das Feld password muss mindestens 8 Zeichen lang sein."
]
}9. Adding Locales at Runtime
Load additional locale data programmatically (useful when translations come from a database):
<?php
use Tina4\I18n;
$i18n = new I18n('src/locales');
// Add or extend a locale at runtime
$i18n->addTranslations('fr', [
'welcome' => 'Bienvenue, {name} !',
'goodbye' => 'Au revoir, {name}.',
'nav' => [
'home' => 'Accueil',
'products' => 'Produits',
'account' => 'Mon Compte',
'logout' => 'Déconnexion'
]
]);
$i18n->setLocale('fr');
echo $i18n->t('welcome', ['name' => 'Alice']);
// Bienvenue, Alice !10. Exercise: Multilingual API
Build a product API that returns localized content.
Requirements
Create locale files for
en,de, and one other language of your choice with keys for:product.in_stock,product.out_of_stockproduct.price(e.g., "Price: {amount}")errors.not_found
Create
GET /api/products/{id}that:- Accepts
?lang=query parameter - Returns product data with localized status and label strings
- Accepts
Create
GET /api/localesthat returns the list of available locales
Test with:
curl "http://localhost:7146/api/products/1?lang=en"
curl "http://localhost:7146/api/products/1?lang=de"
curl "http://localhost:7146/api/products/999?lang=de"
curl "http://localhost:7146/api/locales"11. Gotchas
1. Missing keys return the key name
Problem: The UI shows errors.not_found instead of a message.
Cause: The key does not exist in the active locale, and defaultLocale is not set or that locale also lacks the key.
Fix: Set defaultLocale: 'en' and ensure the English locale file has all keys. It is the source of truth.
2. Locale files not loaded
Problem: $i18n->t('welcome') returns the raw key on all locales.
Cause: The locale directory path is wrong, or JSON files have a syntax error.
Fix: Verify the path passed to new I18n() is correct relative to the project root. Validate JSON files with php -r "echo json_encode(json_decode(file_get_contents('src/locales/en.json')));".
3. Placeholder not replaced
Problem: Output shows Welcome, {name}! with the literal {name}.
Cause: You called $i18n->t('welcome') without passing the values array.
Fix: Always pass replacement values: $i18n->t('welcome', ['name' => $user['name']]).
4. Locale not resetting between requests
Problem: After one request with ?lang=de, subsequent requests without a lang parameter return German text.
Cause: The I18n instance is shared and locale state persists across the request lifecycle if the instance lives in a service container.
Fix: Always call $i18n->setLocale($locale) at the start of each request before calling t(). Do not assume the locale from a previous request.