Skip to content

Translation and i18n

Quiote’s internationalization is built around a TranslationManager that handles four kinds of locale-sensitive output: messages, numbers, currency, and dates. Each is provided by a translator registered per domain, and everything is driven from a single translation.xml catalog. It leans on PHP’s intl extension for the heavy lifting (number/date/currency formatting), so the intl extension must be installed (the Docker image installs it).

Translation is off by default and gated by one setting. The translation_manager is only built — and a TranslationManager only injectable — when it is true:

Config/settings.php
return [
'core.use_translation' => true,
];

There are no global helper functions — you call the manager, injected like any other collaborator:

public function __construct(private readonly TranslationManager $t) {}

In a context that configures no translation, the binding is a factory that throws naming what would have declared it, rather than an empty manager with no locales. Where translation is genuinely optional, ask the container with tryGet() and handle the null.

It exposes four methods, one per output kind. Note the names carefully — the plural helper is __ (double underscore), and _n means number, not plural:

MethodPurposeExample
_($message, $domain?, $locale?, $params?)Translate a message$t->_('Welcome')
__($singular, $plural, $count, ...)Pluralized message$t->__('%d file', '%d files', $n)
_n($number, $domain?, $locale?)Format a number$t->_n(1234.5)
_c($amount, $domain?, $locale?)Format currency$t->_c(19.99)
_d($date, $domain?, $locale?)Format a date/time$t->_d($order->placedAt)

Message translation accepts positional parameters, applied with vsprintf:

$t->_('Hello, %s — you have %d messages', null, null, [$name, $count]);

The $domain argument selects a translation domain; a leading . prefixes the default domain (.errors becomes default.errors). Domains are hierarchical — a lookup for default.errors.Login falls back up to default.errors, then default, until a translator is found. Passing a $locale translates in that locale for one call without changing the current one.

Locales and translators are declared in Config/translation.{xml,php,yaml,yml}. Like all Quiote config it is compiled and cached; unlike settings it has a dedicated schema (namespace .../config/parts/translation/1.1) whose canonical PHP/YAML shape is a flatter array than the nested XML. It has two sections — available locales, and the translators that do the work:

Config/translation.php
return [
'default_domain' => 'default',
'default_locale' => 'en_US',
'default_timezone' => 'America/Los_Angeles',
'locales' => [
'en_US' => ['name' => 'en_US', 'params' => [], 'fallback' => null, 'ldml_file' => null],
'de_DE' => ['name' => 'de_DE', 'params' => [], 'fallback' => null, 'ldml_file' => null],
'fi_FI' => ['name' => 'fi_FI', 'params' => [], 'fallback' => null, 'ldml_file' => null],
],
'translators' => [
'default' => [
'msg' => [
'class' => \Quiote\Translation\GettextTranslator::class,
'filters' => [],
'params' => ['text_domains' => ['default' => '%core.app_dir%/data/i18n']],
],
'num' => ['class' => \Quiote\Translation\QuioteNumberFormatter::class, 'filters' => [], 'params' => []],
'cur' => ['class' => \Quiote\Translation\CurrencyFormatter::class, 'filters' => [], 'params' => ['currency_code' => 'EUR']],
'date' => ['class' => \Quiote\Translation\DateFormatter::class, 'filters' => [], 'params' => ['format' => 'medium']],
],
],
];
  • default_locale and default_timezone are attributes on <available_locales> (top-level default_locale/default_timezone keys in PHP/YAML).
  • Each <translator> can carry a <message_translator> plus <number_formatter>, <currency_formatter>, and <date_formatter>. Translators nest, producing hierarchical domains like default.errors.
  • When a formatter element is present without a class, a sensible default class is used (QuioteNumberFormatter, CurrencyFormatter, DateFormatter). A <message_translator> has no default — you name the class.

Two message translators ship:

  • GettextTranslator — reads gettext .mo files. Full plural support (it parses the Plural-Forms header), so __() works. Configure text_domains to map each domain to a directory of catalogs.
  • SimpleTranslator — translations declared inline in config (params[domain][locale][from] = to). No plural support — __() with this translator throws.

SimpleTranslator’s message shape: flat vs. domain-nested

Section titled “SimpleTranslator’s message shape: flat vs. domain-nested”

For the common case — a translator with no sub-domains of its own — key params directly by locale, with no domain wrapper at all:

'params' => [
'en_US' => ['Welcome' => 'Welcome'],
],

SimpleTranslator::initialize() auto-detects this flat shape: if every top-level key parses as a valid locale identifier, it’s treated as if wrapped in a single ''-keyed domain. This is the shape to reach for by default.

The domain-nested shape (domain => locale => key/translation) is still there for translators that have real sub-domains. It comes with a sharp edge worth knowing about — though you’ll only meet it when nesting.

When resolving a domain, TranslationManager progressively strips trailing .segment parts until a registered translator matches, then passes whatever is left overnot the matched translator’s own name — as the domain argument to translate(). So a translator nested inside another (say, declared with domain default.errors) keys its own messages by the exact suffix left over after the parent is matched:

'params' => [
'errors' => ['en_US' => ['not_found' => 'Not found.']],
],

QuioteNumberFormatter, CurrencyFormatter, and DateFormatter wrap intl’s NumberFormatter and IntlDateFormatter. They pick grouping/decimal separators and patterns from the current locale, and accept a format parameter (a pattern, a per-locale map, or a named specifier). DateFormatter takes type (date / time / datetime) and a format that can be full, long, medium, or short.

The current locale starts at default_locale from the catalog. There is no automatic Accept-Language negotiation — locale selection is explicit. Switch it at runtime:

$t->setLocale('de_DE');
$t->getCurrentLocaleIdentifier(); // may be the closest available match

setLocale() resolves to the closest available locale, so requesting de_DE when only de is configured lands on de. For a one-off in another locale, pass the locale argument to _() / _d() / etc. rather than switching globally.

Matching follows RFC 4647 basic filtering (via intl’s Locale::filterMatches()): a request can resolve to more than one configured locale — for example, requesting nl when both nl_BE and nl_NL are available. Quiote treats that as ambiguous and throws rather than guessing, so keep available_locales free of ranges that overlap in ways you don’t want resolved automatically.

Locale-suffixed templates are also supported: when translation is on, the template layer looks for a locale variant of a template before the base one, so you can localize whole templates as well as strings.

TranslationManager implements the reset contract and clears its cached locale, timezone, and currency state between requests in worker mode, so a setLocale() in one request doesn’t bleed into the next.