Implementing website multilingual support (i18n)
Multilingual support is not "just plug in a translator". It's an architectural decision that affects URL structure, content storage in database, SEO, caching and deployment. Getting it right the first time is more important than doing it fast.
URL architectural options
Three approaches to organizing URLs for multilingual site:
| Strategy | Examples | When to use |
|---|---|---|
| Subdomain | ru.example.com, en.example.com |
Different servers/CDN per region |
| Path | example.com/ru/, example.com/en/ |
Single server, most cases |
| Separate domain | example.ru, example.com |
Different legal entities or brands |
Path (/ru/, /en/) — most common and simplest to implement.
Server: 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'];
}
// Creating with translations
Product::create([
'price' => 1990,
'sku' => 'PROD-001',
'ru' => ['title' => 'Умные часы', 'slug' => 'umnye-chasy'],
'en' => ['title' => 'Smart Watch', 'slug' => 'smart-watch'],
'de' => ['title' => 'Smartwatch', 'slug' => 'smartwatch'],
]);
// Reading by current locale
app()->setLocale('en');
$product->title; // "Smart Watch"
app()->setLocale('ru');
$product->title; // "Умные часы"
Routing with locale prefixes
// 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');
});
// Redirect from / to /ru/ or determined language
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);
}
Frontend: 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 already escapes
},
})
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}} товаров"
}
}
// Using in components
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>Language: {i18n.language}</span>
</header>
)
}
SEO: hreflang, canonical, sitemap
// Generating hreflang for all pages
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 with language alternatives -->
<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>
Content translation: workflow
Manual translation via Google Translate API for initial population:
// 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', // preserves HTML tags
]);
return array_column($results, 'text');
}
}
// artisan command for bulk translation
php artisan translate:products --from=ru --to=en,de,fr,uk --batch=50
Translation caching
Cache translation files in Redis to avoid reading JSON from disk on each request:
// 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']);
});
}
}
Numerals for all languages
// Universal pluralizer via 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: 'товарів' }),
}
Timeframe
Setting up i18n infrastructure (routes, middleware, Translatable, i18next) without content translation — 3–4 days. Translating UI to 4–5 languages with automatic initial translation and manual review — another 3–5 days depending on string count. Full launch with SEO configuration and sitemap — 1–1.5 weeks.







