i18n

Kaly splits translation into three responsibilities:

  • choosing the locale — Kaly\I18n\LocaleResolver, from the request;
  • translating a message — Kaly\I18n\TranslatorInterface, a shared service;
  • exposing the translation to templates — a Kaly\I18n\LocalizedTranslator handed to each render under the reserved i18n variable.
Request + route ──► LocaleResolver ──► locale ──┬──► LocalizedTranslator ──► i18n in templates
                                                │
                                                └──► TranslatorInterface ──► Translator | SymfonyTranslator

The contract

namespace Kaly\I18n;

interface TranslatorInterface
{
    public function translate(
        string $message,
        array $parameters = [],
        ?string $domain = null,
        ?string $locale = null,
    ): string;
}

A null locale means the default locale configured on the service, which stays stable for the whole lifetime of that service. Implementations are shared: they hold catalogs, never the locale of the current visitor.

Loading catalogs, caching, fallbacks and paths are implementation concerns and stay out of the contract. App::boot() binds TranslatorInterface to the native Translator unless a module provides another implementation.

Native translator

Kaly\I18n\Translator implements the portable basic translation subset: PHP catalogs, message ids, domains, explicit locales and exact parameter replacement. Advanced pluralization, ICU formatting, resource loaders and extended fallback rules belong to external engines such as Symfony Translation.

use Kaly\I18n\Translator;

$translator = new Translator('en');
$translator->translate('hello.world');            // default locale
$translator->translate('hello.world', [], null, 'fr'); // explicit locale

Catalog files are named {domain}.{locale}.php and live in the registered paths:

// messages.en.php
return [
    'hello' => [
        'world' => 'Hello world',
    ],
];

Flat catalogs are also supported:

// messages.en.php
return [
    'hello.world' => 'Hello world',
];

Nested catalogs are flattened to dotted ids at load, like Symfony. A dotted id produced twice from different shapes in the same file is ambiguous and rejected, so every accepted catalog has exactly one reading. When several paths provide the same id, later paths win key by key without dropping the other messages. Flat catalogs are recommended when static auditing matters: the complete id appears literally in both the source code and the catalog. Domains remain independent of ids; Kaly does not require an id to repeat its domain as a prefix.

Presence is existence, never truthiness: '' and '0' are valid translations, null means absent. A missing key returns the id itself, formatted with the parameters — exactly like Symfony. An empty id returns an empty string.

Symfony translator (optional)

composer require symfony/translation

Kaly\I18n\Adapter\SymfonyTranslator wraps any Symfony\Contracts\Translation\TranslatorInterface and simply delegates. Symfony already accepts a locale per call, so the adapter never mutates its state for the current visitor.

use Kaly\I18n\Adapter\SymfonyTranslator;
use Kaly\I18n\TranslatorInterface;
use Symfony\Component\Translation\Loader\PhpFileLoader;
use Symfony\Component\Translation\Translator as SymfonyEngine;

$engine = new SymfonyEngine('en');
$engine->addLoader('php', new PhpFileLoader());
$engine->addResource('php', __DIR__ . '/lang/messages.en.php', 'en');

$definitions->set(TranslatorInterface::class, new SymfonyTranslator($engine));

What is portable between engines

A shared interface does not make catalogs interchangeable. Only this baseline is portable, and it is enforced by NativeVsSymfonyCompatibilityTest running both engines over the same files:

  • message ids, flat or nested (global.test);
  • domains, with null meaning the messages default;
  • parameters, with keys matching the placeholders exactly (['{name}' => 'Test'], ['%name%' => 'Test'], ['#name#' => 'Test']);
  • empty, '0' and null translations, empty ids and missing ids formatted with the parameters.

Everything else is engine specific and stays that way on purpose:

Behaviour Translator Symfony
Plurals not supported, pipes pass through legacy intervals and ICU
ICU messages not supported supported
Fallbacks exact locale, then language, then default locale parent locales plus configured fallbacks
Ambiguity flat-vs-nested collisions rejected later entry wins

Pick Symfony (or another explicitly compatible engine) if you need pluralization or ICU messages.

Locale resolution

The locale belongs to the request, not to the translator. Kaly\I18n\LocaleResolver resolves it, in this order:

  1. the locale imposed by the route (the locale prefix of a localized module, or the locale of a matched translated path, see Routing);
  2. the locale imposed by an incoming middleware ($ctx->useLocale(), or the locale request attribute when calling the resolver directly);
  3. Accept-Language negotiation among the allowed locales;
  4. the default locale.
use Kaly\I18n\LocaleResolver;

$resolver = new LocaleResolver('en', ['en', 'fr']);
$locale = $resolver->resolve($request, $route->locale);

Resolution always returns a usable locale: unknown, malformed or disallowed locales fall back to the default instead of failing the request. With an empty allowed list any parseable locale is accepted. The resolver is stateless, so a locale from one request can never leak into the next one — which matters in a worker.

The application locales (APP_LOCALES=fr,en or $app->locales(['fr', 'en'])) configure both the router and the resolver, so a negotiated locale can never be one the router would reject. A LocaleResolver bound explicitly still wins.

RoutingHandler runs the resolver right after matching the route, so every routed middleware and every controller runs with the locale already established. A RouteLocale middleware on a /{locale}/... route then replaces that provisional locale with the validated route parameter (see Routing). It is read from the context, even when the visitor expressed no preference:

$ctx->locale(); // guaranteed behind the routing step, throws before it

Templates

The dispatcher gives every render its own LocalizedTranslator under the reserved i18n variable. It is immutable, holds the engine plus the locale of the request, and always passes that locale explicitly to the engine. View data cannot override it.

return new View('account/index', ['user' => $user]);
<h1>{{ i18n.translate('account.title') }}</h1>
<h1>{$i18n->translate('account.title')}</h1>
<h1><?= $i18n->translate('account.title') ?></h1>

This works with every engine through a plain variable. Engine-specific sugar is optional and up to you — a Twig trans filter, Latte’s TranslatorExtension — as long as it reads i18n from the current render context: a closure registered at boot that captures the localized translator of the first visitor would bring back the shared state problem. With Latte, translating at compile time also requires the locale to be part of the cache key.

Escaping stays the responsibility of the template engine: the translator returns text and never marks it as safe HTML.

No file cache

Catalogs are built lazily in memory, once per domain and locale: the native translator keeps no file cache, PHP files are the cache. Catalog changes are therefore picked up immediately, in debug mode and in production alike.

No implicit translation context

Translation requires an explicit translator through one of two paths:

  • in a view, the reserved i18n variable — a LocalizedTranslator already bound to the locale of the request;
  • anywhere else, inject TranslatorInterface and pass $ctx->locale().

For work outside a request, such as a worker, bind the locale of the operation:

$i18n = new LocalizedTranslator($translator, $recipient->locale);
$subject = $i18n->translate('mail.order.subject');

Typed translation values

Beyond free-form string ids, Kaly types the values meant for display:

  • Kaly\I18n\TranslationKey: a typed identifier (id() plus domain(), null for the default domain). Backed enums are the expected implementation: autocompletion, safe refactoring, and completeness is a loop over cases() checking every id in every locale catalog.
  • Kaly\I18n\Translatable: a value that produces its own message from an already localized translator (translate(LocalizedTranslator)). It never chooses the engine or the locale, and must not pass an explicit locale.

LocalizedTranslator::resolve(string|TranslationKey|Translatable) resolves such a value for display:

$i18n->resolve('Hello');              // literal, never translated
$i18n->resolve(ArticleMessage::Title); // translated by id and domain
$i18n->resolve($exception);            // the value translates itself

The invariant is absolute: resolve() never guesses. A string is always a literal and never calls the engine; only a TranslationKey triggers a lookup and only a Translatable delegates. When a value implements both, Translatable wins. translate(string ...) stays the low-level API for free-form ids.

Validation violations show the pattern: a Kaly\Validation\Violation carries a short messageId plus domain and parameters, alongside a stable machine code and an always displayable fallback. Kaly\Core\LocalizedExceptionHandler (the default ExceptionHandlerInterface) translates each violation of an InputException or ValidationException with the locale of the request and keeps the whole structure — a missing translation falls back to the violation fallback, and without a resolved locale the fallbacks are used as is. The Http layer never depends on I18n: it only exposes the result through Kaly\Validation\HasValidationResult, and translation composes in Core.

Resolving a violation

A Violation is not a Translatable: Kaly\Validation does not depend on Kaly\I18n, so a violation never resolves itself. Kaly\Core\ViolationMessageResolver composes the two layers where they are allowed to meet — it takes the LocalizedTranslator of the current request and resolves one violation, falling back to the violation’s always displayable message when the catalog has no entry:

use Kaly\Core\ViolationMessageResolver;

$resolver = new ViolationMessageResolver($i18n);
$message = $resolver->resolve($violation);

LocalizedExceptionHandler uses the same resolver for HTTP error bodies. An application adapter that needs a display message builds one resolver per request and calls it, instead of reinterpreting messageId / domain / fallback itself — the kaly-forms FormError is the first such adapter. The resolver stays deliberately narrow (resolve(Violation): string): widening LocalizedTranslator::resolve() to Violation, or adding a generic translateOr(), waits for a second real use case.

For free-form deferred text Kaly provides no generic Message class or t() helper. Applications can carry identifiers and parameters in their own data objects, or use Symfony Translation’s TranslatableMessage and t() when they need that model. Translate Symfony messages through their trans() method with the Symfony engine and an explicit locale; Kaly’s translate() contract and adapter accept string identifiers only.

Static extraction

Prefer literal message identifiers when possible. Calls such as $i18n->translate('account.title') are intentionally easy to grep and statically analyze, including calls on LocalizedTranslator. Use literal domains where possible so tools can identify the target catalog too.

This is a design convention, not a bundled extractor or a guarantee that external extractors recognize Kaly’s API. Tools must recognize the translate() calls used in PHP and templates.

Dynamic identifiers are supported at runtime but may need application-specific extraction rules. Catalog parity across locales, placeholder validation, unused keys and naming conventions remain application policies.

For ICU, loaders, or an existing Symfony catalog, see the Symfony Translator recipe.


Kaly PHP framework — MIT

This site uses Just the Docs, a documentation theme for Jekyll.