Локализация в Laravel: три подхода и цена каждого
Языковые файлы, база данных и spatie/laravel-translatable решают разные задачи. Разбираем, где хранить переводы интерфейса и контента и сколько стоит каждый выбор.

Приложение на Laravel надо перевести на второй язык — и это не только строки в интерфейсе. Есть тексты кнопок и уведомлений, которые пишет разработчик, а есть названия товаров и описания статей, которые заводит редактор через админку. Для первого подходит один механизм, для второго — совсем другой. Смешаете их — получите либо миграции ради каждой новой фразы, либо контент, который нельзя перевести без деплоя. Разбираем три рабочих подхода для Laravel 11.x и критерии выбора между ними.
Речь про то, где хранить переводы и как их доставать. Определение языка по URL, домену или заголовку Accept-Language — отдельная тема, здесь её не трогаем и считаем, что нужная локаль уже установлена через App::setLocale().
Два разных типа переводов
Прежде чем сравнивать инструменты, разделите задачу. Путаница между этими двумя типами — главная причина, почему локализация в проекте превращается в кашу.
- Статические строки интерфейса — заголовки, подписи полей, тексты ошибок валидации, письма. Их количество известно на этапе разработки, меняет их разработчик, переводы живут в репозитории.
- Динамический контент — то, что заводят пользователи или редакторы: товары, категории, посты, страницы. Количество заранее неизвестно, переводы вводятся через админку, живут в базе.
Первый тип закрывает встроенный механизм Laravel. Второй встроенных решений не имеет — под него берут либо ручную схему в БД, либо готовый пакет. Ниже — три подхода, покрывающие обе задачи.
Подход 1. Языковые файлы — для интерфейса
Штатный механизм Laravel. Строки лежат в lang/{locale}/*.php или в JSON-файлах lang/{locale}.json. Достаются функциями __() и trans().
// lang/ru/messages.php
return [
'welcome' => 'Добро пожаловать, :name',
'orders' => 'нет заказов|:count заказ|:count заказа|:count заказов',
];
// В контроллере или Blade
echo __('messages.welcome', ['name' => $user->name]);
// Плюрализация под русские формы
echo trans_choice('messages.orders', $count, ['count' => $count]);
Русская плюрализация в Laravel работает: у языка три-четыре формы (один заказ, два заказа, пять заказов), и trans_choice() выбирает нужную по числу. Важно перечислить все формы через | в правильном порядке, иначе для «21 заказа» выведется форма «заказов».
Строки, начиная с Laravel 9, каталог переводов лежит в корне проекта в папке lang, а не в resources/lang. Если у вас проект после апгрейда — проверьте, где именно лежат файлы, оба варианта могут работать.
Цена: любое изменение перевода — правка файла и деплой. Отдать перевод строк редактору без доступа к репозиторию нельзя. Для контента из БД механизм не годится вообще.
Подход 2. Отдельная таблица переводов в БД
Классическая схема для контента: рядом с таблицей products заводится product_translations с полями product_id, locale и переводимыми колонками.
// Миграция
Schema::create('product_translations', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->string('locale', 5);
$table->string('name');
$table->text('description')->nullable();
$table->unique(['product_id', 'locale']);
});
В модели связь и аксессор, который отдаёт перевод под текущую локаль:
class Product extends Model
{
public function translations()
{
return $this->hasMany(ProductTranslation::class);
}
// Перевод под активную локаль с откатом на дефолтную
public function getNameAttribute()
{
$locale = app()->getLocale();
$translation = $this->translations
->firstWhere('locale', $locale)
?? $this->translations->firstWhere('locale', config('app.fallback_locale'));
return $translation?->name;
}
}
Схема прозрачная: данные нормализованы, можно искать и фильтровать по конкретному языку через whereHas. Минус — руками писать связи, аксессоры и заботиться о загрузке через with('translations'), иначе получите проблему N+1 на списке из ста товаров.
Отдельная таблица переводов — это не про удобство разработчика, а про то, что перевод контента становится строкой данных, а не строкой кода. Редактор меняет описание товара на английском через админку, деплой не нужен.
Подход 3. Пакет spatie/laravel-translatable
Пакет от Spatie хранит все переводы поля в одной JSON-колонке той же таблицы. Никаких отдельных таблиц и связей.
// composer require spatie/laravel-translatable
use Spatie\Translatable\HasTranslations;
class Product extends Model
{
use HasTranslations;
// Поля, которые хранят переводы
public $translatable = ['name', 'description'];
}
В базе колонки name и description должны быть типа json. Запись и чтение:
$product = new Product();
$product->setTranslation('name', 'ru', 'Кофеварка');
$product->setTranslation('name', 'en', 'Coffee maker');
$product->save();
// В базе: {"ru":"Кофеварка","en":"Coffee maker"}
app()->setLocale('en');
echo $product->name; // Coffee maker — берётся под текущую локаль
Плюс — минимум кода, N+1 отсутствует по определению: перевод лежит в той же строке. Минус упирается в поиск: искать по переводу внутри JSON сложнее, чем по обычной колонке, и зависит от того, умеет ли ваша СУБД в JSON-функции. PostgreSQL и MySQL 8.x умеют, но запросы будут менее очевидны, а индексы по JSON-полю — отдельная работа. Точный синтаксис методов пакета сверяйте с документацией установленной версии — API у мажорных релизов Spatie менялся.
Сравнение
| Критерий | Языковые файлы | Таблица переводов | spatie/translatable |
|---|---|---|---|
| Для чего | Интерфейс | Контент из БД | Контент из БД |
| Кто меняет | Разработчик | Редактор через админку | Редактор через админку |
| Нужен деплой на правку | Да | Нет | Нет |
| Поиск и фильтр по языку | — | Простой, SQL | Сложнее, через JSON |
| Риск N+1 | Нет | Есть, лечится eager load | Нет |
| Объём своего кода | Минимум | Много | Минимум |
| Плюрализация | Встроена | Своя | Своя |
Когда что выбирать
- Языковые файлы — всегда для интерфейса. Это не альтернатива двум другим, а обязательная часть. Кнопки, ошибки валидации, письма — только здесь.
- Отдельная таблица — когда по переводимому контенту нужен полноценный поиск и фильтрация на конкретном языке: каталог товаров с фасетным поиском по названию, статьи с сортировкой. SQL по нормальной колонке всегда предсказуемее JSON-запросов.
- spatie/translatable — когда контент переводится, но искать по нему на разных языках почти не нужно: описания страниц, названия категорий, тексты баннеров. Меньше кода, меньше связей, меньше шансов уронить список N+1 запросами.
Что ломается на практике
- N+1 на списках при своей таблице. Вывели сто товаров в цикле, у каждого дёрнули
$product->name— сто дополнительных запросов к таблице переводов. ЛечитсяProduct::with('translations')->get(). Ловите такое через Laravel Debugbar илиModel::preventLazyLoading()в dev-окружении. - Отсутствие отката на дефолтную локаль. Редактор завёл товар только на русском, пользователь пришёл с английской локалью — и увидел пустое название. И в своей схеме, и в пакете предусмотрите fallback на
config('app.fallback_locale'). - Неполный список форм в плюрализации. Указали две формы вместо русских трёх-четырёх — и на числах вроде 21 и 22 текст выглядит неграмотно.
- Тип колонки не json при использовании Spatie. Если объявить поле
translatable, а колонку оставитьstring, вы запишете туда сериализованный JSON как обычную строку и получите каша при чтении.
Что делать дальше
Начните с разделения: составьте список того, что переводит разработчик, и того, что переводит редактор. Первое уводите в lang/, для второго выбирайте между таблицей и Spatie по критерию поиска. После внедрения включите preventLazyLoading() на локальной среде и прогоните списки под Debugbar — так вы поймаете N+1 до того, как он приедет в прод. Дальше смотрите на среднее время ответа на страницах со списками контента: рост числа SQL-запросов на карточку — первый признак, что eager loading где-то потерялся.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Laravel Localization — официальная документация, spatie/laravel-translatable — GitHub
Частые вопросы
А если у меня Laravel 8 или 9 — папка lang там же?
Можно ли использовать spatie/translatable и свою таблицу вместе?
Перевод не подставляется, отдаётся пустая строка — что не так?
Насколько поиск по JSON в Spatie медленнее обычной колонки?
Как перевести стандартные сообщения валидации Laravel?
Что переводить в первую очередь при запуске второго языка?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.