---
title: Localization
source: 'https://academia.sh/en/courses/web-application-framework/localization'
course: 'The Web Application Framework'
language: en
updated: '2026-10-21T05:03:38+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

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

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
<?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
<?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
<?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
<?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
<?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 `discount` key was missing in the `en` language, `en/discount` did not enter the path, and the fallback language put the text `tr/discount` into the response without an error.
- A language file is a PHP array; the text is built with a placeholder (`:name`) and a `one` or `many` form 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 item` and `3 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.
