Другу мову на сайті рідко планують заздалегідь, її приносить конкретна потреба: вакансії треба показати закордонним кандидатам, документацію лишити українською, статті перекладати вибірково. Стан «половина контенту двомовна» через це стає постійним, і схема мусить його витримувати, а не вважати аварією.
Далі все на Laravel 13 і PHP 8.4, дві локалі uk і en, українська типова.
Файли перекладів і плюралізація
Інтерфейсні рядки живуть у lang/uk/*.php і lang/uk.json. PHP-файли дають короткі ключі з групою (jobs.title), JSON зберігає переклад за цілою фразою як ключем. Для порталу з редакційним текстом зручніші групи, бо __('jobs.filters.remote') видно в grep, а англійська фраза як ключ ламається від кожної правки копірайтингу.
Файли фреймворку витягуються командою php artisan lang:publish, після чого валідаційні повідомлення правляться в lang/uk/validation.php.
Плюралізація цікавіша. Illuminate\Translation\MessageSelector::getPluralIndex() знає українську: вона в тій самій групі, що ru, be, hr, sr, і дає три форми за звичайним слов'янським правилом. Отже рядок має містити рівно три сегменти:
// lang/uk/jobs.php
return [
'count' => ':count вакансія|:count вакансії|:count вакансій',
];
trans_choice('jobs.count', 21) віддасть «21 вакансія», для 3 буде «3 вакансії», для 11 і для 0 буде «вакансій». Нуль обробляється правильно без жодних додаткових умов, бо його індекс той самий, що й у «багато».
А от спроба додати окремий текст для нуля звичним способом ламає все:
'count' => '{0}Вакансій немає|:count вакансія|:count вакансії|:count вакансій',
Для числа 2 choose() спершу шукає явну умову, не знаходить її, далі викидає {0} через stripConditions() і бере сегмент за індексом 1. Сегментів тепер чотири, індекси зсунулись, і користувач бачить «2 вакансія». Явними діапазонами слов'янське правило не описати взагалі: [2,4] не покриє 22. Робочий варіант один: три сегменти в рядку, а «немає» окремим ключем і окремим if у шаблоні.
Другий спосіб отримати неправильну форму тонший. Якщо ключа в lang/uk немає, перекладач бере його з fallback_locale, але індекс форми обчислює за поточною локаллю. Англійський рядок з двома сегментами плюс індекс 2 дає $segments[0], тобто однину там, де має бути множина. Тому відсутній ключ краще ловити одразу:
// AppServiceProvider::boot()
if (! app()->isProduction()) {
Lang::handleMissingKeysUsing(function (string $key, array $replace, string $locale) {
Log::warning('Missing translation', ['key' => $key, 'locale' => $locale]);
});
}
І закріпити тестом, щоб набори ключів не розповзались:
it('keeps translation keys in sync across locales', function () {
$keys = fn (string $locale) => collect(File::files(lang_path($locale)))
->flatMap(fn ($file) => Arr::dot(
require $file->getPathname(),
$file->getFilenameWithoutExtension().'.'
))
->keys()->sort()->values()->all();
expect($keys('en'))->toBe($keys('uk'));
});
Числа і дати перекладач не форматує. Для них є Number::format($value, locale: 'uk') і Carbon::setLocale(), обидва виставляються окремо від App::setLocale().
Мова в URL, а не в cookie
Варіант «мова в cookie, адреса одна» виглядає найдешевшим і стабільно виходить найдорожчим. У пошуковика немає cookie, тому він індексує одну версію сторінки; посилання, яким поділились у чаті, відкривається чужою мовою; CDN і response cache або віддають не ту локаль, або потребують Vary і втрачають половину сенсу. Мова має бути в адресі, а cookie лишається підказкою для редиректу з кореня.
Локаль як параметр префікса, а не як частина імені маршруту:
// routes/web.php
Route::redirect('/', '/uk');
Route::prefix('{locale}')
->whereIn('locale', ['uk', 'en'])
->group(base_path('routes/localized.php'));
final class SetLocale
{
public function handle(Request $request, Closure $next): Response
{
$locale = $request->route('locale') ?? config('app.fallback_locale');
App::setLocale($locale);
Carbon::setLocale($locale);
URL::defaults(['locale' => $locale]);
$request->route()?->forgetParameter('locale');
return $next($request);
}
}
Три рядки тут неочевидні. URL::defaults() дає змогу писати route('articles.show', $slug) без локалі в кожному виклику: генератор підставить поточну. forgetParameter('locale') прибирає зайвий перший аргумент у всіх екшенах, інакше кожен контролер мусить приймати string $locale, який йому не потрібен. Порядок middleware теж важливий: SetLocale має відпрацювати до валідації й до будь-якого коду, що читає переклади, тому в bootstrap/app.php він ставиться на початок групи:
->withMiddleware(function (Middleware $middleware) {
$middleware->web(prepend: [SetLocale::class]);
})
Окрема спокуса, якої краще позбутись одразу: перекладати самі сегменти URI через __('routes.articles'). Після php artisan route:cache у кеш потрапить локаль, активна в момент запуску команди, і всі маршрути другої мови зникнуть. Якщо локалізовані слаги таки потрібні, маршрути реєструються циклом по локалях з явними рядками, а не викликом перекладача.
Автовизначення мови лишається тільки на корені. $request->getPreferredLanguage(['uk', 'en']) плюс cookie, і редирект 302 на конкретний префікс. Робити таке на кожній сторінці не варто: користувач, що свідомо відкрив /en/jobs, має отримати англійську, а не редирект за заголовком браузера.
hreflang і canonical
Canonical на локалізованій сторінці вказує сам на себе, абсолютною адресою, у поточній мові. Українська версія не є дублікатом англійської, і canonical між локалями схрещувати не можна: так ви просто просите пошуковик викинути одну з них з індексу.
Зв'язок між мовами описує hreflang, і в нього три вимоги, які порушують найчастіше. Посилання мусять бути взаємні: якщо /uk/jobs вказує на /en/jobs, то й зворотне посилання має бути. Сторінка мусить посилатись і на себе. Адреси абсолютні, бо відносні просто ігноруються.
public function alternates(Request $request): array
{
$route = $request->route();
return collect(['uk', 'en'])
->mapWithKeys(fn (string $locale) => [
$locale => route(
$route->getName(),
['locale' => $locale] + $route->parameters(),
absolute: true,
),
])
->all();
}
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<link rel="canonical" href="{{ url()->current() }}">
@foreach ($alternates as $locale => $href)
<link rel="alternate" hreflang="{{ $locale }}" href="{{ $href }}">
@endforeach
Цей код годиться для сторінок із стабільним набором параметрів: каталог, фільтри, статика. На контенті він помиляється двічі. Слаг статті в кожній мові свій, тому підставляти той самий параметр не можна. Друга помилка дорожча: hreflang має перераховувати лише ті версії, які реально існують і віддають 200. Посилання на сторінку, яка редиректить або показує український текст під hreflang="en", гірше за його відсутність. Тому альтернативи для контентних сторінок будуються з рядків перекладів, а не з параметрів маршруту.
x-default потрібен, коли є сторінка без мовного таргетингу: той самий корінь, що визначає мову за Accept-Language. Якщо такої сторінки немає, тег можна не віддавати, він не обов'язковий. Дублювати в ньому українську версію сенсу немає.
Переклад контенту в базі
JSON-колонка з перекладами підкупає простотою міграції і закінчується тим, що кожен запит стає проблемою. Відсортувати за заголовком, зробити унікальний слаг на мову, тримати повнотекстовий індекс, опублікувати англійську версію пізніше за українську: все це вимагає окремої колонки або генерованої колонки на кожен випадок.
Окрема таблиця перекладів вирішує це без хитрощів:
Schema::create('article_translations', function (Blueprint $table) {
$table->id();
$table->foreignId('article_id')->constrained()->cascadeOnDelete();
$table->string('locale', 5);
$table->string('slug');
$table->string('title');
$table->text('lead');
$table->longText('body');
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->unique(['article_id', 'locale']);
$table->unique(['locale', 'slug']);
});
unique(['locale', 'slug']) дозволяє мати /uk/articles/cherhy-v-laravel і /en/articles/laravel-queues без колізій, а published_at на рядку перекладу дає часткову публікацію: англійська версія чекає редактуру, українська вже в стрічці. У батьківській таблиці лишається те, що від мови не залежить: категорія, автор, теги, прапорець featured.
Сторінку статті розв'язуємо через переклад, а не через статтю:
$translation = ArticleTranslation::query()
->with('article.author')
->where('locale', app()->getLocale())
->where('slug', $slug)
->whereNotNull('published_at')
->firstOrFail();
Поведінка на межі виходить правильною сама собою. Немає опублікованого перекладу для цієї локалі - 404, а не український текст під англійською адресою. Альтернативи для hreflang беруться з $translation->article->translations з тим самим фільтром по published_at, тож у голову потрапляють тільки живі адреси. Якщо продукт вимагає показати користувачеві хоч щось замість 404, роблять явний 302 на наявну мову і виключають цю локаль з hreflang: тихий fallback у тому ж URL дає пошуковику сторінку, зміст якої не збігається з заявленою мовою.
Де це ламається
Локаль у черзі. Воркер не бачив вашого middleware, тому джоб виконується з app.locale з конфігу. Мову, потрібну для листа чи PDF, передають у конструкторі джоба явно. Для пошти й нотифікацій є готове: Mail::to($user)->locale('en')->send(...) і контракт HasLocalePreference на моделі користувача, після якого preferredLocale() застосовується автоматично.
Валідація. App::setLocale() у контролері відпрацює вже після Form Request, і повідомлення прийдуть мовою з конфігу. Це той самий аргумент за prepend у групі middleware.
HTML у рядках перекладу. Щойно в lang/uk/pages.php з'являється <a href>, у шаблоні з'являється {!! __('pages.terms') !!}, а разом з ним дірка, якщо переклади колись редагуються з адмінки. Розмітка лишається в Blade, у рядок ідуть плейсхолдери: __('pages.terms', ['link' => $link]) або <x-slot>.
Кеш відповідей. Повносторінковий кеш і CDN мусять включати локаль у ключ. Оскільки вона в шляху, це працює без налаштувань, і це ще один аргумент проти cookie.
Зміна слагів. Переклад заголовка тягне новий слаг, старий треба лишати живим через 301, інакше ви власноруч ламаєте посилання, які вже стоять у hreflang на іншій мові.
Number::useLocale() і Carbon::setLocale() ставлять глобальний стан. Під Octane або в довгих консольних процесах вони лишаються від попереднього запиту, тому в middleware їх виставляють завжди, а не умовно.
Переклад вмісту дорожчає нелінійно, і це нормальна причина обмежити його свідомо: інтерфейс двома мовами, документація однією, статті вибірково. Схема з окремою таблицею перекладів і локаллю в URL такий стан тримає без винятків у коді. Схема з cookie і JSON-колонкою тримає його теж, але ціною підтримки, яку видно в кожному наступному запиті.