<? phpukraine СТАТТІ
⌕Пошук по платформі
LARAVEL 1 жовтня 2026 · 8 хв читання

Мультимовний сайт на Laravel: переклади, маршрути з мовою і hreflang

Переклад інтерфейсних рядків займає день. Решта часу йде на адреси, canonical, hreflang і на рішення, що робити зі статтею, якої другою мовою ще немає. Розбираємо плюралізацію для української в MessageSelector, локаль у префіксі маршруту замість cookie, таблицю перекладів з унікальним слагом на мову і місця, де локаль витікає у чергу й у листи.

РP
Редакція phpukraine
Редакція платформи

Другу мову на сайті рідко планують заздалегідь, її приносить конкретна потреба: вакансії треба показати закордонним кандидатам, документацію лишити українською, статті перекладати вибірково. Стан «половина контенту двомовна» через це стає постійним, і схема мусить його витримувати, а не вважати аварією.

Далі все на 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().

Варіант «мова в 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-колонкою тримає його теж, але ціною підтримки, яку видно в кожному наступному запиті.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті