dimsav/laravel-translatable
Add multi-language support to any Eloquent model without touching your schema. laravel-translatable stores translations in a dedicated table and lets you read, write, and fall back between locales using familiar Eloquent syntax, making it ideal for multilingual apps and APIs.
Before anything else, check the version ceiling. The package's composer constraints target Laravel 5.x (illuminate/* ~5.0). On Laravel 6+ Composer will typically refuse to install it. If you are on a modern Laravel, budget for astrotomic/laravel-translatable (the maintained fork of this exact codebase, near drop-in: same trait name, same translatedAttributes contract, different namespace) rather than fighting the resolver. On a legacy 5.x app, proceed as below.
v10.0.0 is a small but behaviourally significant release. Three things landed: a new Dimsav\Translatable\Locales helper class, a fix to getRelationKey(), and — the one that matters — the trait no longer overrides save(); translations are now persisted by a saved event listener. Everything below still holds, but read the "event-based persistence" gotcha before you upgrade.
composer require dimsav/laravel-translatable
php artisan vendor:publish --provider="Dimsav\Translatable\TranslatableServiceProvider"
This publishes config/translatable.php — read it first. The keys that actually change behaviour day-to-day are fallback_locale, use_property_fallback, and locale (set to null to follow app()->getLocale()).
Prefer the Locales helper over config('app.locales'). New in v10, Dimsav\Translatable\Locales centralises "which locales does this app support", so your form builders, completeness counters and validation loops all read from one place instead of each inventing their own config key:
use Dimsav\Translatable\Locales;
$locales = app(Locales::class)->all(); // confirm exact method names against the class
Model setup — the trait plus a declarative list of translated columns:
use Dimsav\Translatable\Translatable;
class Article extends Model
{
use Translatable;
public $translatedAttributes = ['title', 'body', 'slug'];
protected $fillable = ['published_at']; // translated columns are handled by the translation model
}
Two tables, not one. The base table loses the translated columns entirely:
Schema::create('articles', function (Blueprint $t) {
$t->increments('id');
$t->timestamp('published_at')->nullable();
$t->timestamps();
});
Schema::create('article_translations', function (Blueprint $t) {
$t->increments('id');
$t->unsignedInteger('article_id'); // <model>_id, singular snake case
$t->string('locale')->index();
$t->string('title');
$t->text('body');
$t->string('slug');
$t->unique(['article_id', 'locale']); // required — the package upserts on this pair
$t->foreign('article_id')->references('id')->on('articles')->onDelete('cascade');
});
The naming convention (articles → article_translations with article_id) must be followed exactly unless you supply a custom $translationModel. If you do deviate, v10's getRelationKey() fix is the code path you want — retest any non-conventional foreign key on upgrade.
First use case — writing and reading one record in two locales:
$article = Article::create();
$article->translateOrNew('en')->title = 'Hello';
$article->translateOrNew('fr')->title = 'Bonjour';
$article->save();
app()->setLocale('fr');
echo $article->title; // Bonjour
The property accessor is the whole point of the package: $article->title resolves against the current app locale, with fallback applied per your config.
The idiomatic pattern is locale-keyed arrays fed through fill(), which the trait intercepts and routes to translation models:
$article->fill([
'published_at' => $request->published_at,
'en' => $request->input('en'), // ['title' => ..., 'body' => ...]
'fr' => $request->input('fr'),
]);
$article->save(); // parent row saves, then the `saved` listener persists dirty translations
Note the ordering: since v10 this is a two-phase operation (parent save(), then translations on the saved event) and it is still not wrapped in a transaction by the package. Wrap it yourself when partial writes are unacceptable:
DB::transaction(function () use ($article, $data) {
$article->fill($data)->save();
});
Build the form as one tab/fieldset per configured locale (source the list from Locales, not a hand-rolled config key), name inputs en[title], fr[title], and validate per locale:
$rules = [];
foreach (app(Locales::class)->all() as $locale) {
$rules["$locale.title"] = 'required|string|max:255';
$rules["$locale.slug"] = "required|alpha_dash|unique:article_translations,slug,NULL,id,locale,$locale";
}
That unique rule is the important one: slugs must be unique per locale, not globally, so the rule has to target the translations table with a locale constraint.
Repopulate the edit form with the explicit per-locale dump rather than reading properties (which only give you one locale at a time):
$form = $article->getTranslationsArray(); // ['en' => [...], 'fr' => [...]]
Three distinct behaviours exist and you will use all three:
$article->title; // current locale, fallback per config
$article->translate('fr')->title; // strict — throws ModelTranslationNotFound
$article->translateOrDefault('fr'); // explicit null-object instead of throwing
$article->translate('fr', false); // do not fall back to fallback_locale
Reserve the strict translate() form for places where a missing translation is genuinely a bug (API responses, generated PDFs, sitemap entries), and catch Dimsav\Translatable\ModelTranslationNotFound at the boundary so one untranslated record doesn't 500 the page.
Article::translatedIn('fr')->get(); // has a French translation
Article::translated()->get(); // has at least one translation
Article::whereTranslation('slug', $slug)->firstOrFail(); // current locale
Article::whereTranslation('slug', $slug, 'fr')->firstOrFail();
Article::whereTranslation('title', 'like', "%$q%", 'en');
Article::withTranslation('fr')->get(); // eager load one locale
Article::withTranslations(['en', 'fr'])->get();
whereTranslation performs a join under the hood, so always pair it with an explicit select('articles.*') when you also need the parent columns, and add distinct() if you chain multiple locale-scoped conditions on the same query.
For localized route model binding, override resolution so the slug lookup is locale-aware:
public function resolveRouteBinding($value)
{
return static::whereTranslation('slug', $value)->firstOrFail();
}
Give editors a way to see coverage and prune dead locales:
$article->translations->pluck('locale'); // which locales exist
$article->deleteTranslations('de'); // prune one
$article->deleteTranslations(); // prune all — the record survives
A "translation completeness" column in an index view is cheap — and since v10 the denominator comes from the helper rather than a config key you had to remember to define:
count($article->translations) . '/' . count(app(Locales::class)->all());
toArray() on a translatable model includes the whole relation, so responses balloon. Either constrain with withTranslation($request->locale) and expose only the property accessors, or return the locale map explicitly in your JsonResource:
return [
'id' => $this->id,
'title' => $this->title, // resolved for the request locale
'slug' => $this->slug,
'locales' => $this->getTranslationsArray(), // only if the client actually wants it
];
Translations are now saved by a saved event listener, not an overridden save(). This is the v10 change most likely to bite you. Any save path that suppresses model events will now persist the parent row and silently drop the translations:
$article->fill($data);
$article->saveQuietly(); // parent saved, translations LOST
Model::withoutEvents(fn () => $article->save()); // same trap
Audit for withoutEvents, saveQuietly, and any import/seed job that mutes events. Also re-check subclasses that override save() and call parent::save() — they used to wrap the translation write and no longer do. If you have a saved observer of your own that re-saves the model, verify you haven't created a loop now that the package listens on the same event.
The package is archived and unmaintained. A v10.0.0 tag existing does not mean a support commitment — no fixes for future PHP or Laravel releases should be expected. Keep the blast radius small by isolating the trait in a base TranslatableModel class so a future swap to astrotomic/laravel-translatable is a namespace edit rather than a hunt through 60 models.
Missing translations fail silently by default. With use_property_fallback enabled, $article->title returns the fallback-locale string, which means a half-translated page looks fine in English and broken nowhere. If you need to detect the gap, compare $article->translateOrDefault($locale)->locale or wrap production SEO surfaces in strict translate() + a try/catch that logs.
Mass-assignment surprises. $fillable on the parent model does not cover translated columns — the trait routes them to a separate model instance. If fill(['en' => [...]]) silently drops fields, check $translatedAttributes is complete and matches the translation table's columns exactly; a typo there is the single most common cause of "my field isn't saving."
The unique index is not optional. Without unique(['article_id', 'locale']) the package's upsert-on-save logic produces duplicate translation rows, and then $article->title returns whichever row the relation happened to load first — a nondeterministic bug that only shows up after a re-save.
Custom foreign keys / translation models need a retest after v10. The getRelationKey() fix changes how the parent-to-translation relation key is derived. Anything relying on the old (buggy) derivation — non-standard $translationModel, manually declared translations() relations, hand-written joins in reporting queries — should be exercised in a test before you roll out.
Every property access is a potential query. $article->title on an unloaded relation lazy-loads the translation model. Rendering a 50-row index with three translated columns each is 50 queries. Always ->withTranslation() or ->with('translations') in list endpoints, and verify with DB::listen or Telescope before shipping.
replicate() drops all translations. Cloning a record gives you a bare parent. Copy them explicitly:
$copy = $article->replicate();
$copy->save();
foreach ($article->getTranslationsArray() as $locale => $fields) {
$copy->translateOrNew($locale)->fill($fields);
}
$copy->save();
Custom translation models and timestamps. If you need translated-column observers, extra fields, or timestamps on the translation row, set protected $translationModel = ArticleTranslation::class; on the parent and have it extend Dimsav\Translatable\Translation. Note the default translation model does not track timestamps — enable $timestamps = true and add the columns if editorial audit trails matter.
Locale state is request-scoped, not model-scoped. Any accessor result depends on app()->getLocale() at call time. Don't serialize a translatable model into a cache or a queued job payload and expect the localized values to survive — cache the getTranslationsArray() output plus the locale key, or rehydrate with an explicit setLocale() in the job.
Testing. Set the locale in the test, not in the model: app()->setLocale('fr'), then assert on properties. For DB assertions target the translations table directly (assertDatabaseHas('article_translations', ['article_id' => $id, 'locale' => 'fr'])) since the parent row carries no translated data. A factory state like ->state(fn () => ['en' => ['title' => 'x'], 'fr' => [...]]) keeps fixtures readable. Add one regression test that asserts translations survive your normal save path after the event-listener change — that is the cheapest guard against the saveQuietly trap.
Extension points worth knowing. Dimsav\Translatable\Locales (new in v10) as the canonical locale source; saveTranslation() / deleteTranslation() on the model for explicit control now that save() is no longer overridden; isTranslationAttribute($key) to distinguish translated from native columns in generic code; and the translation_model_namespace config key for globally namespaced translation models. Overriding fill() or the accessor on a subclass works, but call parent:: first — the trait's implementations are not empty. If you were overriding save() for translation side effects, move that logic into an observer or a saved listener instead.
How can I help you explore Laravel packages today?