kongulov/nova-tab-translatable
A Laravel Nova package that groups translatable fields into locale tabs, making multilingual resource forms cleaner and easier to edit. It keeps each language’s inputs neatly separated for faster, less cluttered translation work.
Preconditions — check these before writing any code. This package is a presentation-layer wrapper, not a translation engine. It regroups fields you already declare into one tab per locale. That only works if:
composer.json constraint — this is the most likely integration blocker).spatie/laravel-translatable JSON columns ($translatable = ['title', 'body']) or a translations table.If either is false, stop. There is nothing here for you, and no amount of configuration will make it fit.
Minimal steps.
composer require kongulov/nova-tab-translatable
php artisan nova:resource-check # optional; nothing to migrate
Then wrap an existing field array inside one resource's fields() method:
use Kongulov\NovaTabTranslatable\NovaTabTranslatable; // verify class name in vendor/
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;
public function fields(NovaRequest $request)
{
return [
NovaTabTranslatable::make([
Text::make('Title')->rules('required|max:255'),
Textarea::make('Body')->rules('required'),
]),
// untouched, non-translatable fields stay outside the wrapper
Slug::make('Slug'),
Boolean::make('Published'),
];
}
Do this on one low-traffic resource first. It is a revert-one-file change; treat it as a spike, not a rollout.
Where to look first, in order.
vendor/kongulov/nova-tab-translatable/composer.json — declared Nova / Laravel / PHP ranges and whether spatie/laravel-translatable is a hard requirement or a suggestion.vendor/kongulov/nova-tab-translatable/src/ — it's a small package; read the field class end to end. You need to know exactly how it derives the locale list (config? model $translatable? an explicit ->locales() call?) because that determines every "wrong tabs" bug you'll hit later.vendor/kongulov/nova-tab-translatable/dist/ or resources/js/ — are Vue assets pre-compiled and shipped, or does adoption drag an npm run prod step into your deploy pipeline?config/app.php locale / fallback_locale and whatever locale list your app uses. There is now a third source of truth for locales in the system (config, model $translatable, package render list). Getting them out of sync is the #1 source of confusion.First use case to prove out: a CMS-style resource with 2–5 locales and several translatable fields (title, excerpt, body, SEO meta). That's where the tabbed form is a genuine usability change rather than cosmetic. Two locales and two fields? The stacked form is fine — the package isn't earning its maintenance tail there.
Acceptance test for the spike, not just "it renders":
Wrap the package behind your own field class. This is the single most valuable pattern in the whole assessment. Do not call NovaTabTranslatable::make([...]) from twenty resources. Define one thin wrapper with your standard field set:
namespace App\Nova\Fields;
use Kongulov\NovaTabTranslatable\NovaTabTranslatable;
class TranslatableGroup extends NovaTabTranslatable
{
public static function make($fields = [])
{
return parent::make($fields)
->locales(config('app.supported_locales', ['en'])); // if supported
}
}
Now, when Nova's next major breaks the package (it will — small package, Nova-internals coupling, one maintainer, 84 stars, zero dependents), the fix is one file. Removal costs a file revert, not archaeology.
Trait for resource-level reuse. Keep the field definitions themselves in the resource and the grouping in a trait, so field order stays readable:
trait HasTranslatableContent
{
protected function translatableContentFields(NovaRequest $request): array
{
return [
Text::make('Title')->rules('required|max:255')->sortable(),
Slug::make('Slug')->rules('required|alpha_dash'),
Textarea::make('Excerpt')->rules('required|max:500'),
Markdown::make('Body')->rules('required'),
];
}
}
Then $this->translatableContentFields($request) inside the tab wrapper. Non-translatable resource fields (status, publish date, relations) stay outside and visible regardless of tab — that's the point of the tabs.
Per-locale field variants when you need them. If a locale needs a different editor (e.g. a CJK-specific character counter, or a locale that shouldn't allow markdown), keep the divergence explicit rather than branching inside the shared array. Prefer two wrapped groups over conditional field soup — readability of fields() degrades fast.
Validation rules belong on the inner fields, not the wrapper. The wrapper is a layout primitive. Put ->rules([...]) on each Text/Textarea as you normally would. If your rules are locale-conditional (required for the default locale, nullable elsewhere), express that with a Rule closure that inspects the field attribute — the attribute is the base name (title), not title.en, so $attribute in the closure will be title. Don't write rules that assume a dotted attribute name.
Ordering and default tab. The order of the array you pass is normally the tab order. Put the app's default locale first — editors land on the tab they read. If the package exposes a defaultLocale() / ->locales([...]) chain, set it explicitly rather than relying on array order; the implicit behaviour is the kind of thing that changes silently in a patch release.
Index/detail views are unaffected — plan for that. The tabs only change the create/update form. Your fields() is shared with the index, so make sure the wrapped fields still make sense as index columns (they'll render the current-locale value). If a field should be in the form but not the index, that's a ->hideFromIndex() decision made inside the group, and it's easy to forget once the array is nested.
Rollout sequence. Inventory resources with translatable fields → pick one low-traffic resource → wrap behind your own field class → run the acceptance test above → dry-run a Nova upgrade in staging with the package installed → widen resource by resource. Each step is independently revertible; keep it that way.
Version pinning. Pin an exact version in composer.json ("kongulov/nova-tab-translatable": "2.2.7"), not a caret range. With one maintainer and zero dependents you are the QA. Note also that the recorded last-release date on this package (2026-10-20) is future-dated — confirm against actual repository tags before trusting any compatibility claim.
Validation errors hide in inactive tabs — the one gotcha that matters. If a required field in the French tab fails validation, the editor may be on the English tab, see nothing, and perceive a broken save (or worse, not notice and ship incomplete content). This is a data-quality failure, not a crash, so it doesn't show up in logs. Mitigations, in order of preference:
Nova errors-aware indicator: a small custom field or an Nova::script that counts errors keys against the locale list and marks tabs. Usually ~30 lines of Vue.ResourceStoreRequest so bad data can't persist regardless of UI visibility.Document the outcome. "Validation errors are/isn't surfaced in inactive tabs" is a fact your content owners need, not a ticket.
Locale drift is the source of "wrong tabs" reports. Three lists must agree: the app/config locale list, the model's $translatable array, and what the package renders. If an editor reports a missing or extra tab, check all three before touching the package. Cheap sanity check: dump array_keys($model->getTranslations('title')) and compare to the tab set.
Fallback behaviour is undefined until you test it. When a translation is empty, does the tab show blank, or the fallback locale's value? If it shows the fallback value and the editor saves without noticing, you can create a copy of the English text in the French column — silently polluting your data and any translation-memory export downstream. Verify, and if it does copy, decide whether that's acceptable before rollout.
Assets. If Vue assets aren't pre-compiled in dist/, you've imported an npm build step into your deploy. Check now. If they are pre-compiled but built against a different Nova major's asset conventions, you'll get a blank tab panel or a console error with no server-side trace — open the browser console first for any "tabs render but fields are empty" bug.
Debugging order for "tabs don't appear":
storage/logs).composer show kongulov/nova-tab-translatable and compare its Nova constraint to nova in composer.lock.php artisan nova:publish + php artisan view:clear — stale published assets are a classic.$translatable set and the attributes exist as JSON columns (a text column with per-locale rows won't work).Search and sort don't get better. The package changes the form, not the query layer. Sorting or filtering the index by a translatable column still hits whatever single value your accessor returns (usually current locale). If editors ask for "search across all locales," that's a separate feature — don't let it get attributed to this package.
dependsOn and conditional fields inside tabs. Nova field dependencies across tab boundaries are fragile in any tab implementation: a field in tab B that depends on a field in tab A may not re-evaluate until the tab is actually mounted. If you have cross-field reactivity, keep the dependent pair in the same locale group or lift the controlling field out of the wrapper entirely.
Extension points are limited — plan for the fork, not for configuration. With this dependency profile, assume you may need to absorb the ~200 lines into your own field class. That's 1–3 engineer-days and is a perfectly rational worst case. The decision to make now is only: pin the version, wrap it once, and keep the removal cost at one file. Everything else follows from that.
Don't sell it internally as localization infrastructure. It improves in-admin authoring. It does nothing for translator handoff, review cycles, TMS sync, or translation memory. If the measured bottleneck is the translation workflow, this package is the wrong spend and you'll be explaining that six weeks later. Metrics that actually justify it: time-to-publish a localized record, count of secondary-locale fields left empty at save, editor-reported friction.
How can I help you explore Laravel packages today?