spatie/laravel-translatable
Adds a HasTranslations trait to Eloquent models to store translations as JSON in the same table (no extra tables). Define translatable attributes via PHP attribute or $translatable, then set/get translations per locale and auto-resolve by app locale.
Installation:
composer require spatie/laravel-translatable
Publish the config (optional):
php artisan vendor:publish --provider="Spatie\Translatable\TranslatableServiceProvider"
Apply to a Model:
Use either PHP 8 attributes (recommended) or a $translatable property:
use Spatie\Translatable\HasTranslations;
use Spatie\Translatable\Attributes\Translatable;
#[Translatable(['title', 'content'])]
class Post extends Model
{
use HasTranslations;
}
or
class Post extends Model
{
use HasTranslations;
public $translatable = ['title', 'content'];
}
First Translation:
$post = new Post();
$post->setTranslation('title', 'en', 'Hello World')->save();
$post->setTranslation('title', 'es', 'Hola Mundo')->save();
// Access translations dynamically (respects app locale)
echo $post->title; // 'Hello World' if app locale is 'en'
Querying Translations:
// Get posts with English titles
Post::whereLocale('title', 'en')->get();
// Get posts with titles matching "Hello" in English
Post::whereJsonContainsLocale('title', 'en', 'Hello')->get();
Translation Management:
$post->setTranslation('title', 'fr', 'Bonjour')->save();
// Or bulk-set via array:
$post->setTranslations([
'title' => ['en' => 'Hello', 'es' => 'Hola'],
'content' => ['en' => 'Body...']
]);
$post->clearTranslation('title', 'en'); // Clear single locale
$post->clearTranslations('title'); // Clear all locales for a field
Dynamic Access:
$post->title; // Falls back to app locale or first available
$post->getTranslation('title', 'es'); // 'Hola'
Nested JSON Translations (v6.10+):
#[Translatable(['meta->description'])]
class Post extends Model { ... }
$post->setTranslation('meta->description', 'en', 'Nested content')->save();
$post->meta->description; // 'Nested content' (if app locale is 'en')
Query Scopes:
// Filter by locale presence
Post::whereLocale('title', 'en')->get();
// Filter by translation value (supports JSON operators)
Post::whereJsonContainsLocale('title', 'en', 'Hello%')->get();
Form Handling:
spatie/laravel-translatable with Laravel Collective or Livewire for multi-language forms:
// Livewire example
public function mount()
{
$this->translations = [
'title' => ['en' => '', 'es' => ''],
'content' => ['en' => '', 'es' => '']
];
}
public function save()
{
$post->setTranslations($this->translations)->save();
}
Validation:
$validator = Validator::make($request->all(), [
'title.en' => 'required|string|max:255',
'title.es' => 'required|string|max:255',
]);
API Responses:
return PostResource::make($post)->additional([
'translations' => $post->getTranslations()
]);
Caching:
Cache::remember("post.{$post->id}.translations", now()->addHours(1), function () use ($post) {
return $post->getTranslations();
});
Testing:
assertDatabaseHas:
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'translations' => json_encode(['title' => ['en' => 'Hello']])
]);
Null Handling:
null values are stored as null in the JSON column. To allow empty strings, configure:
'allow_null_for_translation' => false, // In config/translatable.php
setTranslation(..., null) explicitly if you need to clear a translation.JSON Column Constraints:
json or longtext in MySQL). Avoid text for large translations.Locale Fallbacks:
$post->title ?? $post->getTranslation('title', 'en') ?? 'Default';
Nested Keys:
meta->description) require double hyphens (->) in the attribute name. Typos here will break access.Mass Assignment:
fill() carefully:
$post->fill(['title' => 'Hello']); // Won't work! Use setTranslation() instead.
Query Performance:
whereJsonContainsLocale uses JSON operators, which can be slow on large datasets. Index the JSON column if needed:
ALTER TABLE posts ADD INDEX translations_idx ((CAST(translations AS JSON)));
Inspect Raw JSON:
\Log::debug('Translations JSON:', [$post->getRawOriginal('translations')]);
Check Config:
config/translatable.php for custom settings like:
'allow_null_for_translation' => true,
'handle_empty_strings' => 'ignore', // 'ignore', 'store', or 'replace'
Attribute Conflicts:
#[Translatable] and $translatable, ensure no duplicates:
// Merges to ['title', 'content', 'description']
#[Translatable(['title'])]
class Post { public $translatable = ['content', 'description']; }
Locale-Specific Queries:
toSql() to debug query scopes:
\Log::debug(Post::whereLocale('title', 'en')->toSql());
Custom Storage:
getTranslationsAttribute to use a different storage mechanism:
public function getTranslationsAttribute($value)
{
return Cache::remember("translations.{$this->id}", now()->addHours(1), function () {
return json_decode($value, true);
});
}
Event Hooks:
$post->setTranslation('title', 'en', 'New Title');
// Trigger custom logic via observers or events.
Macros:
HasTranslations::macro('translateAll', function (array $locales) {
foreach ($locales as $locale => $value) {
$this->setTranslation('title', $locale, $value);
}
return $this;
});
Validation Rules:
use Illuminate\Validation\Rule;
Rule::macro('translation_exists', function ($field, $locale) {
return function ($attribute, $value, $fail) {
if (!$this->model()->whereJsonContainsLocale($field, $locale, $value)->exists()) {
$fail('The translation does not exist.');
}
};
});
Fallback Logic:
class Post extends Model
{
public function getTitleAttribute($value)
{
return $value ?? $this->getTranslation('title', 'en') ?? 'Default Title';
}
}
How can I help you explore Laravel packages today?