RRelayer
Home/Features

Internationalization (i18n)#

Provides a zero-dependency translator, file-based catalogs, automatic locale resolution, and localization of framework-standard messages. It is opt-in via configuration: if you set none of the i18n-related environment variables, it stays single-locale (English) at no cost, and every framework string is byte-identical to when i18n is not used.

Configuration#

Controlled via environment variables (all optional):

APP_LOCALE=en            # default / active locale (default: en)
APP_LOCALES=en,ja        # supported locales (default: APP_LOCALE only)
LOCALE_COOKIE=locale     # cookie name for the cookie source (default: locale)
LOCALE_PATH_PREFIX=true  # opt-out of /{locale}/... routing (default: true)

Translator and LocaleResolver are always registered in the container (autowire, public, Services & DI). However, locale switching (cookie / Accept-Language / path-prefix resolution) only takes effect when APP_LOCALES lists two or more locales. When it is unset (or single-locale), every request resolves to APP_LOCALE and no path rewriting happens at all — even a /en/* route works exactly as when i18n is not used. LOCALE_PATH_PREFIX=false merely disables /{locale}/... routing in a multi-locale app (cookie / Accept-Language stay active); it cannot enable prefix routing in a single-locale app (there is nothing to distinguish).

APP_LOCALE is the default active locale, not the framework's fallback. The built-in relayer.* messages always fall back to English (the complete bundled catalog), so an untranslated key in a framework string is never exposed as-is.

Locale resolution priority#

For each request, LocaleResolver determines the locale in the following priority order (top is highest):

  1. URL path prefix/{locale}/... when the first segment is a

supported locale. It is the only source that rewrites the path the router matches, and /ja/about and /about both reach the same src/Pages/about/page.psx (Routing & Pages).

  1. Session — consulted only when a session is already started.

Starting a session just to determine the locale would emit a Set-Cookie on every request and break the CDN cache for anonymous pages, so the resolver does not do that. In a logged-in flow that already has a session, the stored _locale is honored.

  1. CookieLOCALE_COOKIE (CDN-safe, no session needed).
  2. Accept-Language — negotiated by q-value against the supported list.
  3. DefaultAPP_LOCALE.

Matching is by primary subtag (ja-JP matches a supported ja), and the resolved value is the canonical spelling from APP_LOCALES. The determined locale is also reflected in <html lang="…"> and can be obtained via $request->locale().

Defer fragments and path-prefix routing. The subrequest of <X defer /> is fetched at a root-absolute /_defer/{name} URL that does not include /{locale} (usePHP pins it to the root). So the parent page's path prefix is not propagated, and the fragment's locale is resolved from the cookie / Accept-Language / default, not from the URL path. If you switch locale only via the /{locale}/… prefix and want defer fragments in the same language too, also set LOCALE_COOKIE (or rely on Accept-Language). The cookie is CDN-safe and is the intended carrier for this case.

Translating your own content#

Place a PHP catalog at <projectRoot>/translations/{locale}.php. It may be flat or nested, and is merged (overriding) into the framework's catalog:

// translations/ja.php
return [
    'home.title'   => 'ようこそ',
    'cart.items'   => '{count}点|{count}点', // pipe = plural
    'user'         => ['greeting' => 'こんにちは、{name}さん'],
];

Translator can be injected into any page, layout, or service:

use Polidog\Relayer\I18n\Translator;

return static fn (Translator $t) => h('h1', [], $t->trans('home.title'));
// placeholder:  $t->trans('user.greeting', ['name' => $name])
// plural:       $t->transChoice('cart.items', $count)
  • trans(string $key, array $params = [], ?string $locale = null)

replaces {name} placeholders with $params.

  • transChoice(string $key, int $count, array $params = [], ?string $locale = null)

selects a form from a pipe-separated one|other message using a simplified CLDR rule (English-style one/other; Japanese, Chinese, Korean, etc. are single-form).

  • An unknown key falls back to the key itself (after placeholder

substitution) — visible but not fatal.

Localizing framework-standard messages#

Validation messages and HTTP error reason phrases (HTML error pages, and the JSON {"error": …} body of API Routes) are resolved in the relayer.* namespace of the same catalog, and en and ja are bundled. Because validation schemas are built outside the container, they reach the current translator via a process-wide ambient holder (Polidog\Relayer\I18n\Translators) that AppRouter sets on every request. A custom message passed to refine() / required('…') is always passed through as-is. CLI output (relayer …, CLI Commands) is English only.

Last updated: 2026-05-19
Change history (1)
  • Created