outl1ne/nova-translatable
Adds translatable fields to Laravel Nova. Wrap any Nova field with the Translatable class to edit and store values per locale, with locale switching in forms and support for spatie/laravel-translatable models. Simple setup for multilingual admin panels.
Install:
composer require outl1ne/nova-translatable
php artisan vendor:publish --tag=nova-translatable-config
This gives you config/nova-translatable.php, which is the first file to open. It holds the locales map (['en' => 'English', 'et' => 'Estonian']) and the default_locale used for the initial tab and for fallback display. Change these once and every Translatable field in the app follows.
Where to look first, in order:
config/nova-translatable.php — locales and default.src/Translatable.php — the wrapper field: resolve, fill, and how it mangles attribute names per locale.First use case — one JSON column, two locales:
// app/Nova/Page.php
use Outl1ne\NovaTranslatable\Translatable;
public function fields(NovaRequest $request)
{
return [
Translatable::make([
Text::make('Title'),
Textarea::make('Intro'),
]),
];
}
Model side, the column must be JSON (or text cast to array) and the model needs to resolve the base attribute from the per-locale inputs. The documented pairing is spatie/laravel-translatable:
class Page extends Model
{
use Spatie\Translatable\HasTranslations;
public $translatable = ['title', 'intro'];
}
Schema::table('pages', fn (Blueprint $t) => $t->json('title')->nullable());
If you're retrofitting, write the migration and a one-off backfill (DB::table('pages')->update(['title' => DB::raw("JSON_OBJECT('en', title)")])) before you ship the field, otherwise the first save silently overwrites the plain string with a single-locale JSON object.
One wrapper per logical group, not per field. Translatable::make([...]) produces a single tab strip containing all the wrapped fields. Wrapping Title and Intro together is better UX than two separate tab strips stacked down the form.
Compose with layout, not instead of it. The wrapper works inside Panel/Tab/Field containers; keep relationship fields, File, and Image outside it:
Panel::make('Content', [
Translatable::make([
Text::make('Title')->rules('required', 'max:120'),
Slug::make('Slug'),
Markdown::make('Body'),
]),
]),
Panel::make('Publishing', [
BelongsTo::make('Author', 'author', User::class),
File::make('Hero Image')->disk('public'),
]),
Repeatable blocks inside a translatable (3.1.0+). Since 3.1.0 you can nest Nova's native Repeatable inside the wrapper, so a per-locale list of blocks — feature bullets, FAQ entries, slide copy — lives in the same locale tab as the rest of the translated content:
Translatable::make([
Text::make('Title'),
Repeatable::make('Bullets', 'bullets', [
Text::make('Heading'),
Textarea::make('Body'),
])->repeatables(...),
]),
The outer JSON column then holds {"en": [ {...}, {...} ], "fr": [ ... ]} — a per-locale array, not an array of per-locale objects. Plan the column shape before you ship it; flipping between the two layouts later means a destructive backfill. If you're on < 3.1.0, Repeatable does not wrap reliably, so pin ^3.1 if you want this. Nested keys in requests become bullets_en.0.heading, so any validation you write for the block sub-fields must use that prefixed, indexed path.
Extract a Resource helper. Most apps repeat the same translatable set across Index/Detail/Form. Centralise:
private function translatable(string $name, string $label = null): Translatable
{
return Translatable::make([Text::make($name, $label)])
->hideFromIndex();
}
Validation is per-locale, keyed by the mangled name. Inside the wrapper, Title becomes title_en, title_fr, etc. in the request. So ->rules('required') on the inner field applies to every locale, which is rarely what you want. Gate it:
Text::make('Title')
->rules('required', 'max:120')
->rulesForLocale('en', ['required']) // check exact helper in your version
If no helper exists, the reliable pattern is a NovaRequest macro that rewrites rules against the suffixed keys, or a custom FormRequest that validates title.* directly:
'title.en' => ['required', 'string', 'max:120'],
'title.*' => ['nullable', 'string'],
Do not put Rule::unique('pages', 'title') inside the wrapper — it will check for a column named title_en and fail. For uniqueness, validate on the JSON path (whereJsonContains) or use a generated column.
Index columns. The wrapper is a form-oriented field. Show the default-locale value as a plain column instead of exposing the tab strip in the table:
Text::make('Title', fn () => $this->getTranslation('title', config('nova-translatable.default_locale')))
->onlyOnIndex(),
Translatable::make([Text::make('Title')])->onlyOnForms(),
Sorting and filtering. ->sortable() on the inner field won't help. Sort at the query level and, in MySQL 5.7+/Postgres, index a generated column or use JSON_EXTRACT/->>; on Postgres prefer a jsonb column with a GIN index. For Nova filters, whereJsonContains('title->en', $term) or a LIKE against the extracted path.
Programmatic writes. When seeding or importing, never assign the array blind — use $page->setTranslations('title', ['en' => ..., 'fr' => ...]) so spatie handles fallback and dirty tracking, and the Nova field picks it up on the next edit.
dependsOn, Rule::unique, Rule::exists, custom resolveUsing closures, Field::make('title') references elsewhere — sees title_en, not title. Log the request payload from the browser network tab when a form misbehaves; it shows exactly which keys the wrapper submitted. With nested Repeatable this compounds: the key is bullets_en.0.heading, and any dependsOn('bullets_fr') will not react to edits inside the rows.BelongsTo/BelongsToMany, KeyValue, and anything with custom fillUsing side effects are still risky — keep them out of the wrapper. Repeatable is now supported as of 3.1.0, but only the native Nova field; third-party repeatable-style packages that ship their own fill/resolve are not covered by that fix. If you need translatable relationship labels, store the translation on the related model instead.hideFromIndex() / onlyOnForms() belong on the wrapper, not the inner fields — propagating the inner field's visibility is inconsistent across versions. Verify in the Index view, not just the form.locales array run php artisan config:clear (and redeploy your cached config), otherwise the tab strip keeps showing the old set. Adding a locale does not backfill existing rows; spatie's fallback_locale covers reads, but the first save of the new locale writes only the keys the user touched.fallback_locale for reads and the package's default_locale for the initial tab. If a translated value appears on the frontend but the form looks empty, that's spatie fallback; if the wrong tab is selected, that's default_locale."". That defeats spatie's fallback (empty string is not missing). Normalise in a model observer or a mutation: array_filter($translations, fn ($v) => filled($v)). Watch this closely with Repeatable: an untouched locale may submit an empty array rather than a missing key, which reads back as "translated to nothing" instead of falling back.Translatable and override resolve/fill plus the rules handling, then alias it in a service provider. Cheaper than patching vendor code and survives composer update.php artisan nova:publish, clear public/vendor) after upgrading rather than assuming the old compiled JS still matches. If the tab strip renders unstyled or blank after a Nova upgrade, it's almost always stale published assets or a Nova asset cache.$this->assertDatabaseHas('pages', ['id' => 1]) plus $page->fresh()->getTranslations('title') === ['en' => '...', 'fr' => '...']. For Repeatable, assert the per-locale array structure explicitly (getTranslations('bullets')['en'][0]['heading']) — a wrong nesting shape will pass a naive assertNotNull and only break in the UI. Nova field tests are brittle; model-level assertions catch the real regressions.How can I help you explore Laravel packages today?