Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nova Translatable Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. config/nova-translatable.php — locales and default.
  2. src/Translatable.php — the wrapper field: resolve, fill, and how it mangles attribute names per locale.
  3. The bundled Vue component (tab strip over locale variants) — this is what you see in the form.
  4. README examples for the exact per-field override method names in your installed version (the API has shifted across Nova 4/5 releases).

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.

Implementation Patterns

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.

Gotchas and Tips

  • Name mangling is the source of most bugs. Anything that resolves by attribute name — 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.
  • Not every field type wraps cleanly. File/Image uploads, 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.
  • Config caching. After editing the 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 confusion. Two fallbacks exist — spatie's 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.
  • Empty-string vs null. Saving a form writes every locale key, often as "". 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.
  • Extension point: if you need locale-aware validation reused everywhere, subclass 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.
  • Nova 5 / Vue 3 compat. The package ships compiled assets; check the release date against your Nova major before assuming it works. 3.1.0 updates the build toolchain and adds Node 24 support — that's a contributor-side change, not a runtime one, but it means the shipped bundle was rebuilt, so re-publish assets (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.
  • Testing. Assert the JSON shape, not the field: $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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings