spatie/laravel-translatable
Adds HasTranslations to Eloquent models to store translations in JSON columns—no extra tables. Define translatable attributes via PHP 8 attribute or $translatable property, then set/get per-locale values while model accessors return the current app locale.
Installation:
composer require spatie/laravel-translatable
No additional configuration is needed beyond requiring the package.
Mark Model as Translatable:
Use either PHP 8 attributes (recommended) or a $translatable property:
use Spatie\Translatable\Attributes\Translatable;
use Spatie\Translatable\HasTranslations;
#[Translatable(['name', 'description'])]
class Product extends Model
{
use HasTranslations;
}
Or:
class Product extends Model
{
use HasTranslations;
public $translatable = ['name', 'description'];
}
First Use Case: Set translations for a model instance:
$product = new Product();
$product->setTranslation('name', 'en', 'Wireless Headphones')
->setTranslation('description', 'en', 'Noise-cancelling wireless headphones')
->save();
Setting Translations:
setTranslation() with explicit locale:
$product->setTranslation('name', 'es', 'Auriculares Inalámbricos');
save()):
$product->name = 'Wireless Headphones'; // Uses app()->getLocale()
Retrieving Translations:
echo $product->name; // 'Wireless Headphones' (if 'en' is current locale)
getTranslation():
echo $product->getTranslation('name', 'es'); // 'Auriculares Inalámbricos'
$translations = $product->getTranslations('name');
Nested JSON Translations:
Define nested keys in $translatable (e.g., 'specs->weight'), then access via:
$product->setTranslation('specs->weight', 'en', '250g');
echo $product->{'specs->weight'}; // '250g'
Bulk Operations:
$product->replaceTranslations('name', ['en' => 'Updated Name', 'es' => 'Nombre Actualizado']);
$product->forgetTranslation('name', 'es'); // Remove Spanish translation
$product->forgetAllTranslations('es'); // Remove all Spanish translations
Querying:
Product::whereLocale('name', 'en')->get(); // Products with English names
Product::whereLocales('name', ['en', 'es'])->get(); // English or Spanish names
Product::whereJsonContainsLocale('name', 'en', 'Wireless')->get();
Fallback Handling: Configure fallbacks in a service provider:
Translatable::fallback(
fallbackLocale: 'en',
fallbackAny: true,
missingKeyCallback: fn($model, $key, $locale, $fallback, $fallbackLocale) => log("Missing: {$key}")
);
setTranslation() in form handlers to avoid magic strings:
$request->validate(['name_en' => 'required']);
$product->setTranslation('name', 'en', $request->name_en);
public function toArray($request)
{
return [
'name' => $this->getTranslations('name'),
'description' => $this->getTranslations('description'),
];
}
getTranslations() to populate multilingual fields in admin interfaces (e.g., Nova, Filament).Locale Sensitivity:
$product->name) uses the current app locale. Always use getTranslation() for explicit locales.$currentLocale = app()->getLocale();
$name = $product->getTranslation('name', $currentLocale);
JSON Column Constraints:
Fallback Overrides:
$useFallbackLocale = false).$product->setTranslation('name', 'en', 'Original');
app()->setLocale('es');
echo $product->name; // Falls back to 'en' if not set
Nested Key Quirks:
meta->title) must include the -> separator. Omitting it causes silent failures.if (!str_contains($key, '->')) {
throw new \InvalidArgumentException("Nested keys must use '->' separator");
}
Query Performance:
whereJsonContainsLocale() uses JSON_CONTAINS under the hood, which can be slow on large datasets.ALTER TABLE products ADD INDEX idx_name_translations ON name;
Mass Assignment:
$fillable explicitly:
protected $fillable = ['name', 'description'];
$fillable or use fill():
$product->fill(['name' => 'New Name']); // Only fills whitelisted fields
Inspect Stored JSON:
Use getRawOriginal('name') to debug stored translations:
dd($product->getRawOriginal('name')); // Raw JSON string
Check Fallback Logic:
Override getTranslation() temporarily to log fallback behavior:
$product->setTranslation('name', 'en', 'Test');
app()->setLocale('fr');
dd($product->getTranslation('name', 'fr')); // Debug fallback
Validate Locale Keys:
Ensure locales are valid (e.g., en, es) and not empty strings. Invalid locales may cause silent failures.
Test Edge Cases:
$product->setTranslation('name', 'en', '').$product->setTranslation('price', 'en', 99.99).$product->setTranslation('name', 'en', 'Café').Custom Accessors:
Override getTranslation() to add logic (e.g., caching, sanitization):
public function getTranslation($key, $locale = null)
{
$translation = parent::getTranslation($key, $locale);
return htmlspecialchars($translation); // Sanitize output
}
Event Listeners:
Listen for TranslationHasBeenSetEvent to log or validate translations:
$product->setTranslation('name', 'en', 'New Name');
// Triggered after save()
Dynamic Fallbacks:
Use getFallbackLocale() to implement dynamic fallbacks (e.g., based on user preferences):
public function getFallbackLocale()
{
return auth()->user()->preferred_locale ?? 'en';
}
Custom Query Scopes: Extend query methods for domain-specific needs:
public function scopeWithEnglishName($query, $name)
{
return $query->whereJsonContainsLocale('name', 'en', $name);
}
Migration Helpers:
Use Spatie\Translatable\Database\TranslatableJson for custom migrations:
Schema::table('products', function (Blueprint $table) {
$table->translatableJson('name')->nullable();
});
How can I help you explore Laravel packages today?