Skip to content

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()}]