optimistdigital/nova-translatable
Nova Translatable brings multilingual editing to Laravel Nova, wrapping text, textarea, and other fields in locale tabs so you can manage translations for spatie/laravel-translatable models directly inside the Nova admin panel.
Confirm three prerequisites before composer require. This is a Nova field, so it's inert without a licensed Nova installation. It also doesn't create storage or behavior — that comes from spatie/laravel-translatable, which must already be installed on the model, with translated attributes on JSON columns. Finally, match the package tag to your Nova major; community fields lag Nova releases, and 3.1.0's Repeatable support presumes a Nova that actually ships that field.
composer require outl1ne/nova-translatable
php artisan vendor:publish --tag=nova-translatable
Older docs and most tutorials reference optimistdigital/nova-translatable. It's the same package under its former vendor name — use outl1ne for new work, but don't churn a working composer.json just to rename the dependency.
Set locales once, centrally. The published config is the single source of truth for the default locale list. Per-resource overrides exist, but start here:
// config/nova-translatable.php
return [
'locales' => [
'en' => 'English',
'fr' => 'Français',
'de' => 'Deutsch',
],
];
Model and migration are plain Spatie — no package magic. If these aren't already correct, the field will appear to "not work" with no error, and this is the single most common support case:
class Page extends Model
{
use HasTranslations;
public $translatable = ['title', 'summary', 'body'];
}
$table->json('title')->nullable();
$table->json('summary')->nullable();
$table->json('body')->nullable();
The resource change is one wrapper. Non-translatable fields stay outside it and render once:
use Outl1ne\NovaTranslatable\Translatable;
public function fields(Request $request)
{
return [
ID::make()->sortable(),
Translatable::make([
Text::make('Title')->rules('required', 'max:255'),
Textarea::make('Summary')->rules('max:500'),
Trix::make('Body'),
]),
Boolean::make('Published'),
];
}
Your first use case: one resource, two fields, two or three locales. Prove the tab switcher, per-locale validation, and the save/reload round-trip before touching Repeatable, Image fields, or reactive configuration. Twenty minutes of smoke testing catches wrong column types, missing $translatable entries, and cached config in one pass.
When something misbehaves, check in this exact order:
json? A varchar yields a silently empty or broken form.$translatable?config/nova-translatable.php or the field's ->locales() override?name map to the same column as the JSON attribute?php artisan config:clear — the locale list is cached configuration.Translatable::make([...]) produces a single tab strip across the fields inside it. Wrapping every field on the resource gives editors a wall of inputs per tab. Group by editorial concept instead:
Translatable::make([
Text::make('Title')->rules('required'),
Slug::make('Slug'),
Trix::make('Body'),
]),
Translatable::make([
Text::make('Meta Title'),
Textarea::make('Meta Description'),
]),
Two short strips beat one enormous one — a copywriter can work on body copy without scrolling past SEO metadata three times.
Global config is almost never right everywhere: site settings may need two languages, a product catalog five. Override on the field:
Translatable::make([
Text::make('Name'),
])->locales([
'en' => 'English',
'nl' => 'Nederlands',
])
This is also the escape hatch when a specific resource shouldn't expose every configured locale to a given team.
->rules() on the inner field applies per locale. That's usually correct for max: and frequently wrong for required on secondary locales:
Translatable::make([
Text::make('Title')
->rules('required', 'max:255')
->messages(['required' => 'Title is required in every locale.']),
])
If the business rule is "required in the default locale, optional elsewhere," that's outside what the field expresses. Write a custom rule in NovaRequest (or a FormRequest) that inspects the JSON payload by locale key. Don't try to shoehorn it with sometimes on the inner field — it won't behave consistently across tabs.
Panel::make('Content', [
Translatable::make([
Text::make('Title'),
Trix::make('Body'),
]),
Boolean::make('Featured'),
BelongsTo::make('Author', 'author', User::class),
])
Fields inside the Panel but outside the wrapper render once, beneath the tabs. That's the right layout for metadata that doesn't vary by language, and it keeps the form visually coherent.
The headline of the release: per-locale copy inside each repeated block.
Repeatable::make('Sections', 'sections', [
Text::make('Heading'),
])->repeatables([
Translatable::make([
Text::make('Heading'),
Trix::make('Copy'),
]),
])
Treat nested Repeatable + Translatable as beta-grade until you've personally exercised: adding a row and filling one locale, reordering after partial translation, deleting a half-translated row, and a validation failure landing on the correct tab. If any of that behaves oddly, fall back to flat translatable columns for that resource and revisit after the next release.
Inner fields still govern index/detail visibility, but Nova renders whichever locale representation the JSON resolves to — which is ambiguous to QA. Resolve it explicitly:
Translatable::make([
Text::make('Title')->onlyOnForms(),
]),
Text::make('Title (EN)', fn () => $this->getTranslation('title', 'en'))
->onlyOnIndex(),
Five extra lines removes "which language am I looking at?" from every QA cycle. Worth it every time.
The field always renders all configured locales in one form. If your workflow is genuinely "the French copywriter works Tuesdays," swap ->locales([...]) dynamically inside fields() based on the current user or a ?locale= request parameter. Cheap, effective for small ops teams, and much less work than a resource-level locale filter.
When you need trimming, HTML sanitizing, or a per-locale default, use fillUsing/resolveUsing on the inner field rather than post-processing on the model. The behavior stays attached to the UI that needs it and survives the field being moved between resources.
required fires on every locale. The classic first-day complaint: ->rules('required') blocks save until every configured locale has content, and editors respond by typing "TODO" into four languages. Decide deliberately — hard gate everywhere, or required nowhere plus a separate publish-readiness check.
Empty string vs. null breaks fallbacks. Spatie's fallback logic distinguishes an absent key, null, and ''. Nova forms submit '' for untouched inputs. When getTranslation($attr, $locale, true) refuses to fall back, dump the raw JSON column first — nine times out of ten an empty-string key is masking it.
File and Image fields are a product decision, not a code decision. Wrapping an Image gives you one upload control and one file on disk per locale, which is rarely what a designer meant by "one hero image." Either keep media outside the wrapper (single asset, localized alt/caption only) or consciously adopt the per-locale asset model and document it in the spec.
sortable() and filterable() don't work on the JSON column. You're sorting JSON strings. If admins need to sort product names, materialize a title_en generated column or an accessor-backed sortable field instead of fighting the storage format.
Reactive fields across tabs are fragile. ->dependsOn() and ->readonly(fn () => ...) chains that cross the tab boundary often resolve only against the currently-mounted locale's inputs. Keep dependents inside the same Translatable group; if a dependency genuinely spans locales, verify it in the browser — a green test suite won't cover tab mount/unmount behavior.
Slugs don't auto-generate per locale. Slug::make('Slug') inside the wrapper gives you a slug field per locale, not per-locale generation from the translated title. Wire that yourself — a model observer, or a NovaRequest hook that fills the slug from whichever locale's title is present on save.
Config is cached. After editing config/nova-translatable.php, run php artisan config:clear. In CI/staging this bites at deploy time when config:cache runs after your locale changes were merged.
Nova upgrades are the real risk surface. A Nova major bump is the likeliest thing to break this package, and 3.1.0 widened the blast radius by depending on Nova's Repeatable internals. Mitigate by pinning the exact version in composer.json and keeping one smoke-test resource in your manual QA script — a flat translatable field plus a Repeatable-backed one — exercised on every Nova upgrade.
Don't build a test strategy around rendering the form. CI and every developer need a Nova license, and testing against Nova internals is awkward. Test the model layer instead — Spatie's translation behavior, fallback resolution, your NovaRequest rules — with plain PHPUnit, and cover the field itself with a short manual checklist. That split gives you a real safety net without pretending Nova is testable.
Keep the blast radius small, in case you need to fork. MIT license, small codebase — forking is viable. Confine usage to fields() in resources: no service-provider hacks, no overriding internals. Then, if upstream stalls, replacement is a mechanical edit across a handful of files rather than an archaeology project.
How can I help you explore Laravel packages today?