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.
The three moving parts
Section titled “The three moving parts”url prefix -> Locales::byPrefix() tried first, and wins outrightAccept-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- Resolution picks a locale.
Localesholds every configured country and language pairing and answersbyPrefix()for the prefixes the router stripped.Negotiatornever sees the url: it readsAccept-Language, and only on a request that carried no prefix of its own. Either way the result is one immutableLocale. See locales and negotiation. - The store loads a catalog.
Storeis every database call the layer makes;Catalogis the warmed string table for one language, memoised per request and cached between them. See catalogs. - The formatter renders.
FormatterwrapsMessageFormatter,NumberFormatterandIntlDateFormatter. See formatting.
Scanner and the staticphp i18n command sit beside all three, comparing the source tree
against the database. See extracting strings.
Configuration
Section titled “Configuration”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.
Starting it
Section titled “Starting it”The tables come first. i18n_keys, i18n_translations and i18n_cached are where every
string lives, and nothing creates them for you:
./staticphp i18n install./staticphp migrate applyi18n 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.
Translating
Section titled “Translating”<?php
// Plain lookupi18n::translate('Log in');
// Placeholders, substituted in a single passi18n::translate('Hello %name%', ['%name%' => $user->name], 'html');
// ICU message, which is what handles pluralsi18n::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.
Writing translations from php
Section titled “Writing translations from php”<?php
// Insert or update; registers the key if it is newi18n::set('Log in', 'Pieslēgties', 'lv_lv');
// Same, but throws TranslationError if the key is not registeredi18n::update('Log in', 'Pieslēgties', 'lv_lv');
// Mark warmed copies stale - one language, or every language with nulli18n::cacheInvalidate('lv_lv');set() returns false rather than throwing when the write fails and strict mode is off.
Degrading instead of dying
Section titled “Degrading instead of dying”$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() runi18n::isDegraded(); // has a database call failed this requesti18n::debug(); // prints the current locale, degraded flag and string tableTranslationError 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,countriesandalternates- 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.
Testing and the cli
Section titled “Testing and the cli”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.