Реалізація мультимовної підтримки сайту (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 команда для масового перекладу
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 тижня.







