Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Translatable Laravel Package

spatie/laravel-translatable

A Laravel trait that makes Eloquent model attributes translatable, storing translations as JSON with no extra table needed. Set and retrieve per-locale values, with fallbacks for missing translations.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and migrate. The package stores every translation as JSON inside the model's own table, so there is no extra table, pivot, or locale column to design.

composer require spatie/laravel-translatable
Schema::create('news_items', function (Blueprint $table) {
    $table->id();
    $table->json('name');          // {"en":"...","nl":"..."}
    $table->json('description')->nullable();
    $table->timestamps();
});

2. Mark the model. As of 6.14.0 the preferred way is the PHP attribute; the $translatable property still works and both are merged/deduplicated when present.

use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\Attributes\Translatable;
use Spatie\Translatable\HasTranslations;

#[Translatable('name', 'description')]
class NewsItem extends Model
{
    use HasTranslations;

    protected $fillable = ['name', 'description'];
}

3. First use case — the CRUD round trip. Write translations by locale, read back by locale, let the magic property follow the app locale:

$item = new NewsItem;
$item->setTranslation('name', 'en', 'Name in English')
     ->setTranslation('name', 'nl', 'Naam in het Nederlands')
     ->save();

$item->name;                          // current app locale
$item->getTranslation('name', 'nl');   // explicit locale
$item->getTranslations('name');        // ['en' => '...', 'nl' => '...']

Where to look first: the docs site (spatie.be/docs/laravel-translatable) for the full trait API and the fallback-locale rules, then the changelog — a lot of day-to-day behaviour (nested keys, null/empty handling, JSON query operands, the #[Translatable] attribute) landed in recent minors and the exact version you have matters.

Implementation Patterns

Locale-tabbed admin forms. The natural shape is a form with one tab per locale, submitting name[en], name[nl], etc. Because a translatable attribute accepts an array on assignment, mass assignment works without a custom mutator:

$item->fill($request->validated()); // ['name' => ['en' => '...', 'nl' => '...']]

You can also set them in one shot with setTranslations('name', ['en' => '...', 'nl' => '...']), and remove a single locale with forgetTranslation('name', 'nl').

Validation per locale. Treat the whole attribute as an array and validate the keys as a set:

'name'              => ['required', 'array'],
'name.*'            => ['nullable', 'string', 'max:255'],
'name.'.config('app.fallback_locale') => ['required', 'string'],

or use name.* plus a required_without_all style rule if exactly one locale is mandatory. Anything reaching the trait as a plain scalar is treated as the current locale's translation, which is convenient in seeders and terse controllers but easy to get wrong in bulk imports.

API resources: never expose the raw column. toArray()/toJson() serialise the full JSON blob for every locale. Resolve deliberately instead:

return [
    'name' => $this->getTranslation('name', app()->getLocale()),
    // or, when you want everything for a language switcher:
    'name' => $this->getTranslations('name'),
];

Listing pages and locale-aware filters. The trait ships scopes that map to JSON path queries, so you don't hand-write whereJsonContains:

NewsItem::whereLocale('name', 'en')->get();                       // has an English name
NewsItem::whereLocales('name', ['en', 'nl'])->get();              // has either
NewsItem::whereJsonContainsLocale('name', 'en', 'Name in English')->get();
NewsItem::whereJsonContainsLocale('name', 'en', 'Name in%', 'like')->get();

Use whereLocale/whereLocales for "is this translated?" filters and the whereJsonContainsLocale* variants for value matching. Reach for the raw column only when you need locale-agnostic search.

Nested JSON columns. Since 6.10.0 a translatable path can drill into an existing JSON column using arrow notation — useful for keeping a meta blob structured while translating one field inside it:

#[Translatable('name', 'meta->description')]
class NewsItem extends Model { use HasTranslations; }

$item->setTranslation('meta->description', 'nl', 'Beschrijving');
$item->getTranslation('meta->description', 'nl');

Prefer this over adding dozens of parallel columns when the translated text is genuinely part of a grouped payload.

Fallback strategy is a runtime decision. The magic property and getTranslation() resolve through Laravel's app.fallback_locale. If a screen must never silently show a different language (e.g. legal copy), pass false for the fallback argument / read the specific locale, and let your UI decide. For public sites, the Translatable facade lets you push a fallback (including "fall back to any available translation") at boot or per request — set it once in a service provider rather than scattering getTranslation() calls with hard-coded locales.

Seeders/factories are where the array assignment shines:

NewsItem::factory()->create([
    'name' => ['en' => 'Hello', 'nl' => 'Hallo'],
]);

Compose with UI packages, don't replace them. Filament/Nova/Blade translation-field packages are the extension point for the editing experience; this package is the storage/access layer. Keep translation-field components thin and let the model own the data shape.

Gotchas and Tips

  • The column must be json (or text with JSON semantics). A string column will silently store a mangled blob. nullable() is fine, but combine it with the package's null/empty configuration or you'll get inconsistent null vs "" vs missing-key behaviour across versions (6.9.0 → 6.11.3 changed this several times — pin and test).
  • Don't add array/json casts to translatable columns. The trait handles encoding/decoding itself; stacking a cast on top historically caused double-encode and attribute-mutator bugs (fixed repeatedly in 6.9.x/6.10.2). Same warning for hand-written accessors/mutators on those attributes — use the trait's methods instead.
  • $model->getAttributes() and $model->name are different things. The raw attribute is the JSON string/array of all locales; the property accessor is the current locale. Debugging "wrong language" almost always means something read the raw attribute (or the model was serialised) rather than the accessor.
  • Accessing a nested path via property works with the arrow string ($item->{'meta->description'} / variable property) but $item->meta->description does not go through translation resolution — meta is plain JSON. Keep nested paths in a variable or constant to avoid quoting mistakes.
  • Query performance. whereLocale and friends compile to JSON path expressions; a plain index on the column won't help locale-specific value lookups on MySQL. For hot filters, add generated/virtual columns per locale (or a normalised search table) and keep the trait for reads/writes.
  • Clearing translations: as of 6.10.0 assigning an empty array clears the attribute. Don't rely on null for "remove this locale" — forgetTranslation() is the explicit, readable option.
  • Fallback surprises in tests. Tests inherit app.fallback_locale; a missing translation returns the fallback rather than null, so assertions like assertNull($item->name) fail for the right reason. Set the locale explicitly in the test and assert on getTranslations() when you need exactness.
  • Dirty tracking and save(). setTranslation() writes back to the attribute, so a single save() after a loop is enough; but if you read the raw attribute, mutate the array, and reassign, you bypass the trait's null/empty rules.
  • Model setup ordering. The #[Translatable] attribute only exists from 6.14.0 — if you're on an older minor, use public array $translatable. Both are merged, so removing a column from one place won't remove it if it's still listed in the other; keep a single source of truth per model.
  • Extension points worth knowing: override getFallbackLocale()/fallback resolution on the model for per-entity fallbacks (e.g. a region-specific default), and use the Translatable facade for global overrides. That's the clean seam for multi-tenant or per-channel language policy instead of branching at every call site.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings