Продвинутое

Локализация

Winter подбирает язык по запросу и переводит строки по ключам из PHP-словарей. Рядом живёт вторая половина той же задачи — часовой пояс пользователя. Оба значения принадлежат одному запросу и не смешиваются между параллельными.

Словари resources/langПеревод trans() / Locale::t()Пояс Timezone::current()

Что такое локализация и зачем

Локализация — приведение того, что видит пользователь, к его языку и привычкам: текстов, дат, времени.

Проблема. Строки, зашитые в код, переводятся только правкой кода, а выбирать язык под каждого пользователя вручную неудобно. Со временем становится хуже: одно и то же сообщение появляется в контроллере, в письме и в ошибке валидации — и расходится в формулировках.

Со временем сложнее: под Swoole воркер обслуживает несколько запросов одновременно, и «текущий язык», сохранённый в обычной статической переменной, начнёт протекать между пользователями.

Решение. Тексты живут в словарях по языкам, код обращается к ним по ключу. Язык выбирается из запроса автоматически, хранится отдельно для каждого запроса и доступен откуда угодно — из контроллера, из сервиса, из фоновой задачи.

Быстрый старт

Настраивать ничего не нужно. Положите словарь в resources/lang:

resources/lang/ru.php
<?php

return [
  'auth' => [
      'welcome'      => 'Добро пожаловать, :name!',
      'unauthorized' => 'Требуется вход',
  ],
  'order' => [
      'created' => 'Заказ :id создан',
  ],
];

И переводите по ключу:

php
trans('auth.unauthorized');                  // → Требуется вход
trans('auth.welcome', ['name' => 'Алиса']);  // → Добро пожаловать, Алиса!

Имя файла — код языка: ru.php, en.php, kk.php. Вложенность произвольная, ключи адресуются точкой.

Словари

Где лежат resources/lang/<язык>.php
Что возвращают Массив, вложенность любой глубины
Как адресуются Точечной нотацией: order.created
Какие языки доступны Определяются по файлам в каталоге

Список доступных языков нигде не объявляется — фреймворк смотрит, какие .php лежат в каталоге. Добавили kk.php — казахский стал доступен.

Всё молчит: ни исключений, ни записей в лог

Механизм отказывает тихо на каждом шаге, и это стоит знать заранее.

Что не так Что вернёт trans('order.created')
Опечатка в ключе order.created — сам ключ
Нет файла нужного языка сам ключ
Каталога resources/lang нет вовсе сам ключ

Последний случай — самый частый на новом проекте: каталог resources/ не создаётся при установке, его заводят руками. Пока его нет, доступных языков ноль, выбирается язык по умолчанию, словарь пуст, и все переводы возвращаются ключами.

Если в интерфейсе видны строки вида order.created — проверяйте в этом порядке: есть ли каталог, есть ли в нём файл нужного языка, нет ли опечатки в ключе.

Минимум, чтобы заработало

mkdir -p resources/lang
// resources/lang/ru.php
return ['order' => ['created' => 'Заказ :id создан']];

Больше ничего: ни регистрации, ни настройки, ни перезапуска сборки. Файл подхватывается при первом обращении к переводу.

Перевод

Два способа обратиться к переводу — они равнозначны:

php
use Flytachi\Winter\Kernel\Localization\Locale;

trans('order.created', ['id' => 42]);          // глобальная функция
Locale::t('order.created', ['id' => 42]);      // то же самое
Locale::translate('order.created', ['id' => 42]);   // полное имя, t() — псевдоним

trans() доступна везде без импортов, поэтому в шаблонах и коротких местах удобнее она.

Подстановки

Способ подстановки выбирается по виду переданного массива.

Массив Как подставляется Пример шаблона
Ассоциативный По имени: :ключ 'Заказ :id создан'
Список Через sprintf 'Добро пожаловать, %s!'
php
trans('order.created', ['id' => 42]);     // 'Заказ :id создан'   → Заказ 42 создан
trans('auth.welcome', ['Алиса']);         // 'Привет, %s!'        → Привет, Алиса!

Именованные удобнее почти всегда: их можно переставлять местами при переводе на другой язык, лишние ключи игнорируются, а незнакомые плейсхолдеры остаются в тексте как есть.

Что можно подставлять

Значения приводятся к строке. Число, строка, null подставятся корректно; объект — только если у него есть __toString(), иначе на его месте окажется пустая строка. Массив в подстановку передавать нельзя.

Множественных форм нет

Осознанная граница: trans_choice(), {count, plural, ...} и подобного в Winter нет. trans() возвращает строку, и только её.

Причина в том, что правила множественного числа сильно различаются между языками — в русском три формы, в английском две, в арабском шесть, — и любой встроенный механизм либо покрывает пару языков, либо превращается в отдельную библиотеку.

Практически это решается двумя способами. Ключ на форму, когда языков немного:

php
'orders' => [
  'one'  => ':count заказ',
  'few'  => ':count заказа',
  'many' => ':count заказов',
],
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]);

Либо MessageFormatter из расширения intl, если нужны правила всех языков сразу и вы готовы держать сообщения в формате ICU.

Выбор языка

Язык определяется автоматически на каждом запросе, из двух источников по порядку:

  1. Кука locale — явный выбор посетителя;
  2. Заголовок Accept-Language — предпочтение браузера.

Если не подошло ничего — берётся язык по умолчанию, en.

Порядок именно такой, потому что источники разной природы. Accept-Language описывает предпочтение, чаще всего унаследованное от системы и ни разу не выбранное человеком сознательно. Кука фиксирует решение: кто-то нажал на переключатель языка. Решение весомее предпочтения.

Заголовок

Фреймворк сопоставляет то, что просит браузер, с тем, что есть в каталоге словарей, и берёт лучшее совпадение с учётом весов q:

text
Accept-Language: ru-RU,ru;q=0.9,en;q=0.8
доступны:        en, ru
выбрано:         ru        (ru-RU не найден → откат к ru)

Кука

Ядро куку только читает — ставит её приложение, там, где обрабатывается переключатель:

php
#[PostMapping('language')]
public function switchLanguage(#[RequestParam] string $lang): ResponseEntity
{
  Cookie::add(Cookie::make('locale', $lang)->expiresIn(60 * 60 * 24 * 365));

  return ResponseEntity::ok();
}

Со следующего запроса Locale::initFromRequest() подхватит её сам.

Настройка Что делает
Locale::setCookieName('lang') Читать другую куку вместо locale
Locale::setCookieName(null) Не смотреть на куки вовсе

Отключают обычно там, где язык задаётся адресом (/ru/...) или профилем: устаревшая кука там только спорила бы с настоящим источником.

Значение куки проверяется, и не из вежливости

Кука приходит от клиента, а язык становится частью пути к словарю — resources/lang/<язык>.php. Поэтому значение принимается, только если оно называет реально существующий словарь.

locale=../../../../etc/passwd не станет языком: значение не совпадает ни с одним файлом в каталоге, и выбор просто уходит к Accept-Language. На это есть тест.

Узнать и переопределить

php
Locale::lang();        // 'ru' — язык текущего запроса
Locale::set('kk');     // переключить язык до конца этого запроса

Locale::set() пригождается, когда язык хранится в профиле пользователя и должен победить заголовок браузера — например, в middleware после аутентификации:

php
public function before(HttpRequest $request, HttpResponse $response): void
{
  $user = $this->authenticate($request);

  if ($user->language !== null) {
      Locale::set($user->language);
  }
}

Что автовыбор не смотрит

Кука и Accept-Language — всё. Ни параметр в адресе, ни поддомен, ни профиль пользователя не учитываются: если язык у вас хранится где-то ещё, вызывайте Locale::set() сами, как в примере выше.

Язык вне запроса

trans() работает и там, где запроса нет вовсе — в процессе, в задаче планировщика, в консольной команде. Там автовыбору неоткуда взяться, поэтому действует язык по умолчанию, пока вы не укажете нужный:

php
foreach ($this->users->pending() as $user) {
  Locale::set($user->language);           // язык получателя
  $this->mailer->send($user, trans('digest.subject'));
}

Часовой пояс

Вторая половина локализации: показать время так, как его видит пользователь.

Метод Что делает
Timezone::current() Пояс текущего запроса
Timezone::set(string $tz) Установить пояс на текущий запрос
Timezone::isSet() Задан ли явно
Timezone::reset() Сбросить к значению по умолчанию

Когда пояс не задан, current() возвращает TIME_ZONE из .env, а без него — UTC.

Заполняется он обычно не вручную: готовый ClientTimezoneMiddleware берёт пояс из заголовка клиента и ставит его на время обработки запроса.

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()` и `new DateTime()` без зоны врут

PHP хранит часовой пояс по умолчанию в глобальной переменной движка, общей на весь воркер. Под Swoole несколько запросов обрабатываются одновременно, поэтому запрос, поставивший свой пояс и уступивший управление на обращении к базе, может вернуться и обнаружить чужой.

Это не гипотеза — так было измерено: запрос из Asia/Tashkent уступил управление, запрос из Europe/London поставил свой пояс, и первый после возобновления прочитал лондонское время — и передал его в сессию базы для собственного запроса.

Библиотека это починить не может, так устроен PHP. Поэтому там, где ответ должен принадлежать пользователю, передавайте зону явно — через Timezone::current().

Связь с валидацией

Сообщение ограничения, целиком обёрнутое в фигурные скобки, считается ключом перевода:

php
#[Size(2, 100, message: '{order.name_length}')]
public readonly string $name,
resources/lang/ru.php
'order' => [
  'name_length' => 'Поле «:field»: от :min до :max символов',
],

В подстановки попадает :field — имя поля — и любое публичное свойство самого ограничения: :min, :max, :value. Подробнее — на странице Валидация.

Настройка

Менять обычно нечего: каталог resources/lang и язык en работают из коробки. Если нужно другое, задайте это в configure() на классе приложения — он выполняется раньше, чем что-либо успеет перевестись.

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');
  }
}

Более поздний вызов тоже подействует, но всё, что успело перевестись до него, возьмётся из старого каталога.

Почему у каталога есть значение по умолчанию

Раньше без явной настройки словарь искался по пути /<язык>.php, файла там никогда не оказывалось, и каждый ключ возвращался сам собой. Ни исключения, ни строки в логе — выглядело как сломанная возможность, а не как забытая настройка. Поэтому теперь путь по умолчанию есть.

Дальше