mrmonat/nova-translatable
A Laravel Nova integration for translatable models that lets you edit multilingual fields per locale directly in Nova resources, with locale tabs and inputs for managing translations alongside your other resource fields.
Confirm the stack first. This field is a thin UI adapter for spatie/laravel-translatable. If your model doesn't already use that trait and a JSON column, there's nothing to wire up — go no further.
Prerequisites
spatie/laravel-translatable installed, model has use HasTranslations; public $translatable = ['title', 'body'];json (MySQL 5.7+/Postgres jsonb), not text pretending.Install as a pinned VCS/path repo, not from Packagist. The package is archived; you want a commit you control.
"repositories": [
{ "type": "vcs", "url": "[email protected]:your-org/nova-translatable.git" }
],
"require": { "mrmonat/nova-translatable": "dev-main#<sha>" }
Then confirm the service provider registers the compiled assets (Nova::script / Nova::style) and that dist/js/field.js is committed.
First use case — a Page model with title and body translated across en/fr/de:
use MrMonat\Translatable\Translatable; // verify namespace in composer.json autoload
public function fields(Request $request)
{
return [
Translatable::make('title')
->locales(['en', 'fr', 'de'])
->rules('max:255')
->singleLine(),
Translatable::make('body')
->locales(['en', 'fr', 'de'])
->rules('max:65535'),
];
}
Where to look first in the source (it's small, ~an hour read):
src/Translatable.php — the field class. Read resolveUsing / fillUsing before anything else; that's the whole data contract.resources/js/components/FormField.vue — the locale tabs and dirty-state handling.composer.json — the Nova/Laravel/PHP constraints tell you immediately whether it can even resolve on your matrix.Before you invest an hour, spend ten minutes on spatie/nova-translatable. A maintained first-party equivalent is almost always the right answer; this repo is worth keeping as a reference implementation or a fork base.
One field per translatable column. Don't try to make a single field drive several attributes — mirror your $translatable array. Each Translatable::make('attr') owns exactly one JSON key set.
Let the locale list come from one place. Either always pass ->locales([...]) explicitly, or drive it from config so the field and spatie/laravel-translatable can never disagree:
->locales(config('translatable.locales'))
Divergence here is how you get a locale editors can't reach (or worse, one that gets dropped on save).
Validation is per-locale. ->rules('required|max:255') applies to each locale value, not the group. If only the app's fallback locale must be mandatory, express it with spatie's rule object or a closure rather than blanket required — otherwise editors are forced to fill every tab before they can save a partial draft.
use Spatie\Translatable\Rules\TranslatableRule;
Translatable::make('title')
->rules(new TranslatableRule('max:255'))
->requiredLocales(['en']); // only if the fork exposes something like this
Index and detail views. Translatable columns cast to arrays, so a naive index column renders JSON. Either hide it (->hideFromIndex()) or resolve the current locale explicitly:
Translatable::make('title')->hideFromIndex()
and use a plain Text::make('title', fn () => $this->getTranslation('title', app()->getLocale()))->onlyOnIndex() for the list.
Group with Nova's layout primitives. For records with several translated attributes, put the translatable fields in a Panel or Tab group so you have one locale switcher per section rather than N scattered ones. Combine ->onlyOnForms() with read-only detail entries if editors should never touch a locale.
Search and sort are your job. Nova's ->searchable() / ->sortable() assume a scalar column; a JSON column will either error or silently do nothing useful. Handle it with whereJsonContains / where('title->en', ...) in the resource's indexQuery, or accept that translated columns aren't filterable.
Prove the write path with a test, not by reading code. This is the one pattern worth mandating:
$page = Page::factory()->create(['title' => ['en' => 'Old', 'fr' => 'Ancien']]);
// update only 'en' through the field
$page->refresh();
$this->assertSame('Ancien', $page->getTranslation('title', 'fr'));
If a locale write clobbers its siblings, that test catches it in CI instead of an editor catching it in production.
Silent sibling clobbering is the real risk. Two implementations are possible in fillUsing: per-locale setTranslation() (safe, merges) versus assigning the whole array (destroys locales not rendered by the field). Read that method. Then keep the field's locale list a strict subset of what's in the DB, and keep the round-trip test above.
Nova 4/5 will not run this unmodified. Nova 4 moved the field pipeline to Vue 3 and the asset build from Mix to Vite. A 2021 Vue 2 FormField.vue and its prebuilt dist/js/field.js are the hard blockers — plan a component rewrite, not a version bump. Diff composer.json against your matrix before anything else.
Asset pipeline after a fork. If you rename the component (you should, to avoid colliding with a first-party field), you must rebuild dist/js/field.js and republish — Nova caches compiled assets, so a stale build shows old behaviour and costs an hour of confused debugging. php artisan nova:publish plus a hard browser refresh.
Locales must match what the app actually uses. A locale in ->locales() but absent from config('translatable.locales') (or vice versa) yields inconsistent fallback behaviour between the admin and the front end. Add a test asserting array_diff($fieldLocales, config('translatable.locales')) === [].
JSON column type matters. On MySQL < 5.7 or a TEXT column, whereJsonContains and cast behaviour degrade quietly. Verify the migration, not the model.
PHP 8.2/8.3 deprecation noise. 2021-era code (dynamic properties, untyped params) will emit deprecations. Harmless but noisy — suppress with a targeted patch in your fork rather than an error_reporting change.
Extension points, in order of usefulness:
->withMeta([...]) to carry extra locale metadata into the Vue component without touching the field class.->displayUsing(...) for index/detail formatting.fillUsing — the last resort, and the place to add an explicit merge if you discover clobbering.Own it on a clock. Log the fork with a named owner and a re-evaluation trigger: the next Nova major, the next PHP/Laravel major, or the addition of a third/fourth locale. With zero dependents there is no downstream canary — you are the canary.
How can I help you explore Laravel packages today?