Стандартный путь в Laravel: lang/ru/messages.php, lang/en/messages.php, вызовы __('messages.hero_title') в шаблонах. Для сайта с сотнями строк и отдельным переводчиком это правильно. Для лендинга на один экран текста и блога на две записи это лишний слой, из-за которого правка одной фразы превращается в прыжки между тремя файлами.
Я собрал двуязычность иначе, и через полгода правок жалеть не пришлось. Ниже - устройство целиком, вместе с ловушкой, на которой я потерял полчаса.
Язык живёт в маршруте, а не в сессии
Никакого автоопределения по Accept-Language и никакой локали в сессии. Корень отдаёт русскую версию, английская доступна по слагу:
Route::get('/', [LandingController::class, 'index']);
Route::get('/en', [LandingController::class, 'index'])->defaults('lang', 'en');
Выглядит примитивно, но именно эта примитивность решает главную проблему двуязычных сайтов - петли редиректов. Когда язык хранится в сессии, а корень редиректит по заголовку браузера, переключатель начинает воевать с автоопределением: пользователь жмёт «RU», его кидает на корень, корень видит английский браузер и возвращает на /en. Здесь такого не бывает: переключатель - это две обычные ссылки, и каждая ведёт ровно туда, куда написано.
Ловушка: defaults нельзя повесить на группу
Для блога нужны те же маршруты с префиксом. Пишем очевидное:
// не работает
Route::prefix('en')->defaults('lang', 'en')->group(function () {
Route::get('/blog', [BlogController::class, 'index']);
});
И получаем Method Illuminate\Routing\RouteRegistrar::defaults does not exist. Метод defaults() есть у маршрута, но не у построителя групп: prefix, middleware, name цепляются к группе, а значения по умолчанию нет. Приходится вешать на каждый маршрут отдельно:
Route::prefix('en')->group(function () {
Route::get('/blog', [BlogController::class, 'index'])
->defaults('lang', 'en')->name('en.blog.index');
});
Отдельная подлость в том, что ошибка вылезает не при загрузке маршрутов, а при первом запросе, и в логах теряется среди прочего.
Тексты лежат в трёх местах, и это осознанно
Тексты лендинга - константа в контроллере. Один массив с ключами ru и en, оба набора рядом:
private const TRANSLATIONS = [
'ru' => ['hero_offer' => 'Делаю сайты, веб-приложения и API...'],
'en' => ['hero_offer' => 'I build websites, web apps and APIs...'],
];
Правка формулировки - это открыть один файл и увидеть обе версии сразу. С lang-файлами пришлось бы держать открытыми два.
Общие строки - шапка, подвал, подписи блога - вынесены в отдельный класс. Когда блог получил ту же шапку, что и лендинг, соблазн был скопировать ключи. Это дало бы два источника одного текста и гарантированное расхождение через месяц. Вместо этого появился SiteText::for($lang), а из массива лендинга дубли уехали.
Тексты статей - в базе, парами полей: title и title_en, content и content_en. Русские обязательны, английские нет.
Фолбэк вместо пустоты
Статья почти всегда пишется сначала по-русски. Если перевода ещё нет, английская страница не должна быть пустой:
public function translatedTitle(): string
{
return ($this->isEn() && $this->title_en) ? $this->title_en : $this->title;
}
Проверка именно на непустое значение, а не на существование поля: пустая строка в базе это тоже «перевода нет».
Побочный эффект, о котором стоит знать заранее: на /en может оказаться русский текст. Для блога это лучше, чем дырка, но hreflang для такой статьи выводить не надо - иначе поисковик получит обещание английской версии, которой нет. У меня альтернативы печатаются только при заполненном title_en.
Локаль приложения приходится выставлять руками
Модель решает, какой набор полей отдавать, по app()->getLocale(). Но само по себе значение из маршрута туда не попадает - Laravel про наш параметр lang ничего не знает. Поэтому контроллер блога делает это явно:
private function lang(Request $request): string
{
$lang = $request->route('lang') === 'en' ? 'en' : 'ru';
app()->setLocale($lang);
return $lang;
}
Лендингу это не нужно: он берёт тексты напрямую по $lang, не спрашивая локаль. Разница мелкая, но забыть про неё легко, и тогда английская статья молча отдаёт русский текст.
Переключатель, который не теряет страницу
Самая частая недоделка на двуязычных сайтах: со страницы /en/blog/some-post кнопка «RU» уводит на главную. Пользователь читал статью, а оказался на лендинге.
Лечится тем, что каждая страница сама сообщает свои альтернативы. В общем layout переключатель берёт адреса из секций с запасным значением:
<a href="@yield('alt-ru', url('/'))">RU</a>
<a href="@yield('alt-en', url('/en'))">EN</a>
А страница статьи их переопределяет:
@section('alt-ru', url('/blog/'.$post->slug))
@section('alt-en', url('/en/blog/'.$post->slug))
Лендинг ничего не переопределяет и пользуется запасными значениями. Три строки, а поведение сразу правильное на всех страницах.
Что должен увидеть поисковик
У каждой версии свой canonical на себя же - иначе одна из них объявит себя копией другой и выпадет из выдачи. Плюс взаимные hreflang и x-default на русскую:
<link rel="canonical" href="https://site.com/en/blog/slug">
<link rel="alternate" hreflang="ru" href="https://site.com/blog/slug">
<link rel="alternate" hreflang="en" href="https://site.com/en/blog/slug">
<link rel="alternate" hreflang="x-default" href="https://site.com/blog/slug">
Sitemap генерируется динамически и перечисляет обе версии каждой страницы, у каждой записи те же альтернативы через xhtml:link. Статьи попадают туда автоматически.
Что держать в синхроне
Слабое место подхода очевидно: забытый ключ в английском наборе роняет /en ошибкой «undefined array key», и заметить это можно уже в проде. За сессию правок я проверял совпадение наборов руками раз шесть, после чего закрыл вопрос тестом:
$this->assertSame([], array_values(array_diff(array_keys($ru), array_keys($en))),
'В английском наборе не хватает ключей');
Заодно тест ловит пустые значения и запрещённые символы в текстах. Восемьдесят пять ключей, две локали, четыре проверки - и целый класс ошибок закрыт до того, как код уехал на сервер.
Когда так делать не надо
Если переводом занимается отдельный человек, языков больше двух или текстов больше пары сотен строк - берите lang-файлы. Они для этого и сделаны: переводчику отдаётся папка, а не исходники контроллера.
Всё остальное - лендинг, портфолио, небольшой корпоративный сайт, где текст правит тот же человек, что и вёрстку, - живёт с массивом в контроллере лучше. Меньше файлов, меньше косвенности, обе версии всегда перед глазами.