Building a Laravel multi-language system looks at first glance like just translating a few strings, but in a real project the work spreads across three distinct layers: interface text (lang files), language handling in URLs (locale routing), and translating the dynamic content that lives in your database. This guide covers all three end to end, with working code. The approach is based on the real architecture I use on my own portfolio (aslain.dev), where I manage five languages: TR/EN/FR/NL/DE.
Locale basics: app.locale and fallback
In Laravel the active language is read with App::getLocale() and set with App::setLocale('en'). Two settings matter, both in config/app.php:
'locale' => 'en',
'fallback_locale' => 'en',
fallback_locale defines which language to fall back to when a translation key is missing in the selected language. It is the most commonly forgotten yet most life-saving setting in a multilingual project: if a string has not been translated into French yet, the user sees the English version instead of a blank screen. To manage your supported languages from one place, keep a simple array:
// config/app.php or a custom config file
'available_locales' => ['tr', 'en', 'fr', 'nl', 'de'],
Lang files: interface text
For static interface text (menus, button labels, form headings), use Laravel's built-in translation files. There are two formats. The first is PHP array files; lang/en/messages.php looks like this:
<?php
return [
'welcome' => 'Welcome, :name',
'nav' => [
'projects' => 'Projects',
'contact' => 'Contact',
],
];
In Blade you call it with __('messages.welcome', ['name' => $user->name]); dot notation reaches nested keys. The second format is JSON translation files: in lang/fr.json you use the source sentence itself as the key. This is convenient for short interface strings:
{
"Save changes": "Enregistrer les modifications",
"Projects": "Projets"
}
The call is again __('Save changes'). Laravel checks the JSON file first, then the PHP arrays. For plural forms use trans_choice(): trans_choice('messages.apples', $count) with pipe-separated rules in the file, like 'apples' => '{0} no apples|{1} one apple|[2,*] :count apples'.
Locale routing: detecting and setting the language
The cleanest way to set the language on every request is a middleware. You can read the user's preference first from the session, then from the Accept-Language header:
php artisan make:middleware SetLocale
public function handle(Request $request, Closure $next)
{
$supported = config('app.available_locales');
$locale = $request->session()->get('locale')
?? $request->getPreferredLanguage($supported)
?? config('app.fallback_locale');
if (in_array($locale, $supported, true)) {
App::setLocale($locale);
}
return $next($request);
}
getPreferredLanguage() matches the browser's Accept-Language header against the languages you support, which is more reliable than parsing it by hand. In Laravel 11/12 you register the middleware in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [\App\Http\Middleware\SetLocale::class]);
})
If you want the language in the URL (e.g. /en/projects), wrap your routes in a prefix group. This is valuable for SEO because each language gets its own indexable address:
Route::prefix('{locale}')
->whereIn('locale', ['tr','en','fr','nl','de'])
->group(function () {
Route::get('/projects', [ProjectController::class, 'index']);
});
A language switcher then writes the preference to the session and returns:
Route::get('/lang/{locale}', function (string $locale) {
if (in_array($locale, config('app.available_locales'), true)) {
session()->put('locale', $locale);
}
return back();
});
Translating database content
The real challenge is not the lang files but the dynamic content a user enters through the admin panel: project titles, service descriptions, blog posts. There are two solid approaches here. The first is storing each translatable column as JSON. In the migration you make the column json and cast it in the model:
// migration
$table->json('title');
// model
protected $casts = ['title' => 'array'];
The row holds {"tr":"Başlık","en":"Title","fr":"Titre"}. When reading, a small helper method returns the selected language and falls back to English, then to the first available language:
public function tr(string $field, ?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$values = $this->{$field} ?? [];
return $values[$locale]
?? $values['en']
?? (reset($values) ?: '');
}
This method is fast and needs no extra table; it is exactly what I use on aslain.dev. The second approach is the spatie/laravel-translatable package. It standardizes the same JSON-column idea and resolves $project->title to the active language automatically. Installation is simple:
composer require spatie/laravel-translatable
You add the HasTranslations trait to the model and declare public array $translatable = ['title', 'body'];. For large projects with many languages or translation management needs, the package eases maintenance; for small projects, your own trait is enough.
Performance and common pitfalls
A few practical points keep the system solid:
- Caching: In production run
php artisan config:cacheandroute:cache. Routes containing closures cannot be cached; move them to a controller. - Don't skip the fallback: A single missing key produces an empty title. Always define a fallback chain inside your JSON content too.
- hreflang: If you use the language in the URL, add each language's alternate link to
<head>; Google then serves the right language to the right user. - Dates and numbers: With Carbon,
$date->locale(app()->getLocale())->translatedFormat('d F Y')gives localized month names; do not translate the format by hand.
Frequently Asked Questions
Should I use a JSON column or a separate translations table?
If the number of languages is fixed and reasonable (say, 5), a JSON column is the simplest and fastest route; all languages come back in a single query. If you need many languages, the ability to query languages separately, or to track translation status, a dedicated translations table is more flexible.
Should I manage the language via URL prefix or session?
If SEO matters, prefer a URL prefix (/en/...): each language gets its own indexable address. For a purely personal panel or a single-page app, a session/cookie-based choice is sufficient and simpler.
What does fallback_locale do?
If a translation key cannot be found in the selected language, Laravel automatically falls back to the fallback_locale language. This prevents untranslated text from appearing blank; it is usually set to English.
Planning a multilingual Laravel project? Whether it is a setup from scratch or migrating an existing site to multiple languages, I can help you build a clean, maintainable architecture. Get in touch with me.