Skip to content

Overview

StaticPHP\Utils\Models\i18n is the facade for the whole translation layer. Everything on it is static, and it delegates to a handful of small classes under StaticPHP\Utils\Models\Translation\.

The design decision that shapes the rest: the source text is the key. There is no nav.header.login. A template says _('Log in'), and Log in is what the database stores the translations against. A string nobody has translated yet renders as the english it was written as, with a marker on it, rather than as an identifier.

url prefix -> Locales::byPrefix() tried first, and wins outright
Accept-Language -> Negotiator consulted only when there is no prefix
|
Locale one country and language pairing
|
Catalog -> Store -> database the string table for that locale
|
Formatter -> ICU plurals, numbers, dates
  1. Resolution picks a locale. Locales holds every configured country and language pairing and answers byPrefix() for the prefixes the router stripped. Negotiator never sees the url: it reads Accept-Language, and only on a request that carried no prefix of its own. Either way the result is one immutable Locale. See locales and negotiation.
  2. The store loads a catalog. Store is every database call the layer makes; Catalog is the warmed string table for one language, memoised per request and cached between them. See catalogs.
  3. The formatter renders. Formatter wraps MessageFormatter, NumberFormatter and IntlDateFormatter. See formatting.

Scanner and the staticphp i18n command sit beside all three, comparing the source tree against the database. See extracting strings.

The shipped defaults live in src/Utils/Config/i18n.php and populate $config['i18n']. Configuration covers how to load it; each key is explained here on the page for the part that reads it.

Key Read by
available Locales::fromConfig() - countries, languages, optional locale override
url_format Locales::prefix() - {{country}} and {{language}}
redirect i18n::init(), on a request with no prefix
negotiate i18n::init(), to pick the locale when the url carries no prefix
fallback i18n::translate() and i18n::format()
auto_register key registration on first sight
missing_suffix the marker appended to an untranslated string
strict Store and Formatter; null follows $config['debug']
set_locale i18n::init(), calls setlocale()
cache, cache_prefix, cache_subdir, cache_ttl the Catalog that i18n::init() constructs
db_config, db_scheme, tables the Store that i18n::init() constructs

One thing the file does beyond assigning keys: it derives a url prefix for every configured pairing and prepends them to $config['url_prefixes'], so /lv-en/some/page is recognised by the router as a prefix instead of as the first controller segment.

The tables come first. i18n_keys, i18n_translations and i18n_cached are where every string lives, and nothing creates them for you:

Terminal window
./staticphp i18n install
./staticphp migrate apply

i18n install copies the schema for the configured driver into the migrations directory as a migration and prints where it put it; migrate apply runs it. Both are covered under extracting strings and the migrations cli.

Then, per request:

<?php
use StaticPHP\Core\Models\Config;
use StaticPHP\Utils\Models\i18n;
Config::load(['i18n'], 'Utils', 'staticphp');
i18n::init();

init() in order: reads Config::$items['i18n'], builds a Locales from it, resolves the request to one Locale, points every static at it, constructs the Store and Catalog, optionally calls setlocale(), sets ExtendedDateTime::$defaultLocale, then loads the string table for the resolved language.

It also accepts an explicit pairing, which is what the cli and the tests use:

<?php
i18n::init('lv', 'en');

An explicit pairing that is not configured throws StaticPHP\Utils\Models\Translation\TranslationError.

<?php
// Plain lookup
i18n::translate('Log in');
// Placeholders, substituted in a single pass
i18n::translate('Hello %name%', ['%name%' => $user->name], 'html');
// ICU message, which is what handles plurals
i18n::format('{n, plural, zero{# failu} one{# fails} other{# faili}}', ['n' => $count]);

Both take the same four arguments:

<?php
public static function translate(
string $text,
array $replace = [],
?string $escape = null,
?string $language_key = null
): string;
public static function format(
string $text,
array $arguments = [],
?string $escape = null,
?string $language_key = null
): string;

$escape is one of html, attr, input, js, url, or null to leave the string alone. $language_key translates into a language other than the current one, in the <country>_<language> form described on the locales page.

What happens to a string nobody has translated

Section titled “What happens to a string nobody has translated”

translate() and format() both go through the same private lookup: walk the fallback chain for the locale, return the first value that is not null, and if there is none, register the key.

Registration appends $config['i18n']['missing_suffix'] - * by default - to the source text and returns that. The suffix is the point: an untranslated string shows up on the page as Log in*, visible to whoever is reviewing it and searchable in the translation backend, without anyone having to run an extraction step first.

With $config['i18n']['auto_register'] on, and if the store is not degraded, the key is also inserted and the placeholder written for the requested language. The insert happens once per key per request no matter how many times the page asks for it. Turn auto_register off on a read-only replica, or anywhere a page render must not write.

<?php
// Insert or update; registers the key if it is new
i18n::set('Log in', 'Pieslēgties', 'lv_lv');
// Same, but throws TranslationError if the key is not registered
i18n::update('Log in', 'Pieslēgties', 'lv_lv');
// Mark warmed copies stale - one language, or every language with null
i18n::cacheInvalidate('lv_lv');

set() returns false rather than throwing when the write fails and strict mode is off.

$config['i18n']['strict'] decides what a database or ICU failure does. false returns the source string and logs; true rethrows, which is what you want in tests and development; null follows $config['debug'].

Outside strict mode, a store that has taken a failure sets a flag and stops registering keys, and the catalog refuses to warm a table it could not read, so an outage is never cached as authoritative. Ask about it with:

<?php
i18n::isInitialised(); // has init() or inject() run
i18n::isDegraded(); // has a database call failed this request
i18n::debug(); // prints the current locale, degraded flag and string table

TranslationError is the exception to that: it only ever means the configuration is wrong, and Store rethrows it even outside strict mode.

i18n::twigRegister() writes the current locale into Config::$items['view_data']['i18n']

  • country_code, language_code, language_key, url_prefix, countries and alternates - so a plain php view can read it, and then, if a view engine is configured, registers:
Name Kind Maps to
translate filter i18n::translate()
format filter i18n::format()
_ function i18n::translate()
_f function i18n::format()
i18n_url function i18n::url()
i18n_number function i18n::number()
i18n_currency function i18n::currency()
i18n_date function i18n::date()

None of them is marked html safe. A translation that really does carry markup needs an explicit |raw; everything else is escaped by twig as usual, which is what keeps a user supplied value substituted into a translation from becoming stored xss.

Twig is optional for the package as a whole, and so it is here: twigRegister() sets view_data either way and returns early when Config::viewEngine() is empty.

i18n::inject() wires an already built config, Locale, Store and Catalog in without touching the router or the request:

<?php
public static function inject(array $config, Locale $locale, Store $store, Catalog $catalog): void;

i18n::reset() clears every static, so one process can serve two languages in turn.