guidocella/laravel-multilingual
Laravel package for building multilingual apps: defines per-locale routes and URLs, integrates language switching and detection, and helps translate paths for localized navigation. Lightweight setup for Laravel projects needing clean locale-aware routing.
Installation
composer require guidocella/laravel-multilingual
Publish the config:
php artisan vendor:publish --provider="Guidocella\Multilingual\MultilingualServiceProvider" --tag="config"
Configure Default Locale
Update config/multilingual.php:
'default_locale' => 'en',
'supported_locales' => ['en', 'fr', 'es'],
First Multilingual Model
Extend MultilingualModel and define translatable fields:
use Guidocella\Multilingual\MultilingualModel;
class Product extends MultilingualModel
{
public $translatable = ['name', 'description'];
}
Create a Translated Record
$product = Product::create([
'name' => ['en' => 'Laptop', 'fr' => 'Ordinateur'],
'description' => ['en' => 'High-performance...', 'fr' => 'Haute performance...'],
]);
Retrieve Translations
$product->getTranslation('name', 'fr'); // Returns 'Ordinateur'
Request-based switching:
use Guidocella\Multilingual\Facades\Multilingual;
$locale = app()->getLocale(); // Or from request
Multilingual::setLocale($locale);
$product->name; // Automatically resolves to current locale
Fallback logic:
$product->name ?? $product->getTranslation('name', 'en'); // Fallback to English
$product->toArray(); // Returns translations in current locale
Or force a specific locale:
$product->toArray(['locale' => 'fr']);
Filter by translation:
$products = Product::whereTranslation('name', 'like', '%Laptop%')->get();
Order by translation:
$products = Product::orderByTranslation('name', 'asc')->get();
Livewire example:
public function mount()
{
$this->locale = app()->getLocale();
}
public function save()
{
$product->update([
'name' => [$this->locale => $this->name],
]);
}
Inertia props:
return Inertia::render('ProductEdit', [
'product' => $product->toArray(['locale' => $this->locale]),
]);
Create middleware to set locale from URL:
public function handle($request, Closure $next)
{
$locale = $request->route('locale');
if (in_array($locale, config('multilingual.supported_locales'))) {
app()->setLocale($locale);
}
return $next($request);
}
Register in routes/web.php:
Route::middleware('set-locale')->group(function () {
Route::get('/{locale}/products', [ProductController::class, 'index']);
});
Index translatable fields for large datasets:
Schema::table('products', function (Blueprint $table) {
$table->index('name->'.implode('|', config('multilingual.supported_locales')));
});
Use json columns (default) or switch to separate tables for performance-critical apps:
protected $translationsTable = 'product_translations'; // Custom table
@lang($product->name) // Outputs translated value
<h1>{{ $product->name }}</h1> <!-- Auto-resolves to current locale -->
Mock locales:
$this->app->setLocale('fr');
$product = Product::factory()->create(['name' => ['fr' => 'Test']]);
$this->assertEquals('Test', $product->name);
Assert translations:
$this->assertEquals('Laptop', $product->getTranslation('name', 'en'));
json columns have a 64KB limit. Exceeding this throws JSON document too deep.
Fix: Use a custom table or split long translations (e.g., description into description_short/description_long).null, causing Undefined index errors.
Fix: Always use getTranslation() with fallbacks:
$name = $product->getTranslation('name', app()->getLocale(), 'en');
Cache::forget() after updates.fill() or create() may overwrite all translations unintentionally.
Fix: Explicitly pass translations:
$product->fill([
'name' => ['en' => 'New Name'], // Only update English
]);
$translatable after table creation breaks migrations.
Fix: Run:
php artisan multilingual:install
Or manually add the translations JSON column.dd($product->getTranslations()); // Full raw data
dd(app()->getLocale(), $product->getAvailableLocales());
JSON_ERROR_* errors on save.$translations = json_encode($request->input('name')); // Validate before save
Enable query logging to debug whereTranslation:
DB::enableQueryLog();
$products = Product::whereTranslation('name', 'like', '%Laptop%')->get();
dd(DB::getQueryLog());
Override the default JSON storage:
class Product extends MultilingualModel
{
protected $translationsTable = 'custom_product_translations';
public function getTranslationsAttribute()
{
return $this->translationsTable ? $this->getTranslationsFromTable() : parent::getTranslationsAttribute();
}
}
Extend validation rules:
use Guidocella\Multilingual\Rules\TranslationExists;
$request->validate([
'name' => ['required', new TranslationExists('products', 'name', $locale)],
]);
Customize toArray() in a resource:
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->getTranslation('name', $request->locale),
'translations' => $this->getTranslations(),
];
}
Listen for translation updates:
public function handle($event)
{
if ($event->model instanceof Product) {
// Log or sync translations to external service
}
}
Override locale resolution:
use Guidocella\Multilingual\Facades\Multilingual;
Multilingual::macro('detectLocale', function ($request) {
return $request->header('X-Locale') ?? app()->getLocale();
});
Pro Tip: For large-scale apps, consider combining this package with Spatie’s Laravel Translation Manager for a more robust i18n workflow.
How can I help you explore Laravel packages today?