marshmallow/translatable
A Laravel package for adding translations to Eloquent models. Use a trait to mark translatable attributes, store per-locale values, and retrieve localized content with fallback support, keeping multilingual model data simple and integrated with Laravel.
Install and publish — this package ships a service provider and at least one migration (a translatables polymorphic table). Start with:
composer require marshmallow/translatable
php artisan vendor:publish --provider="Marshmallow\Translatable\TranslatableServiceProvider"
php artisan migrate
Check config/translatable.php (or similarly named) for the locale list and the morph key naming — that config is the first thing to read, not the README examples.
Where to look first — the migration and the trait are the whole contract. Read database/migrations/*_create_translatables_table.php to see the exact columns (translatable_type, translatable_id, locale, key, value — verify names) because every debugging session later ends there. Then read the trait to see which model methods it injects (translate(), getTranslation(), attribute accessors, magic __get).
Wire up one model — add the trait to the model you want translated, then add the Nova field to that resource. Do this for a single throwaway model first:
class Article extends Model {
use \Marshmallow\Translatable\Traits\Translatable;
}
public function fields(Request $request) {
return [
// ... other fields
\Marshmallow\Translatable\Fields\Translatable::make('Title')
->locales(['en', 'nl', 'de']),
];
}
First real use case — a CMS-style resource where editors need per-locale title, slug, and body on the same record, and you want the site (not Nova) to resolve app()->getLocale() at render time. That is the package's sweet spot: content tables stay clean, translations live in one side table.
Keep the translation column off your main table. The entire premise is that articles has no title column. Your Nova resource field name must match the translation key, not a DB column. If you leave the original column in place you will get silent shadowing between the real attribute and the translated one — pick one and delete the other.
Reading translations in app code. The usual shape is a translated accessor with fallback:
$article->translate('title', 'nl') ?? $article->title; // verify method signature
app()->getLocale(); // if the trait auto-resolves
Decide early whether you want automatic locale resolution via the trait (convenient, but it makes $article->title ambiguous in queued jobs and console commands where app()->getLocale() is en by default) or an explicit translate($key, $locale) call everywhere. For APIs, explicit wins.
Locale switcher in Nova. The cleanest workflow with this kind of field is one Nova form with a locale selector (or tabs) that swaps which translation set the field group is editing, rather than N duplicated fields. If the field supports per-locale configuration, group your translated fields into a Panel/Tab per locale — it keeps the edit screen readable and matches how editors think.
Eager loading is mandatory. Because translations are a separate relation, a listing of 50 articles with 3 translated fields is 150+ queries without it. Either eager load the relation in your repository/controller, or cache the resolved translation map per request. Check whether the trait exposes a withTranslations() scope; if not, add a small helper on your model base class.
Index columns. Nova index rows resolve translated values one row at a time. For any table over ~100 rows, either display the base-locale value via a computed field or accept the query cost knowingly. Searching/sorting on translated values needs a join — this package will not give you whereTranslation() for free unless it's documented; write the join yourself against translatables.
Migration and import path. Existing content needs backfilling: loop your records in chunks and write each existing column into the translatables table for the base locale, then drop the old columns in a second migration. Do it as two deploys (write both, then cut over) so you can roll back.
Saving in Nova. Translated fields save through the resource's store/update lifecycle into the side table. If you have observers, saving/saved hooks on the parent model will not see the translation payload — hook the translated model or the Nova lifecycle event instead, or your slugs/audit logs will silently miss translations.
Test it explicitly. Feature-test both locales rather than trusting the field: create via Nova (or novaPost), assert the translatables rows, then assert the front-end resolution and the fallback when a locale is missing.
composer.json — with one maintainer and a Feb 2026 release, an unpinned minor bump can change the field API.nova constraint in the package's composer.json matches your install, and smoke-test one resource's create/edit/index after any Nova upgrade.app()->getLocale() in the wrong context. Queue workers, Artisan commands, and Nova itself (which uses its own locale, not the front-end one) will all resolve to your default. Never rely on implicit locale in those paths — always pass the locale explicitly.null for a missing translation rather than falling back. Wrap it: translate($key, $locale) ?? translate($key, config('app.fallback_locale')) ?? $original. Missing this produces empty headings in production, not an exception — the quietest possible failure.translatables rows are not covered by the parent model's updated_at unless the trait touches it — verify, and if it doesn't, $model->touch() after writing translations.WHERE title LIKE ? will not find translated titles. Either denormalize a searchable column on the parent for the base locale, or index translations into Scout/Meilisearch with locale as a filterable attribute.translatables rows on model delete (cascade or observer). Orphaned polymorphic rows are the classic bug here; add a scheduled prune if unsure.translatable_type/translatable_id shape means you can bypass the package entirely for bulk operations — DB::table('translatables')->upsert(...) in a job is often faster and clearer than looping model saves. Just keep the morph class string exact (watch morphMap if you alias models).Relation::morphMap() alias vs. FQCN mismatch silently writes rows the trait then cannot find.How can I help you explore Laravel packages today?