Lesson 20 / 21
Localization
Language arrays, placeholders, plural forms and the fallback language; the text of the requested language not being wired when the key is missing and the fallback language stepping in without an error, the requested language running when the key exists and the fallback language hiding a missing translation.
Published Updated
In this lesson
- Builds text from language arrays with placeholders and plural forms
- Measures that the text of the requested language is not wired when the key is missing in it and that the fallback language steps in without an error
- Shows that the fallback language hides a missing translation and that a key found in no language returns under its own name
Contents
In the Scheduled Tasks lesson we asked when a task would run; if the running task sends a notification to a user, the language in which the notification is written is also a choice. If the text of the response is embedded in the code, every language needs a separate code path. Moving the text out of the code, into a table separated by language, reverses the work: the code stays the same, and the table changes. This lesson asks: what happens to the response if there is no text in the requested language?
Localization is choosing the texts of an application according to the user’s language. The texts live in a language file: in this lesson a language file is a PHP array that maps keys to text. The variable parts inside a text are marked with a placeholder: a piece such as :name is replaced with the value that comes with the call. The forms of a text that change with a number are called plural forms: “1 item” and “3 items” are two forms of the same key. The language that is looked at second when a key is missing in the requested language is called the fallback language.
AS10. A language file is a PHP array, and two languages are defined: tr and en. The fallback language is tr and it has one level: if the key is missing in the requested language, only the fallback language is looked at, and the fallback has no fallback of its own. Keys are flat names; a name such as general.unknown contains a dot but does not express a nested structure. The language code carries no region part, date, number and currency formatting are not modeled, and the discount key is deliberately left missing in the en file. The Turkish language data stays Turkish in this lesson, because the subject of the lesson is the difference between two languages.
AS11. The plural form separates only two cases: if the number is exactly 1, one, otherwise many. This is correct for English; that plural rules are far more complex from language to language (there are languages in which zero, two or certain number endings take separate forms) is not in the lesson’s model. Turkish does not pluralize the noun after a number, so the two forms in the tr file are the same. The placeholder has the form :name, and the related part of the text is filled by a plain string replacement; value formatting and escaping are not modeled.
The Measurement Core
The same measurement is used in every lesson of the course. ran.php records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. Ledger keeps three lists: the parts placed on the request’s path, the parts called within the request, and the parts left outside the request. classify() checks them in order. The decision order is classify() itself: ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request. In this lesson a part is a piece of text, and its name has the form language/key: en/discount measures the question “did the discount text in the English file enter the response in this request”. When a text is used, both onPath() and ran() are called; when it is not used, neither is called.
<?php // ran.php — did an expected part run in this request? declare(strict_types=1); final class Ledger { public static array $path = []; // parts placed on the request's path public static array $ran = []; // parts called within the request public static array $deferred = []; // parts left outside the request public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; } public static function onPath(string $part): void { self::$path[] = $part; } public static function ran(string $part): void { self::$ran[] = $part; } public static function defer(string $part): void { self::$deferred[] = $part; } } function classify(string $part): string { return match (true) { in_array($part, Ledger::$ran, true) => 'ran', in_array($part, Ledger::$deferred, true) => 'deferred', in_array($part, Ledger::$path, true) => 'skipped', default => 'not wired', }; }
Language Arrays, Placeholders and the Fallback Language
localization.php defines three parts. Translator::get() tries the requested language and the fallback language in order: when it finds the key, it puts the part on the path, marks it as ran and fills the text; if it finds the key in neither, it returns the key itself. fill() picks the one or many form according to the number if the text is an array, and then replaces the placeholders with strtr. languageArrays() gives the two language files. CartController takes a language, a name, a number and a list of keys, translates each key and joins the parts into a single response.
In this structure the controller knows the text of no language. The language choice reaches the controller as a string, and the resolution is entirely in Translator. The fallback language is given when Translator is built; so changing the fallback does not change the controller.
<?php // localization.php — language arrays, placeholders, plural forms and the fallback language declare(strict_types=1); require_once 'ran.php'; final class Translator { public function __construct(private array $languages, private string $fallback) {} public function get(string $language, string $key, array $values = []): string { foreach ([$language, $this->fallback] as $candidate) { if (!isset($this->languages[$candidate][$key])) { continue; } Ledger::onPath("$candidate/$key"); Ledger::ran("$candidate/$key"); return $this->fill($this->languages[$candidate][$key], $values); } return $key; } private function fill(string|array $text, array $values): string { if (is_array($text)) { $text = ($values['count'] ?? 0) === 1 ? $text['one'] : $text['many']; } $placeholders = []; foreach ($values as $name => $value) { $placeholders[':' . $name] = (string) $value; } return strtr($text, $placeholders); } public function keys(string $language): array { return array_keys($this->languages[$language]); } } function languageArrays(): array { return [ 'tr' => [ 'greeting' => 'Merhaba, :name', 'cart' => ['one' => ':count ürün', 'many' => ':count ürün'], 'discount' => 'Sepette indirim var', ], 'en' => [ 'greeting' => 'Hello, :name', 'cart' => ['one' => ':count item', 'many' => ':count items'], ], ]; } final class CartController { public function __construct(private Translator $translator) {} public function show(string $language, string $name, int $count, array $keys): array { Ledger::newRequest(); $lines = []; foreach ($keys as $key) { $lines[] = $this->translator->get($language, $key, ['name' => $name, 'count' => $count]); } return [200, implode(' | ', $lines)]; } }
The Text of the Requested Language Was Not Wired
The driver asks for only the discount key in the en language. This key is deliberately missing in the en file. After the response, the outcome of two parts is written: the discount text in the English file and the same text in the Turkish file.
<?php // fallback.php — the key is missing in the requested language: the fallback language steps in require_once 'localization.php'; $controller = new CartController(new Translator(languageArrays(), 'tr')); $response = $controller->show('en', 'Alice', 3, ['discount']); printf("show(language: en, discount) -> %d %s\n", $response[0], $response[1]); foreach (['en/discount', 'tr/discount'] as $part) { printf(" %-11s %s\n", $part, classify($part)); }
show(language: en, discount) -> 200 Sepette indirim var en/discount not wired tr/discount ran
The request did not fail; it returned 200 and the response carried the Turkish text. en/discount was not wired: there is no such text in the English file, so it never entered the path. tr/discount ran: the fallback language stepped in and put the text into the response. The outcome of the lesson therefore reads as not wired, but this is not an error, it is a path: instead of a text that does not exist in the language the user asked for, the user sees a text in another language, and the application keeps working.
Saying “skipped” here would be wrong. A skipped part is a part that was placed on the path but not called; en/discount was never placed on the path, because the resolution does not put a text it could not find in the file on the path. The text could not be wired; it was not passed over as the result of a condition.
The Contrast: The Requested Language Ran When the Key Existed
This time the same controller is given the greeting and cart keys, which exist in both languages. The driver makes three requests: in the en language with one item, in the en language with three items, and in the tr language with three items. After each request the outcome of the greeting text in the two languages is written.
<?php // present.php — when the key exists in the requested language, the text of that language runs require_once 'localization.php'; $controller = new CartController(new Translator(languageArrays(), 'tr')); foreach ([['en', 1], ['en', 3], ['tr', 3]] as [$language, $count]) { $response = $controller->show($language, 'Alice', $count, ['greeting', 'cart']); printf("show(language: %s, count: %d) -> %d %s\n", $language, $count, $response[0], $response[1]); foreach (['en/greeting', 'tr/greeting'] as $part) { printf(" %-13s %s\n", $part, classify($part)); } }
show(language: en, count: 1) -> 200 Hello, Alice | 1 item en/greeting ran tr/greeting not wired show(language: en, count: 3) -> 200 Hello, Alice | 3 items en/greeting ran tr/greeting not wired show(language: tr, count: 3) -> 200 Merhaba, Alice | 3 ürün en/greeting not wired tr/greeting ran
When the key was in the requested language, the text of the requested language ran and that of the fallback language was not wired. So it is the reverse of the discount example: the fallback language never entered the resolution, because the first language tried was enough. The responses show two things together. The plural form is chosen by the number: in the en language 1 item became 1 item and 3 items became 3 items; in the tr language it became 3 ürün, because the two forms are the same in the Turkish file. The placeholder was filled in every request too: :name became Alice in every language, and :count took the number that came from the call.
The classes of the two languages mirror each other: when one language runs, the other is not wired. This means the fallback language steps in only when the requested language fails.
The Bounding Measurement
The fallback language protects the user from an error, but at the same time it hides a missing translation. missing.php first requests all the keys in the Turkish file in the en language and lists which of them were not wired in the English file, that is, which came from the fallback. Then it requests a key that exists in no language.
<?php // missing.php — the fallback hides a missing translation; a key found in no language returns under its own name require_once 'localization.php'; $translator = new Translator(languageArrays(), 'tr'); $controller = new CartController($translator); $keys = $translator->keys('tr'); $controller->show('en', 'Alice', 2, $keys); $missing = array_filter($keys, fn(string $k) => classify("en/$k") === 'not wired'); printf("en request: %d keys, from the fallback: %s\n", count($keys), implode(', ', $missing)); $response = $controller->show('en', 'Alice', 2, ['general.unknown']); printf("in no language -> %d %s\n", $response[0], $response[1]); foreach (['en/general.unknown', 'tr/general.unknown'] as $part) { printf(" %-19s %s\n", $part, classify($part)); }
en request: 3 keys, from the fallback: discount in no language -> 200 general.unknown en/general.unknown not wired tr/general.unknown not wired
The first line shows that behind the 200 response there is a translation debt: one of the three keys came from the fallback. The response looks complete from the outside; the gap can be noticed only by measuring, that is, by looking at which part was not wired. This is the subject of a test or an audit rule: the fallback language protects the user experience, but it does not say that a translation is missing.
The second part shows the case in which the fallback is not enough either. Because the key is in neither language, Translator returned the name of the key; the response is again 200 and both parts were not wired. The user sees the text general.unknown in the response. In this model no error is thrown; another application may choose to throw an error or to return an empty text. The measurement does not say which of these choices is right, it only shows what happens in this model.
Summary
- This lesson’s outcome is not wired: the
discountkey was missing in theenlanguage,en/discountdid not enter the path, and the fallback language put the texttr/discountinto the response without an error. - A language file is a PHP array; the text is built with a placeholder (
:name) and aoneormanyform chosen by the number. - Its contrast: when the key existed in the requested language, the text of that language ran and that of the fallback language was not wired; the plural form was separated as
1 itemand3 items. - In the bounding measurement the fallback language hid a missing translation: one of three keys came from the fallback, and the response stayed 200.
- A key found in no language returned under its own name, and the text of both languages was not wired.
Next Step
In this course we measured in every lesson whether a part ran: authentication, queue, event, notification, cache, file, scheduler and language. Each came by a different path, and each had a contrast of its own. But can these measurements be turned into something that checks itself after every change, instead of a driver and an output read by hand? The last lesson of the course asks how all of these can be verified from a request to a response and at the level of single units.
To keep your progress and take notes, Log in
My notes
Log in to take notes.