Pop PHP
The Toolkit

I18n

pop-i18n translates the strings your application prints. You ship one file per language, containing source-to-output pairs, and call one method wherever text reaches the user. Strings that have no translation come back unchanged, so you can wrap every string in the call from the start and fill the files in later.

BASH
composer require popphp/pop-i18n

Language Files#

A language file is named after the language alone — fr.json, not fr_FR.json — and holds one entry per region. fr_FR and fr_CA are two locale blocks inside the same fr.json, not two files.

JSON
{
    "language": {
        "src": "en",
        "output": "fr",
        "name": "French",
        "native": "Français",
        "locale": [{
            "region": "FR",
            "name": "France",
            "native": "France",
            "text": [
                {
                    "source": "Hello, my name is %1. I love to program %2.",
                    "output": "Bonjour, mon nom est %1. J'aime programmer %2."
                }
            ]
        }]
    }
}

src is the language the source strings are written in, output is the language this file translates into, and name/native are display labels — the English name and the name in the language itself. Each locale adds region, its own name/native, and the text array of source-output pairs. %1, %2 and so on are the parameter placeholders.

XML carries the same structure with a doctype in front of it, and is the format Pop\I18n\Format\Xml writes:

XML
<language src="en" output="fr" name="French" native="Français">
    <locale region="FR" name="France" native="France">
        <text>
            <source>Hello, my name is %1. I love to program %2.</source>
            <output>Bonjour, mon nom est %1. J'aime programmer %2.</output>
        </text>
    </locale>
</language>

The two formats are interchangeable. I18n looks for <language>.xml first and falls back to <language>.json, so a directory can hold both as long as one language does not appear in both.

When you have the translations as data rather than as files, the Format classes assemble them:

PHP
use Pop\I18n\Format;

$language = ['src' => 'en', 'output' => 'de', 'name' => 'German', 'native' => 'Deutsch'];

$locales = [[
    'region' => 'DE',
    'name'   => 'Germany',
    'native' => 'Deutschland',
    'text'   => [
        ['source' => 'This field is required.', 'output' => 'Dieses Feld ist erforderlich.'],
        ['source' => 'Please enter your name.', 'output' => 'Bitte geben Sie Ihren Namen ein.'],
    ],
]];

Format\Xml::createFile($language, $locales, __DIR__ . '/../language/de.xml');
Format\Json::createFile($language, $locales, __DIR__ . '/../language/de.json');

Both write a complete, loadable file. Data missing a required key throws Pop\I18n\Format\Exception — dropping output from $language above gives "Error: The language parameter must have at least the 'src' and 'output' keys defined."

createFragment() writes a translation stub for editing rather than a loadable language file.

Translating Strings#

__() returns the translated string and _e() echoes it. Both take the source string, optional parameters, and an optional variation name:

PHP
use Pop\I18n\I18n;

$lang = new I18n('fr_FR', __DIR__ . '/../language');

echo $lang->__('Hello, my name is %1. I love to program %2.', ['Nick', 'PHP']);
// Bonjour, mon nom est Nick. J'aime programmer PHP.

$lang->_e('Hello, my name is %1. I love to program %2.', ['Nick', 'PHP']);

Parameters substitute positionally: the first array element replaces %1, the second %2. A single value may be passed as a bare string instead of a one-element array. Any placeholder with no matching parameter is left in the output literally — passing 'Nick' alone to the string above prints J'aime programmer %2., so count the placeholders against what you pass.

A source string with no translation comes back unchanged, and so does every string when no language file matched at all. Nothing throws and nothing is logged:

PHP
use Pop\I18n\I18n;

$lang = new I18n('fr_FR', __DIR__ . '/../language');

echo $lang->__('Not in the catalog');  // 'Not in the catalog'

That's what makes it safe to wrap strings before the translations exist — but it also means a typo in a source string is invisible. The string prints, in the source language, and the only symptom is that one line stayed English.

A source string can carry several outputs, for alternate phrasings or formality levels. In JSON the output becomes an object whose keys are the variation names:

JSON
{
    "source": "Hello, how are you?",
    "output": {
        "formal": "Bonjour, comment allez-vous?",
        "informal": "Salut, ça va?"
    }
}

The XML equivalent nests <output> elements inside <output>, each with an alt attribute. Either way, the third argument to __()/_e() picks one:

PHP
use Pop\I18n\I18n;

$lang = new I18n('fr_FR', __DIR__ . '/../language');

echo $lang->__('Hello, how are you?');                    // Bonjour, comment allez-vous?
echo $lang->__('Hello, how are you?', null, 'informal');  // Salut, ça va?
echo $lang->__('Hello, how are you?', null, 'nonsense');  // Bonjour, comment allez-vous?

The first defined output is the default, used both when you pass no variation and when the variation you pass matches nothing. Outputs with no alt attribute in XML, or in a plain JSON array, are selected by numeric position instead — 0, 1, 2.

Choosing a Language#

The language is a constructor argument. new I18n('fr_FR', $dir) splits on the underscore into a language and a locale, and loads fr.xml or fr.json from $dir:

PHP
use Pop\I18n\I18n;

$lang = new I18n('fr_CA', __DIR__ . '/../language');

$lang->getLanguage();  // 'fr'
$lang->getLocale();    // 'CA'

A string with no underscore uses the language uppercased as the locale, so new I18n('fr') resolves to fr_FR. Pass null and the constructor reads the POP_LANG constant if you have defined one, falling back to en_US:

PHP
use Pop\I18n\I18n;

define('POP_LANG', 'fr_CA');

$lang = new I18n(null, __DIR__ . '/../language');

$lang->getLocale();  // 'CA'

loadFile() bypasses the directory scan and reads one named file, which is what you want when the files do not follow the naming convention:

PHP
use Pop\I18n\I18n;

$lang = new I18n('fr_FR');
$lang->loadFile(__DIR__ . '/../language/fr.json');

A path that does not exist, or one that is neither .xml nor .json, throws Pop\I18n\Exception ("Error: The language file ... does not exist or is not valid."). Malformed XML is the one case that does not: it surfaces as PHP's built-in \Exception from SimpleXMLElement's parser instead.

pop-i18n takes the language you hand it. Read Accept-Language or your own user preference and pass the result in.

Point the constructor at a directory that exists — it falls back to the component's own src/Data/ folder, which holds empty templates.

For the other half of the decision — offering the choice — I18n::getLanguages() scans a directory and returns every locale it can actually serve, sorted, keyed by locale with a display label:

PHP
use Pop\I18n\I18n;

$languages = I18n::getLanguages(__DIR__ . '/../language');

// [
//     'fr_CA' => 'Français, Canada (French, Canada)',
//     'fr_FR' => 'Français, France (French, France)',
// ]

It's static, needs no instance, and reads both formats. Both regions above come from the one fr.json. That array is what a language picker should be built from, since it lists what you ship rather than what a visitor asked for.

Language file generation, the fragment helpers and the full Format API go past what this page covers — see the pop-i18n README.

See Also#