Реализация мультиязычности (i18n) сайта
Мультиязычность — это не «прикрутить переводчик». Это архитектурное решение, которое влияет на структуру URL, хранение контента в БД, SEO, кеширование и деплой. Сделать правильно с первого раза важнее, чем сделать быстро.
Архитектурные варианты URL
Три подхода к организации URL мультиязычного сайта:
| Стратегия | Примеры | Когда использовать |
|---|---|---|
| Поддомен | ru.example.com, en.example.com |
Разные серверы/CDN на регион |
| Путь | example.com/ru/, example.com/en/ |
Один сервер, большинство случаев |
| Отдельный домен | example.ru, example.com |
Разные юридические лица или бренды |
Путь (/ru/, /en/) — самый распространённый и простой в реализации.
Серверная часть: Laravel + Astrotomic Translatable
composer require astrotomic/laravel-translatable
// database/migrations/..._create_product_translations_table.php
Schema::create('product_translations', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->string('locale', 10)->index();
$table->string('title');
$table->text('description')->nullable();
$table->string('slug')->nullable();
$table->unique(['product_id', 'locale']);
$table->timestamps();
});
// app/Models/Product.php
use Astrotomic\Translatable\Contracts\Translatable as TranslatableContract;
use Astrotomic\Translatable\Translatable;
class Product extends Model implements TranslatableContract
{
use Translatable;
public array $translatedAttributes = ['title', 'description', 'slug'];
protected $fillable = ['price', 'sku', 'is_active'];
}
// Создание с переводами
Product::create([
'price' => 1990,
'sku' => 'PROD-001',
'ru' => ['title' => 'Умные часы', 'slug' => 'umnye-chasy'],
'en' => ['title' => 'Smart Watch', 'slug' => 'smart-watch'],
'de' => ['title' => 'Smartwatch', 'slug' => 'smartwatch'],
]);
// Чтение по текущей локали
app()->setLocale('en');
$product->title; // "Smart Watch"
app()->setLocale('ru');
$product->title; // "Умные часы"
Маршрутизация с префиксами
// routes/web.php
Route::prefix('{locale}')
->where(['locale' => 'ru|en|de|fr|uk'])
->middleware('setLocale')
->group(function () {
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/catalog', [CatalogController::class, 'index'])->name('catalog');
Route::get('/catalog/{slug}', [ProductController::class, 'show'])->name('product');
});
// Редирект с / на /ru/ или определённый язык
Route::get('/', function () {
return redirect()->route('home', ['locale' => app()->getLocale()]);
});
// app/Http/Middleware/SetLocale.php
public function handle(Request $request, Closure $next): mixed
{
$locale = $request->route('locale') ?? session('locale', 'ru');
if (!in_array($locale, ['ru', 'en', 'de', 'fr', 'uk'])) {
abort(404);
}
App::setLocale($locale);
session(['locale' => $locale]);
return $next($request);
}
Фронтенд: i18next
npm install i18next react-i18next i18next-http-backend i18next-browser-languagedetector
// i18n/config.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import HttpBackend from 'i18next-http-backend'
import LanguageDetector from 'i18next-browser-languagedetector'
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'ru',
supportedLngs: ['ru', 'en', 'de', 'fr', 'uk'],
ns: ['common', 'catalog', 'checkout'],
defaultNS: 'common',
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
detection: {
order: ['path', 'cookie', 'localStorage', 'navigator'],
lookupFromPathIndex: 0,
},
interpolation: {
escapeValue: false, // React уже экранирует
},
})
export default i18n
public/locales/
ru/
common.json
catalog.json
checkout.json
en/
common.json
catalog.json
checkout.json
// public/locales/ru/common.json
{
"nav": {
"catalog": "Каталог",
"cart": "Корзина ({{count}})",
"account": "Личный кабинет"
},
"actions": {
"addToCart": "Добавить в корзину",
"buyNow": "Купить сейчас"
},
"product": {
"count_one": "{{count}} товар",
"count_few": "{{count}} товара",
"count_many": "{{count}} товаров",
"count_other": "{{count}} товаров"
}
}
// Использование в компонентах
import { useTranslation } from 'react-i18next'
function CatalogHeader({ count }: { count: number }) {
const { t, i18n } = useTranslation('common')
return (
<header>
<h1>{t('nav.catalog')}</h1>
<span>{t('product.count', { count })}</span>
<span>Язык: {i18n.language}</span>
</header>
)
}
SEO: hreflang, canonical, sitemap
// Генерация hreflang для всех страниц
function hreflangTags(string $routeName, array $params = []): string
{
$locales = ['ru', 'en', 'de', 'fr', 'uk'];
$tags = '';
foreach ($locales as $locale) {
$url = route($routeName, array_merge($params, ['locale' => $locale]));
$tags .= "<link rel=\"alternate\" hreflang=\"{$locale}\" href=\"{$url}\" />\n";
}
$defaultUrl = route($routeName, array_merge($params, ['locale' => 'ru']));
$tags .= "<link rel=\"alternate\" hreflang=\"x-default\" href=\"{$defaultUrl}\" />\n";
return $tags;
}
<!-- sitemap.xml с языковыми альтернативами -->
<url>
<loc>https://example.com/ru/catalog/smart-watch</loc>
<xhtml:link rel="alternate" hreflang="ru" href="https://example.com/ru/catalog/smart-watch"/>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/catalog/smart-watch"/>
<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/catalog/smart-watch"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/ru/catalog/smart-watch"/>
</url>
Перевод контента: workflow
Ручной перевод через Google Translate API для первичного наполнения:
// app/Services/TranslationService.php
use Google\Cloud\Translate\V2\TranslateClient;
class TranslationService
{
private TranslateClient $client;
public function __construct()
{
$this->client = new TranslateClient([
'key' => config('services.google_translate.key'),
]);
}
public function translateBatch(array $texts, string $targetLang, string $sourceLang = 'ru'): array
{
$results = $this->client->translateBatch($texts, [
'source' => $sourceLang,
'target' => $targetLang,
'format' => 'html', // сохраняет HTML-теги
]);
return array_column($results, 'text');
}
}
// artisan command для массового перевода
php artisan translate:products --from=ru --to=en,de,fr,uk --batch=50
Кеширование переводов
Файлы переводов кешируем в Redis чтобы не читать JSON с диска при каждом запросе:
// AppServiceProvider
public function boot(): void
{
if (app()->isProduction()) {
$this->app->singleton('translator', function ($app) {
$loader = new CachedTranslationLoader(
$app['translation.loader'],
$app['cache.store'],
ttl: 3600
);
return new Translator($loader, $app['config']['app.locale']);
});
}
}
Числительные для всех языков
// Универсальный плюрализатор через Intl.PluralRules
function createPluralizer(locale: string, forms: Record<string, string>) {
const rules = new Intl.PluralRules(locale)
return (n: number) => `${n} ${forms[rules.select(n)] ?? forms.other}`
}
const pluralizers = {
ru: createPluralizer('ru', { one: 'товар', few: 'товара', many: 'товаров', other: 'товаров' }),
en: createPluralizer('en', { one: 'item', other: 'items' }),
de: createPluralizer('de', { one: 'Artikel', other: 'Artikel' }),
uk: createPluralizer('uk', { one: 'товар', few: 'товари', many: 'товарів', other: 'товарів' }),
}
Сроки
Настройка i18n-инфраструктуры (маршруты, middleware, Translatable, i18next) без перевода контента — 3–4 дня. Перевод интерфейса на 4–5 языков с автоматической первичной трансляцией и ручной вычиткой — ещё 3–5 дней в зависимости от объёма строк. Полный запуск с SEO-конфигурацией и sitemap — 1–1.5 недели.







