Verified against Semitexa Ultimate 2026.10.20.0340
Localization
semitexa/locale does three things on every HTTP request:
- Resolves the locale from the URL prefix, the
localecookie orAccept-Language, under the current tenant's language pack. - Stores it per coroutine (
LocaleContextStore), so concurrent requests in one worker never see each other's language. - Translates keys from JSON catalogs, with named placeholders, plural forms, one fallback locale and optional per-tenant database overrides.
semitexa/ssr adds the Twig functions (trans, trans_choice, locale, locale_url, locale_switch_url) and makes url() and handler redirects locale-aware. The package ships no console commands, and no date or number formatting: format those yourself with the locale code from locale().
Translation files
One JSON file per locale, named by the locale code, in the module's View/locales directory:
src/modules/Shop/src/Application/View/locales/en.json
src/modules/Shop/src/Application/View/locales/uk.json
The older layout src/modules/<Module>/Application/View/locales/ is still read. Installed packages are read from locales/ next to each module's template directory.
{
"shop.title": "Shop",
"shop.greeting": "Hello, {{name}}!",
"shop.items": {
"one": "{{count}} item",
"other": "{{count}} items"
}
}
- A value is either a string or a plural map whose keys are only
zero,one,two,few,many,other. - Anything else is dropped without a warning: numbers, booleans, and nested objects used for grouping (
{"nav": {"home": "Home"}}is not a plural map, so the wholenaventry disappears). Use dotted keys instead. - A file that is not valid JSON is skipped whole, also silently.
Keys and namespaces
Every key is stored twice: under the module prefix (Shop.shop.title, where Shop is the module directory name, or the short module alias such as os for a package), and bare (shop.title). When two modules ship the same bare key, the first one loaded keeps it. Prefix your keys with the module's own name inside the file, as the shipped modules do (hello.*, shell.*), and look them up bare.
When files are read
The catalog is built once per worker, on first use, and kept for the worker's lifetime. Editing a JSON file has no effect until the workers restart (bin/semitexa server:restart app).
Translating in Twig
<html lang="{{ locale()|e('html_attr') }}">
<h1>{{ trans('shop.title') }}</h1>
<p>{{ trans('shop.greeting', {name: user.name}) }}</p>
<p>{{ trans_choice('shop.items', cart.count) }}</p>
| Function | Returns |
|---|---|
trans(key, params = {}) |
the message in the current locale |
trans_choice(key, count, params = {}) |
the plural form for count |
locale() |
the current locale code |
locale_url(path, locale = null) |
path with a locale prefix (see URLs) |
locale_switch_url(locale) |
the current page in another locale |
Output is escaped like any other Twig function result. A translation that contains HTML is printed as text unless you add |raw, and only do that for catalog strings you control.
Translating in PHP
TranslationService is the implementation behind the Twig functions. It is not registered in the container, so it cannot be injected; the working entry point is the Translator facade from semitexa/ssr, which builds the catalog once per worker and translates in the request's locale:
use Semitexa\Ssr\Application\Service\I18n\Translator;
$title = Translator::trans('shop.title');
$hello = Translator::trans('shop.greeting', ['name' => $user->getName()]);
$items = Translator::transChoice('shop.items', $count);
$locale = Translator::getLocale();
// Explicit locale, existence check, key enumeration:
$service = Translator::getService();
$german = $service->trans('shop.title', [], 'de');
$exists = $service->hasTranslation('shop.title');
$keys = Translator::getCatalog()->keys('en', 'shop.');
Translator is marked deprecated in favour of injecting TranslationService, but no injectable service exists yet, so it remains the way in.
Placeholders
Placeholders are {{name}}, replaced by plain string substitution from the params array. Single braces ({name}) and :name are not placeholders in trans().
Plurals
transChoice() / trans_choice() picks a category from the count and then substitutes the count three ways: {{count}}, :count, and count added to params.
| Languages | Categories used |
|---|---|
en, de, nl, sv, da, no, nb, nn |
one (exactly 1), other |
uk, ru, be |
one (1, 21, 31…, not 11), few (2–4, 22–24…, not 12–14), many (everything else) |
pl |
one (exactly 1), few (2–4, 22–24…, not 12–14), many |
| any other language | treated like en: one / other |
The region is ignored (uk-UA uses the uk rules). If the chosen category is missing from the map, other is used, then one. zero and two are accepted in files but no rule ever selects them. A plain string "one form|other form" is still accepted as a two-form plural. trans() on a plural key returns its other form.
Fallback order
For one key, the first hit wins:
- tenant override for the current locale
- catalog, current locale
- tenant override for the fallback locale (
LOCALE_FALLBACK, or the tenant pack'sfallback) - catalog, fallback locale
- the key itself
There is no region fallback: with LOCALE_SUPPORTED=pt-BR, only pt-BR.json is read for that locale, never pt.json.
Per-tenant overrides
A tenant can replace any message without touching the shipped catalog. Overrides live in the tenant-scoped table locale_translation_override (create it with bin/semitexa orm:sync) and are written through TranslationOverrideStore, an #[AsService]:
use Semitexa\Core\Attribute\InjectAsReadonly;
use Semitexa\Locale\Application\Service\I18n\TranslationOverrideStore;
#[InjectAsReadonly]
protected TranslationOverrideStore $overrides;
// For the current tenant only:
$this->overrides->set('en', 'shop.title', 'Store');
$this->overrides->remove('en', 'shop.title');
Each request loads the current tenant's overrides for a locale with one query, the first time it translates in that locale. If the table is missing or the query fails, translation falls back to the catalog and the failure is logged once per tenant and locale per worker.
How the request locale is resolved
The locale phase runs after tenancy and session, before routing. It:
- Takes the tenant's language pack (
LOCALE_*settings, overlaid with the tenant'slocaleplatform settingsdefault,fallback,supportedwhensemitexa/ssrand platform settings are present). - Sets the locale to the pack's default and the fallback to the pack's fallback.
- Tries the resolvers in priority order. The first one that returns a supported locale wins.
| Resolver | Reads | Matches |
|---|---|---|
path |
first path segment, /uk/catalog |
exact, case-sensitive code from the supported list |
cookie |
cookie locale |
exact code from the supported list |
header |
Accept-Language, highest q first |
exact tag (normalized to xx-YY), then its primary subtag (en-US → en) |
The priority comes from LOCALE_STRATEGY and LOCALE_COOKIE_ENABLED:
LOCALE_STRATEGY |
without cookie | with LOCALE_COOKIE_ENABLED=true |
|---|---|---|
path |
path | cookie, path |
header |
header | cookie, header |
both or unset |
path, header | cookie, path, header |
With LOCALE_URL_PREFIX=true, path is moved to the front and becomes authoritative: a prefixed URL uses its prefix, and an unprefixed URL is the default locale. The cookie and header resolvers are never reached.
The framework does not read the session or a user profile for the locale, and it never writes the locale cookie. Storing a person's choice is the application's job (see the switcher below). A LocaleResolved event (locale, resolvedBy) is dispatched once a locale is chosen, including the default chosen for an unprefixed URL when prefixing is on; it is not dispatched when no resolver matched.
With tenancy, the tenant's locale layer is read from the same URL prefix (TENANCY_LOCALE_PREFIXES), and TenantResolvedLocaleListener copies it into the context when the tenant is resolved. The locale phase then resolves the prefix itself, so the layer only matters with LOCALE_ENABLED=false. To give a tenant its own default language, set its pack (default, supported).
Locale-aware URLs and redirects
With LOCALE_URL_PREFIX=true:
- Routing sees the path without the prefix:
/uk/catalogmatches the/catalogroute. - The default locale is never prefixed.
GET/HEADon/en/catalog(whenenis the default) is answered with301to/catalog, query string kept. SetLOCALE_REDIRECT_DEFAULT=falseto serve it instead. - A stored preference redirects. A
GET/HEADpage request (itsAcceptcontainstext/html) on an unprefixed path, carrying alocalecookie with a supported, non-default code, is answered with302to the prefixed URL andVary: Cookie, Accept. Requests without the cookie (crawlers) are not redirected. This check reads the cookie even whenLOCALE_COOKIE_ENABLEDis not set. url('route.name')andRouteUrlBuilder::to()prefix the current locale, unless it is the default.locale_url(path, locale)prefixespathwithlocale(the current one by default), unless it is the default.locale_switch_url(locale)strips any prefix from the current path, drops the query string, and prefixes the target. As a plain link it is bounced back by the preference redirect when it targets the default language and the visitor's cookie says otherwise; use the switcher below.locale_switch_url(<default>)is also the unprefixed current path the switcher needs.- Handler redirects:
$resource->setRedirect('/account')becomes/uk/accountwhile servinguk. Absolute URLs and paths that already start with a supported locale are left alone.
With prefixing off, all of these return the path unchanged and nothing is redirected. The sitemap emits per-locale alternates only when prefixing is on.
A language switcher
Because of the preference redirect, a plain link to the default-language page bounces a visitor whose cookie says otherwise straight back. Change the cookie in the same step as the navigation, and switch the current locale before redirecting: a bare setRedirect() path is prefixed with the locale being served, so after setLocale() it lands in the new language. This works for a GET link and a POST form alike; register the route for both if you use both, and, as with any POST, send the _csrf field for a signed-in visitor:
use Semitexa\Core\Attribute\InjectAsMutable;
use Semitexa\Core\Cookie\CookieJarInterface;
use Semitexa\Core\Http\Response\ResourceResponse;
use Semitexa\Locale\Context\LocaleContextStore;
// Handler for /language, fields: locale, path (e.g. "/catalog")
#[InjectAsMutable]
protected CookieJarInterface $cookies;
public function handle(SwitchLanguagePayload $payload, ResourceResponse $resource): ResourceResponse
{
$locale = $payload->getLocale();
if (!in_array($locale, LocaleContextStore::getSupportedLocales(), true)) {
$locale = LocaleContextStore::getDefaultLocale();
}
$this->cookies->set('locale', $locale, ['path' => '/', 'maxAge' => 31536000, 'sameSite' => 'lax']);
LocaleContextStore::setLocale($locale);
// A path that still carries a locale is left alone by the redirect, so the
// page would stay in the old language: drop the prefix here.
$segments = explode('/', ltrim($payload->getPath(), '/'), 2);
if (in_array($segments[0], LocaleContextStore::getSupportedLocales(), true)) {
$segments = [$segments[1] ?? ''];
}
return $resource->setRedirect('/' . implode('/', $segments));
}
A browser GET to /language from a visitor whose cookie names a supported language other than the default is first redirected to /uk/language?… (query kept); the handler still runs, and its redirect uses the new locale, not the cookie's. A GET switch is a state change any link can trigger, which here only changes a visitor's language.
The locale under Swoole
LocaleContextStore keeps the locale, fallback, default, supported set and prefix flag in Swoole\Coroutine::getContext(): per coroutine, gone when the coroutine ends. Read it through the container or statically:
use Semitexa\Core\Attribute\InjectAsReadonly;
use Semitexa\Core\Locale\LocaleContextInterface;
use Semitexa\Locale\Context\LocaleContextStore;
#[InjectAsReadonly]
protected LocaleContextInterface $localeContext; // LocaleManager: a view over the store
$code = $this->localeContext->getLocale();
$code = LocaleContextStore::getLocale(); // same value
Calling setLocale() changes the locale for the rest of the current request only.
- Async handlers (run in-process after the response) receive the request's locale: the store registers its keys with
CarriedContext. - A coroutine you start yourself begins with no context. Capture with
CarriedContext::capture()before and run insideCarriedContext::runWith(), or set the locale again. - Queued jobs and CLI commands run outside a request coroutine and use a static fallback. Nothing resolves a locale there and nothing carries the sender's: it is
LOCALE_DEFAULT, fallbackLOCALE_FALLBACK. Put the locale in the job payload and set it at the start of the job. The queue worker resets it toLOCALE_DEFAULTafter every job, so one job's locale never leaks into the next. - Mail renders in
MailEnvelope::$localewhen set (matched to the supported list,uk_UA→uk), and restores the sender's locale afterwards. Without it, the sender's locale is used, which in a queued job isLOCALE_DEFAULT.
Configuration
| Key | Default | Used for |
|---|---|---|
LOCALE_ENABLED |
true |
false skips locale resolution entirely: the locale stays LOCALE_DEFAULT, or, with tenancy, the URL prefix's locale layer |
LOCALE_DEFAULT |
en |
locale when nothing matches; never prefixed |
LOCALE_FALLBACK |
en |
second catalog consulted for a missing key |
LOCALE_SUPPORTED |
en,uk,de,pl,ru |
comma-separated codes every resolver validates against |
LOCALE_STRATEGY |
unset (= path, header) | path, header or both |
LOCALE_COOKIE_ENABLED |
false |
put the locale cookie resolver first |
LOCALE_URL_PREFIX |
false |
locale prefix in URLs; makes the path authoritative |
LOCALE_REDIRECT_DEFAULT |
true |
301 from /{default}/… to the bare path |
Values are read once per worker; restart after changing them. A tenant's pack (platform settings, module locale) can replace default, fallback and supported; a tenant default outside its own supported list is replaced by the global default, or the first supported locale.
Watch out for
- App modules cannot override a package's bare key. Package catalogs load first and the first bare key wins. Use your own key prefix, or a tenant override.
- Nested JSON objects vanish. Only plural maps are kept; a grouping object, a number or a boolean is dropped without a message.
{count}is not a placeholder. Only{{count}}and:countare replaced.- Catalog edits need a worker restart. So do
LOCALE_*changes. - With
LOCALE_URL_PREFIX=true,Accept-Languageand the cookie resolver are ignored. A first-time visitor gets the default language on an unprefixed URL; only alocalecookie your application wrote triggers the preference redirect. - The preference redirect beats a plain link. A visitor with
locale=ukwho follows a link to/catalogis sent to/uk/catalog. Switch languages by updating the cookie (above), not by linking to the bare path. - A redirect follows the locale being served.
setRedirect('/catalog')on a Ukrainian page goes to/uk/catalog. A language switcher must callLocaleContextStore::setLocale()with the new locale first (see above). locale_url()replaces a prefix that is already there.locale_url('/uk/a', 'de')is/de/a. A first segment that is not a supported locale (/ukraine/a) is kept.- Supported codes must be spelled as the header resolver normalizes them. It compares
pt-BR, soLOCALE_SUPPORTED=pt-brnever matches a full tag. Only the primary subtag fallback can, and only whenptis in the list too. Accept-Language: uk;q=0never selectsuk. A zero quality excludes the language; a weight that is not a number (q=x) counts as1.- Queued jobs translate in
LOCALE_DEFAULT, not in the sender's locale. Carry the locale in the payload. - Unknown languages get English plural rules. French, Czech, Arabic and others fall back to
one/other.