Advanced

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.

Dictionaries resources/langTranslation trans() / Locale::t()Zone Timezone::current()

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:

resources/lang/en.php
<?php

return [
  'auth' => [
      'welcome'      => 'Welcome, :name!',
      'unauthorized' => 'Sign-in required',
  ],
  'order' => [
      'created' => 'Order :id created',
  ],
];

And translate by key:

php
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:

php
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 alias

trans() 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!'
php
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:

php
'orders' => [
  'one'  => ':count order',
  'few'  => ':count orders',
  'many' => ':count orders',
],
php
$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:

  1. The locale cookie — the visitor’s explicit choice;
  2. The Accept-Language header — 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:

text
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 kernel only reads it — the application sets it, wherever the language switcher is handled:

php
#[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

php
Locale::lang();        // 'ru' — the current request's language
Locale::set('kk');     // switch the language for the rest of this request

Locale::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:

php
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:

php
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.

php
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().

A constraint message wrapped entirely in braces is treated as a translation key:

php
#[Size(2, 100, message: '{order.name_length}')]
public readonly string $name,
resources/lang/en.php
'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.

bootstrap.php
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
  • Viewstrans() in templates
  • Responses — negotiating the response format