jegex/filament-translatable
Filament Translatable adds seamless multi-language field editing to Filament forms, letting you manage translations across locales without leaving the admin panel. A lightweight helper for Laravel apps that need clean, simple, per-locale content editing.
jegex/filament-translatable is a thin Filament layer over spatie/laravel-translatable. Instead of hand-rolling locale tabs and title[en] state paths in every resource, you wrap a set of fields once and get per-locale inputs that dehydrate into the JSON shape Spatie expects.
Prerequisites (check these before anything else):
spatie/laravel-translatable installed, model using HasTranslations with a populated $translatable array.json (or text) — not string. A varchar column is the single most common reason a first attempt "silently doesn't save".Install:
composer require jegex/filament-translatable
php artisan vendor:publish --tag="filament-translatable-config" # if offered
If it ships a Filament plugin class, register it in your PanelProvider (->plugin(...)); otherwise it's a form component you import directly.
First use case — get one field working end to end: take a Post model with title and body translatable into en/fr/es. Wrap those two fields in the package's translatable container in PostResource::form(), load the edit page for an existing record, change the fr title only, save. Then verify, not just eyeball the UI:
$post->refresh()->getTranslations('title'); // ['en' => '...', 'fr' => '...', 'es' => '...']
If that array is right, the wiring is correct and everything else is presentation.
Where to look first: the README's form example, the package config file (locale list + fallback), and src/ for the container class and service provider. The source is small — reading the container's dehydrate/hydrate methods answers most state-path questions faster than the docs.
On v1.0.6: this is a patch bump from v1.0.5 with no published notes beyond the compare link. There are no announced breaking changes, deprecations, or features. Nothing to migrate; treat it as a routine pin.
One wrapper per translatable attribute group. Keep inner field names identical to the model's $translatable entries — any renaming and the dehydration mapping breaks quietly.
Decide the editing model per resource, up front. Two patterns, and mixing them is where teams get confused:
Pick one convention per resource and document it. A resource that half-does both produces "I edited it but the other language changed" bug reports.
Table columns need an explicit locale. Translated attributes are arrays; a plain TextColumn::make('title') will either error or render nothing useful:
TextColumn::make('title')
->getStateUsing(fn ($record) => $record->getTranslation('title', app()->getLocale())),
Validation belongs inside the wrapper. Rules applied to inner fields fire per locale. The usual business rule is "fallback locale is required, others optional" — express that with a conditional ->required(...) rather than marking every locale required, which translators will hate.
Extract the schema. Put the translatable field group in a static function translatableFields(): array on the resource (or a trait) so create, edit, and relation managers can't drift apart.
Always pass locale explicitly in actions, exports, API resources, and Blade partials: $post->getTranslation('title', $locale), never the ambient $post->title.
Testing: the wrapper nests state under the locale key, so Livewire tests fill ['data.title.en' => 'Hello', 'data.title.fr' => 'Bonjour'] and then assert on the DB JSON or getTranslations(). Add one test that saves a partial form (only one locale filled) — that's the path that regresses.
SelectColumns, and anywhere Filament assumes a scalar. Symptom: htmlspecialchars(): Argument #1 must be of type string, array given. Fix with getStateUsing.->searchable(query: fn ($q, $search) => $q->where('title->en', 'like', "%{$search}%")), and if you sort on translations at scale, add generated columns with indexes — JSON extraction sorts are table scans.unique validation is misleading. Rows are shared across locales, so a standard unique:posts,title checks the whole JSON blob, not "unique within fr". You need a custom rule. This is the most common post-launch data-quality bug with any Spatie-translatable UI.$post->title returns the fallback when the requested locale is blank, so a field that looks populated may have no fr value at all. Read getTranslations() whenever the distinction matters (completeness reports, publishing gates).{"en": "old value"}, not "old value". A plain string in a JSON column will blow up hydration on edit.Repeater, Builder) inside the translatable container produce deep state paths. Test hydration on an existing record before shipping; create-only testing hides the bug.app.supported_locales in sync, and make sure the fallback locale matches Spatie's fallback_locale. Drift here produces "why is this English" reports that look like package bugs but are config bugs.$livewire->form->getState() (test or Tinker) before blaming the package — it shows you exactly what shape was dehydrated and whether the problem is the wrapper or your model.^1.0 constraint plus a subclass is how you get a surprise. Pin, run your translatable-field test, then bump.How can I help you explore Laravel packages today?