Localization
Winter picks the language from the request and translates strings by key out of PHP dictionaries. Alongside lives the other half of the same job — the user’s time zone. Both values belong to one request and do not mix between concurrent ones.
What localization is and why
Localization is bringing what the user sees into line with their language and habits: texts, dates, times.
The problem. Strings baked into the code can only be translated by editing the code, and picking a language per user by hand is awkward. Over time it gets worse: the same message appears in a controller, in an email and in a validation error — and their wordings drift apart.
Harder still: under Swoole a worker serves several requests at once, so a “current language” kept in an ordinary static variable will start leaking between users.
The solution. The texts live in per-language dictionaries and the code reaches them by key. The language is chosen from the request automatically, stored separately per request and available from anywhere — a controller, a service, a background task.
Quick start
There is nothing to configure. Put a dictionary in resources/lang:
<?php
return [
'auth' => [
'welcome' => 'Welcome, :name!',
'unauthorized' => 'Sign-in required',
],
'order' => [
'created' => 'Order :id created',
],
];And translate by key:
trans('auth.unauthorized'); // → Sign-in required
trans('auth.welcome', ['name' => 'Alice']); // → Welcome, Alice!The file name is the language code: en.php, ru.php, kk.php. The nesting is
arbitrary and keys are addressed with dots.
Dictionaries
| Where they live | resources/lang/<language>.php |
| What they return | An array, nested to any depth |
| How they are addressed | In dot notation: order.created |
| Which languages are available | Determined by the files in the directory |
The list of available languages is not declared anywhere — the framework looks at
which .php files lie in the directory. Add kk.php and Kazakh becomes available.
Everything fails quietly: no exceptions, no log records
The machinery fails silently at every step, and that is worth knowing in advance.
| What is wrong | What trans('order.created') returns |
|---|---|
| A typo in the key | order.created — the key itself |
| No file for the language | the key itself |
No resources/lang directory at all |
the key itself |
The last case is the most common on a new project: the resources/ directory is not
created on install, you make it yourself. Until it exists there are zero available
languages, the default language is chosen, the dictionary is empty and every
translation comes back as its key.
If strings like order.created show up in the interface, check in this order: does
the directory exist, is there a file for the language in it, is there a typo in the
key.
The minimum to make it work
mkdir -p resources/lang// resources/lang/en.php
return ['order' => ['created' => 'Order :id created']];Nothing else: no registration, no configuration, no rebuild. The file is picked up on the first translation request.
Translating
Two ways to reach a translation — they are equivalent:
use Flytachi\Winter\Kernel\Localization\Locale;
trans('order.created', ['id' => 42]); // the global function
Locale::t('order.created', ['id' => 42]); // the same thing
Locale::translate('order.created', ['id' => 42]); // the full name; t() is an aliastrans() is available everywhere without imports, so it is handier in templates and
in short places.
Substitutions
The substitution style is chosen by the shape of the array you pass.
| Array | How it substitutes | Template example |
|---|---|---|
| Associative | By name: :key |
'Order :id created' |
| A list | Through sprintf |
'Welcome, %s!' |
trans('order.created', ['id' => 42]); // 'Order :id created' → Order 42 created
trans('auth.welcome', ['Alice']); // 'Hello, %s!' → Hello, Alice!Named ones are better nearly always: they can be reordered when translating into another language, extra keys are ignored, and unknown placeholders stay in the text as they are.
What can be substituted
Values are cast to string. A number, a string and null substitute correctly; an
object only if it has a __toString(), otherwise an empty string appears in its
place. An array must not be passed as a substitution.
There are no plural forms
A deliberate boundary: trans_choice(), {count, plural, ...} and the like do not
exist in Winter. trans() returns a string, and only that.
The reason is that plural rules differ sharply between languages — Russian has three forms, English two, Arabic six — and any built-in mechanism either covers a couple of languages or turns into a library of its own.
In practice there are two ways round it. A key per form, when there are few languages:
'orders' => [
'one' => ':count order',
'few' => ':count orders',
'many' => ':count orders',
],$form = match (true) {
$n % 10 === 1 && $n % 100 !== 11 => 'one',
$n % 10 >= 2 && $n % 10 <= 4
&& ($n % 100 < 10 || $n % 100 >= 20) => 'few',
default => 'many',
};
trans("orders.{$form}", ['count' => $n]);Or MessageFormatter from the intl extension, if you need every language’s rules at
once and are prepared to keep the messages in ICU format.
Choosing the language
The language is determined automatically on every request, from two sources in order:
- The
localecookie — the visitor’s explicit choice; - The
Accept-Languageheader — the browser’s preference.
If neither matched, the default language is used — en.
The order is what it is because the sources are of different kinds. Accept-Language
describes a preference, usually inherited from the operating system and never consciously
chosen by anyone. A cookie records a decision: someone clicked the language switcher.
A decision outweighs a preference.
The header
The framework matches what the browser asks for against what is in the dictionary
directory and takes the best match, honouring the q weights:
Accept-Language: ru-RU,ru;q=0.9,en;q=0.8
available: en, ru
chosen: ru (ru-RU not found → falls back to ru)The cookie
The kernel only reads it — the application sets it, wherever the language switcher is handled:
#[PostMapping('language')]
public function switchLanguage(#[RequestParam] string $lang): ResponseEntity
{
Cookie::add(Cookie::make('locale', $lang)->expiresIn(60 * 60 * 24 * 365));
return ResponseEntity::ok();
}From the next request on, Locale::initFromRequest() picks it up by itself.
| Setting | What it does |
|---|---|
Locale::setCookieName('lang') |
Read a different cookie instead of locale |
Locale::setCookieName(null) |
Ignore cookies entirely |
Turning it off makes sense where the language comes from the URL (/en/...) or from the
account: there a stale cookie would only argue with the real source.
The cookie value is validated, and not out of politeness
The cookie arrives from the client, and the language becomes part of the dictionary path —
resources/lang/<language>.php. So the value is accepted only when it names a dictionary
that actually exists.
locale=../../../../etc/passwd will not become a language: it matches no file in the
directory, and the choice simply falls through to Accept-Language. There is a test for
that.
Reading and overriding it
Locale::lang(); // 'ru' — the current request's language
Locale::set('kk'); // switch the language for the rest of this requestLocale::set() is useful when the language is stored in the user’s profile and must
beat the browser’s header — in a middleware after authentication, for instance:
public function before(HttpRequest $request, HttpResponse $response): void
{
$user = $this->authenticate($request);
if ($user->language !== null) {
Locale::set($user->language);
}
}What automatic selection does not look at
The cookie and Accept-Language, and that is all. Neither a query parameter nor a
subdomain nor the user’s profile is taken into account: if your language lives somewhere
else, call Locale::set() yourself, as in the example above.
The language outside a request
trans() works where there is no request at all too — in a process, in a scheduler
task, in a console command. There is nothing for automatic selection to work from
there, so the default language applies until you name the one you want:
foreach ($this->users->pending() as $user) {
Locale::set($user->language); // the recipient's language
$this->mailer->send($user, trans('digest.subject'));
}Time zone
The other half of localization: showing time the way the user sees it.
| Method | What it does |
|---|---|
Timezone::current() |
The current request’s zone |
Timezone::set(string $tz) |
Set the zone for the current request |
Timezone::isSet() |
Whether it was set explicitly |
Timezone::reset() |
Reset it to the default |
When no zone is set, current() returns TIME_ZONE from .env, and UTC without
that.
It is usually not filled in by hand: the ready-made
ClientTimezoneMiddleware takes the zone from the client’s
header and sets it for the duration of the request.
use Flytachi\Winter\Kernel\Localization\Timezone;
$when = new \DateTimeImmutable('now', new \DateTimeZone(Timezone::current()));
return ResponseEntity::ok(['at' => $when->format('d.m.Y H:i')]);`date()` and `new DateTime()` without a zone lie
PHP keeps the default time zone in an engine-global variable, shared across the whole worker. Under Swoole several requests are handled at once, so a request that set its zone and yielded on a database call can come back and find somebody else’s.
This is not a hypothesis — it was measured: a request from Asia/Tashkent yielded, a
request from Europe/London set its zone, and the first one read London time after
resuming — and passed it into the database session for its own query.
A library cannot fix this; it is how PHP works. So wherever the answer must belong to
the user, pass the zone explicitly — through Timezone::current().
The link with validation
A constraint message wrapped entirely in braces is treated as a translation key:
#[Size(2, 100, message: '{order.name_length}')]
public readonly string $name,'order' => [
'name_length' => 'The ":field" field: from :min to :max characters',
],The substitutions receive :field — the field name — and every public property of the
constraint itself: :min, :max, :value. In detail on the
Validation page.
Configuring it
There is usually nothing to change: the resources/lang directory and the en
language work out of the box. If you need something else, set it in configure() on
the application class — that runs before anything has a chance to be translated.
use Flytachi\Winter\Kernel\App\ApplicationArguments;
use Flytachi\Winter\Kernel\Kernel;
use Flytachi\Winter\Kernel\Localization\Locale;
final class Application extends WinterApplication
{
protected static function configure(ApplicationArguments $args): void
{
parent::configure($args);
Locale::setBasePath(Kernel::$pathRoot . '/translations');
Locale::setDefault('ru');
}
}A later call takes effect too, but everything translated before it comes from the old directory.
Why the directory has a default
Previously, without explicit configuration, the dictionary was looked for at
/<language>.php, no file was ever there, and every key came back as itself. No
exception, no line in the log — it looked like a broken feature rather than a
forgotten setting. Hence the default path.
Next
- Validation — translating error messages
- Middleware — where the user’s language and time zone are set
- Views —
trans()in templates - Responses — negotiating the response format