Localization¶
Namespace Keel\I18n. A Translator over PHP message files with placeholders and plural forms,
LocaleMiddleware picking the request locale, Locale injected into handlers, and a trans
filter for Twig and Latte.
// config/i18n.php
return static fn(string $projectDir): I18nConfig => new I18nConfig(
path: $projectDir . '/translations', // <path>/<locale>/<domain>.php
locales: ['en', 'ru', 'pt-BR'],
defaultLocale: 'en',
fromPath: true, // /ru/about
sessionKey: 'locale', // after SessionMiddleware
acceptLanguage: true,
);
// config/services.php
return new ServicesConfig([I18nProvider::class]);
// config/http.php
return new HttpConfig(middleware: [SessionMiddleware::class, LocaleMiddleware::class]);
// translations/en/messages.php
return [
'hello' => 'Hello, {name}!',
'cart' => ['items' => '{count} item|{count} items'],
];
// translations/ru/messages.php
return [
'hello' => 'Привет, {name}!',
'cart' => ['items' => '{count} товар|{count} товара|{count} товаров'],
];
#[Get('/cart')]
public function cart(Translator $t, Locale $locale): array
{
return ['title' => $t->trans('cart.items', ['count' => 3]), 'locale' => $locale->value]; // "3 товара", "ru"
}
Messages¶
A message file returns a nested array; keys are dotted paths into it (cart.items), a flat
key with dots in it wins over the path. {placeholders} are filled from the parameters. A
message with | lists plural forms in CLDR order (one|other for English, one|few|many for
Russian, Ukrainian, Polish; one|few|other for Czech, and so on) and the count parameter
picks one by the language's rule. With ext-intl loaded an ICU pattern ({count, plural, one {#
item} other {# items}}) is formatted by MessageFormatter instead. A missing message is
returned as its key, so nothing breaks before the translation exists; has() tells. The
domain argument names another file (errors.php); locale overrides the current one.
A message missing from the request's locale is looked up in fallbackLocale, else the default
locale. Files are loaded once per worker and kept; the current locale is per request: the
translator is Resettable, and LocaleMiddleware sets it (LocaleAware). Translation inside
a service is therefore the request's locale with no plumbing.
Choosing the locale¶
LocaleMiddleware tries, in this order, the sources the config enables, and takes the first
that names a supported locale: a leading path segment (fromPath, /ru/about), the query
parameter (queryParam), the session key (sessionKey, so SessionMiddleware goes first), a
cookie (cookie), the Accept-Language header (acceptLanguage, honouring q values and
mapping en-GB to en or pt to pt-BR when only those are supported). Nothing matching
means defaultLocale. The locale becomes the Locale request attribute (value,
language(), region()) and the Content-Language response header; a negotiated locale adds
Vary: Accept-Language. Locale tags are canonicalised (en_us is en-US).
Validation messages (must not be blank, ...) are not translated: an API's errors are for
developers, and HTML forms render their own messages from the field paths. Rules that take a
message argument accept a translated one.
Templates¶
With a Translator registered, TwigProvider and LatteProvider add trans as a filter and a
function and locale():
{{ 'hello'|trans({name: user.name}) }} {{ trans('cart.items', {count: cart.count}) }} [{{ locale() }}]
{='hello'|trans: ['name' => $user->name]} {trans('cart.items', ['count' => $cart->count])} [{locale()}]