Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Локалізація

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

6 питань

Локалізація дає багатомовність. Рядки перекладів лежать у lang/{locale} (наприклад, lang/uk/messages.php або JSON-файли).

// lang/uk/messages.php
return ['welcome' => 'Ласкаво просимо, :name'];
{{ __('messages.welcome', ['name' => $user->name]) }}
  • app()->setLocale('uk') перемикає мову (зазвичай у middleware).
  • trans_choice() обирає форму за кількістю (1 яблуко / 2 яблука / 5 яблук).

Докладніше в документації: Локалізація

Плейсхолдери - змінні частини рядка позначаються двокрапкою:

// lang/uk/messages.php
'welcome' => 'Вітаємо, :name!',
__('messages.welcome', ['name' => $user->name]);

Регістр плейсхолдера впливає на регістр підстановки: :NAME дасть ОЛЕНА, :Name - Олена.

Множина. В англійській дві форми: «1 comment», «2 comments». В українській - три:

Число Форма
1, 21, 31, 101 1 коментар
2-4, 22-24 3 коментарі
0, 5-20, 25-30, 11-14 5 коментарів

Форми розділяють символом |, а функція trans_choice обирає потрібну за числом і правилами мови поточної локалі:

// lang/uk/comments.php
'count' => ':count коментар|:count коментарі|:count коментарів',
trans_choice('comments.count', 1);   // 1 коментар
trans_choice('comments.count', 3);   // 3 коментарі
trans_choice('comments.count', 11);  // 11 коментарів
trans_choice('comments.count', 21);  // 21 коментар

:count підставляється автоматично. Правила української (як і інших мов) уже вбудовані в Laravel - у класі MessageSelector, тож порядок форм для uk такий: одна, кілька, багато.

Явні діапазони - коли потрібен особливий текст для нуля чи межі:

'count' => '{0} Коментарів ще немає|{1} :count коментар|[2,4] :count коментарі|[5,*] :count коментарів',

Але явні діапазони не знають правил мови: [2,4] спрацює для 3, а для 23 - ні. Для українських текстів надійніше лишити три форми без діапазонів, а нуль обробити окремою умовою в коді чи шаблоні.

У Blade:

{{ trans_choice('comments.count', $post->comments_count) }}

Типові помилки:

  • склеювати рядок з частин: $count . ' ' . __('коментарів') - неправильна форма для половини чисел;
  • писати дві форми для української за англійським зразком;
  • перевіряти лише 1, 2 і 5, а не 11-14 і 21 - саме там помиляються найчастіше.

Докладніше в документації: Локалізація: множина

Файли в lang/ перекладають інтерфейс - кнопки, підписи, помилки. Для контенту з бази - назв категорій, описів товарів - потрібен інший підхід, і вибір між трьома.

1. Колонки на кожну мову - найпростіше:

Schema::table('categories', function (Blueprint $table) {
    $table->string('name_uk');
    $table->string('name_en');
});

Працює, поки мов дві. Кожна нова - міграція й правка всіх запитів.

2. JSON-колонка:

$table->json('name');   // {"uk": "Вакансії", "en": "Jobs"}

protected function casts(): array
{
    return ['name' => 'array'];
}

Нова мова не потребує міграції. Мінус - пошук і сортування за перекладом стають незручними, хоча PostgreSQL і MySQL уміють індексувати JSON-шляхи.

3. Окрема таблиця перекладів - рядок на пару «запис + мова». Найгнучкіше: пошук і сортування звичайні, мов скільки завгодно. Ціна - join у кожному запиті.

Що врахувати незалежно від вибору:

  • Запасний варіант. Якщо перекладу немає, показувати мову за замовчуванням, а не порожнє місце.
  • URL. Мову зазвичай виносять у шлях (/en/jobs), бо це дає окремі адреси для індексації - на відміну від зберігання вибору лише в сесії.
  • hreflang у розмітці, щоб пошук розумів звʼязок між версіями.
  • Не лише текст. Формати дат, чисел і валют теж локальні; Carbon::setLocale() і Number::format() це враховують.

Докладніше в документації: Локалізація

Каталог lang у новому Laravel-застосунку відсутній - переклади фреймворку (повідомлення валідації, пагінації, автентифікації) живуть у самому пакеті. Щоб їх змінити чи додати свої:

php artisan lang:publish

Команда створює lang/en/ з файлами auth.php, pagination.php, passwords.php, validation.php. Для української переклади фреймворку зазвичай беруть з пакета спільноти (наприклад, laravel-lang/lang), а не перекладають вручну.

Два формати перекладів.

1. PHP-файли з короткими ключами:

// lang/uk/orders.php
return [
    'status' => [
        'paid' => 'Оплачено',
        'shipped' => 'Відправлено',
    ],
    'empty' => 'Замовлень поки немає',
];
__('orders.status.paid');
  • ключі групуються за файлами й вкладеністю;
  • зручно для системних рядків: статуси, повідомлення валідації, тексти листів;
  • ключ не змінюється, коли змінюється текст.

2. JSON-файли, де ключ - сам текст:

// lang/uk.json
{
    "Save changes": "Зберегти зміни",
    "Welcome back, :name!": "З поверненням, :name!"
}
__('Save changes');
  • у шаблонах видно справжній текст, а не orders.empty;
  • якщо перекладу немає, показується сам ключ - англійський текст, а не технічний ідентифікатор;
  • зручно для великого інтерфейсу, де вигадувати ключ для кожної кнопки обтяжливо.

Порівняння:

PHP-файли JSON
ключ orders.status.paid Save changes
вкладеність так ні
без перекладу показується ключ англійський текст
зміна вихідного тексту ключ лишається ключ змінюється, переклади треба переносити

Пастки:

  • конфлікт імен: рядок __('Orders') за наявності файлу lang/uk/orders.php і відсутності ключа в JSON поверне весь масив файлу;
  • запасна мова (APP_FALLBACK_LOCALE) використовується, коли рядка немає в поточній локалі - зручно, але приховує неперекладені рядки. Знаходити їх допомагає Lang::handleMissingKeysUsing() для логування;
  • переклади пакетів перевизначаються у lang/vendor/{пакет}/{локаль}/.

На практиці часто поєднують: PHP-файли - для системних рядків і повідомлень валідації, JSON - для текстів інтерфейсу.

Докладніше в документації: Локалізація: рядки перекладу як ключі

Мову зазвичай визначає middleware - з URL, налаштувань користувача чи заголовка браузера:

class SetLocale
{
    public function handle(Request $request, Closure $next): Response
    {
        $locale = $request->route('locale')
            ?? $request->user()?->locale
            ?? $request->getPreferredLanguage(['uk', 'en']);

        App::setLocale($locale);

        return $next($request);
    }
}

Де це стикається з кешем - три різні місця:

1. Кеш застосунку. Значення, покладене під ключем nav.items, буде віддане й іншій мові. Ключ має включати локаль:

Cache::remember('nav.items.'.app()->getLocale(), 3600, fn () => ...);

2. Кеш HTTP і CDN. Якщо мова визначається заголовком Accept-Language, а не URL, то одна адреса віддає різний вміст - і проксі роздасть усім ту версію, яка потрапила в кеш першою. Рятує або Vary: Accept-Language, або мова в URL.

3. Кеш маршрутів. route:cache фіксує маршрути один раз, тож локаль не може бути частиною визначення маршруту - лише параметром.

Чому мову краще тримати в URL. Окремі адреси на кожну мову - це єдиний варіант, який нормально індексується: у пошуку зʼявляються обидві версії, hreflang їх звʼязує, а посилання веде туди, куди вело в того, хто ним поділився. Мова в сесії всього цього не дає.

Дрібниця, яку часто пропускають: App::setLocale() не змінює локаль Carbon - для дат потрібен окремий Carbon::setLocale().

Докладніше в документації: Локалізація

Проблема. Локаль - це стан поточного процесу: middleware встановлює App::setLocale('uk') для запиту. Але:

  • адміністратор з англійським інтерфейсом змінює статус замовлення - і лист клієнту генерується англійською;
  • воркер черги та запланована команда взагалі не мають запиту - вони працюють з локаллю за замовчуванням з config/app.php;
  • воркер обробляє завдання різних користувачів поспіль - локаль, встановлена одним завданням через setLocale, «протікає» в наступні.

Рішення 1 - явна локаль при надсиланні:

Mail::to($customer)->locale('uk')->queue(new OrderShipped($order));
$user->notify((new InvoicePaid($invoice))->locale('pl'));

Laravel запам'ятовує локаль у самому завданні, і воркер перемикається на неї лише на час рендерингу листа, а потім повертає попередню.

Рішення 2 - бажана локаль на моделі (краще):

use Illuminate\Contracts\Translation\HasLocalePreference;

class User extends Authenticatable implements HasLocalePreference
{
    public function preferredLocale(): string
    {
        return $this->locale ?? config('app.locale');
    }
}

Тепер усі листи й сповіщення для цього користувача автоматично йдуть його мовою - незалежно від того, хто і звідки їх надіслав. Викликати locale() не потрібно.

Що ще залежить від локалі - і про що забувають:

  • дати й числа: Carbon має власну локаль; $date->translatedFormat('j F') у листі має відповідати мові отримувача;
  • URL у листах: якщо мова закодована в адресі (/uk/orders/42), посилання мають генеруватися для локалі отримувача, а не поточного запиту;
  • тема листа, що формується в envelope() через __(), теж рендериться в локалі завдання - тож перекладайте її там, а не в конструкторі (конструктор виконується в локалі відправника);
  • довільні завдання, що формують тексти (PDF, експорт), - для них перемикання не автоматичне:
use Illuminate\Support\Traits\Localizable;

class GenerateInvoicePdf implements ShouldQueue
{
    use Localizable;

    public function handle(): void
    {
        $this->withLocale($this->user->preferredLocale(), fn () => $this->render());
    }
}

Трейт Localizable (його ж використовують Mailable і відправник сповіщень) повертає попередню локаль у блоці finally - навіть після винятку, на відміну від ручного setLocale.

Тести: відправити лист користувачу з локаллю uk, коли застосунок працює з en, і перевірити assertSeeInHtml('Замовлення відправлено') - цей тест ловить більшість проблем.

Докладніше в документації: Пошта: бажані локалі користувачів