Локализация
Winter подбирает язык по запросу и переводит строки по ключам из PHP-словарей. Рядом живёт вторая половина той же задачи — часовой пояс пользователя. Оба значения принадлежат одному запросу и не смешиваются между параллельными.
Что такое локализация и зачем
Локализация — приведение того, что видит пользователь, к его языку и привычкам: текстов, дат, времени.
Проблема. Строки, зашитые в код, переводятся только правкой кода, а выбирать язык под каждого пользователя вручную неудобно. Со временем становится хуже: одно и то же сообщение появляется в контроллере, в письме и в ошибке валидации — и расходится в формулировках.
Со временем сложнее: под Swoole воркер обслуживает несколько запросов одновременно, и «текущий язык», сохранённый в обычной статической переменной, начнёт протекать между пользователями.
Решение. Тексты живут в словарях по языкам, код обращается к ним по ключу. Язык выбирается из запроса автоматически, хранится отдельно для каждого запроса и доступен откуда угодно — из контроллера, из сервиса, из фоновой задачи.
Быстрый старт
Настраивать ничего не нужно. Положите словарь в resources/lang:
<?php
return [
'auth' => [
'welcome' => 'Добро пожаловать, :name!',
'unauthorized' => 'Требуется вход',
],
'order' => [
'created' => 'Заказ :id создан',
],
];И переводите по ключу:
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 создан']];Больше ничего: ни регистрации, ни настройки, ни перезапуска сборки. Файл подхватывается при первом обращении к переводу.
Перевод
Два способа обратиться к переводу — они равнозначны:
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!' |
trans('order.created', ['id' => 42]); // 'Заказ :id создан' → Заказ 42 создан
trans('auth.welcome', ['Алиса']); // 'Привет, %s!' → Привет, Алиса!Именованные удобнее почти всегда: их можно переставлять местами при переводе на другой язык, лишние ключи игнорируются, а незнакомые плейсхолдеры остаются в тексте как есть.
Что можно подставлять
Значения приводятся к строке. Число, строка, null подставятся корректно; объект —
только если у него есть __toString(), иначе на его месте окажется пустая
строка. Массив в подстановку передавать нельзя.
Множественных форм нет
Осознанная граница: trans_choice(), {count, plural, ...} и подобного в Winter
нет. trans() возвращает строку, и только её.
Причина в том, что правила множественного числа сильно различаются между языками — в русском три формы, в английском две, в арабском шесть, — и любой встроенный механизм либо покрывает пару языков, либо превращается в отдельную библиотеку.
Практически это решается двумя способами. Ключ на форму, когда языков немного:
'orders' => [
'one' => ':count заказ',
'few' => ':count заказа',
'many' => ':count заказов',
],$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.
Выбор языка
Язык определяется автоматически на каждом запросе, из двух источников по порядку:
- Кука
locale— явный выбор посетителя; - Заголовок
Accept-Language— предпочтение браузера.
Если не подошло ничего — берётся язык по умолчанию, en.
Порядок именно такой, потому что источники разной природы. Accept-Language описывает
предпочтение, чаще всего унаследованное от системы и ни разу не выбранное человеком
сознательно. Кука фиксирует решение: кто-то нажал на переключатель языка. Решение
весомее предпочтения.
Заголовок
Фреймворк сопоставляет то, что просит браузер, с тем, что есть в каталоге словарей, и
берёт лучшее совпадение с учётом весов q:
Accept-Language: ru-RU,ru;q=0.9,en;q=0.8
доступны: en, ru
выбрано: ru (ru-RU не найден → откат к ru)Кука
Ядро куку только читает — ставит её приложение, там, где обрабатывается переключатель:
#[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. На это есть тест.
Узнать и переопределить
Locale::lang(); // 'ru' — язык текущего запроса
Locale::set('kk'); // переключить язык до конца этого запросаLocale::set() пригождается, когда язык хранится в профиле пользователя и должен
победить заголовок браузера — например, в middleware после аутентификации:
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() работает и там, где запроса нет вовсе — в процессе, в задаче
планировщика, в консольной команде. Там автовыбору неоткуда взяться, поэтому
действует язык по умолчанию, пока вы не укажете нужный:
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 берёт пояс из заголовка
клиента и ставит его на время обработки запроса.
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().
Связь с валидацией
Сообщение ограничения, целиком обёрнутое в фигурные скобки, считается ключом перевода:
#[Size(2, 100, message: '{order.name_length}')]
public readonly string $name,'order' => [
'name_length' => 'Поле «:field»: от :min до :max символов',
],В подстановки попадает :field — имя поля — и любое публичное свойство самого
ограничения: :min, :max, :value. Подробнее — на странице
Валидация.
Настройка
Менять обычно нечего: каталог resources/lang и язык en работают из коробки.
Если нужно другое, задайте это в configure() на классе приложения — он выполняется
раньше, чем что-либо успеет перевестись.
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, файла там никогда
не оказывалось, и каждый ключ возвращался сам собой. Ни исключения, ни строки в
логе — выглядело как сломанная возможность, а не как забытая настройка. Поэтому
теперь путь по умолчанию есть.
Дальше
- Валидация — переводы сообщений об ошибках
- Middleware — где ставится язык пользователя и часовой пояс
- Представления —
trans()в шаблонах - Ответы — согласование формата ответа