alareqi/filament-translatable-fields
A Filament form component for editing translatable model attributes across locales. It adds locale tabs and translation-aware fields so you can manage multilingual content directly in Filament resources, with simple setup for Laravel apps using translatable model fields.
Install and wire up (5 minutes):
composer require aymanalareqi/filament-translatable-fields
php artisan vendor:publish --tag="filament-translatable-fields-config"
Register the plugin on the panel you actually use (not just the default one — see Gotchas):
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->plugin(TranslatableFieldsPlugin::make());
}
The model side must already be ready — this package is a UI layer over spatie/laravel-translatable, not a replacement:
use Spatie\Translatable\HasTranslations;
class Post extends Model
{
use HasTranslations;
public array $translatable = ['title', 'body'];
}
with a json column ($table->json('title')), not string.
Where to look first:
config/filament-translatable-fields.php — this is where the locale list lives. Your Filament panel locales and this list must agree, or you get empty tabs. Read this file before writing any form code.First use case (the 80% path): take an existing resource with a TextInput::make('title') backed by a JSON column and swap the field class:
TranslatableTextInput::make('title')
->required()
->maxLength(255),
That's the whole pitch: the field renders one input per configured locale, Filament's locale switcher drives which one the editor sees, and the value round-trips as {"en": "...", "ar": "..."}. Do this for one field, save, inspect the row in the DB, and you understand the package.
1:1 field swapping. The realistic adoption path is mechanical substitution across resources — TextInput, Textarea, and rich-text variants have translatable counterparts. Keep a checklist and convert resource-by-resource; mixed translatable/plain fields in one form are fine.
Locale tabs over field tabs. The alternative to per-field locale inputs is grouping all translatable fields of one locale into a Tabs component. Per-field is better for short strings (title, slug) where the editor wants to see all languages at once; tab-per-locale wins for long content (body, meta_description). Decide per resource, not globally.
Table columns. Rendering a JSON column directly prints {"en":...}. Always resolve to a string in the column:
TextColumn::make('title')
->getStateUsing(fn (Post $record) => $record->getTranslation('title', app()->getLocale()))
->searchable(query: fn ($query, $search) => $query->where('title->'.app()->getLocale(), 'like', "%{$search}%")),
Validation that actually covers every locale. ->required() on the wrapper validates the field, which after hydration is an array — it will pass if any one locale is filled. For "must exist in the primary locale", use a closure or ->rule() per locale, and use Rule::unique against the JSON path rather than the column.
Repeaters, Builder, and relationship forms. Translatable fields nested inside a Repeater need unique state paths per item; verify with ->live() + dd($state) in a scratch page before committing. For ->relationship() sub-forms, the related model also needs HasTranslations and $translatable — a common miss that silently stores a plain string.
Search and sort. Sorting a translatable column sorts the raw JSON — meaningless. Sort on the resolved locale or skip sorting. If you need it, add a generated/derived column in the migration and sort on that.
Factories and seeders. Write ['en' => 'Title', 'ar' => 'عنوان'] arrays directly; don't pass strings and expect the package to fill them. Assert on $model->getTranslations('title') in tests, not $model->title (which returns the current app locale).
Integration with Filament's locale switcher. The package keys off Filament's current locale. If editors should switch languages via the topbar, enable Filament's locale switcher and make sure app()->getLocale() is driven from it — otherwise the field shows the app default and editors think their translation vanished.
json column, not string. On MySQL/SQLite a string column will silently store the JSON-encoded string and getTranslation() will return the raw blob. If translations look like {"en":"x"} in the UI, check the migration first — this is the #1 reported cause.
Locale list drift. Filament panel locales, config('app.locale'), the package config, and the model's $translatable are four separate sources of truth. Add a test that asserts the package config locales are a subset of your panel locales; it catches the "new language added, tab missing" bug class.
Config caching. php artisan config:cache after touching locales, and remember vendor:publish --force on upgrade will overwrite your config file — diff before forcing.
Fallback locale makes empty look filled. getTranslation() returns the fallback when the target locale is empty. In table columns use getTranslationWithoutFallback() (or check getTranslations()) if you want to distinguish "untranslated" from "translated", which matters for any "needs translation" badge or filter.
Unique validation is a trap. ->unique() on the field compares against the whole JSON column. Use Rule::unique('posts', 'title->en') per locale, and be aware SQLite's JSON path support differs from MySQL's — test on your production driver.
Assets/build after version bumps. Filament plugins that ship views or JS need php artisan filament:assets (and a hard browser refresh) after composer update. A "field renders but styling is broken" report is almost always stale assets, not a code bug.
Filament major-version constraint. Before upgrading Filament (v3 → v4), check this package's composer.json constraint for the Filament version — plugin compatibility lags core releases by weeks. Pin Filament during the transition and read the package's issue tracker rather than assuming.
Debugging recipe. When a save "loses" a translation, walk the chain in order: $form->getState() in a ->beforeSave() hook → $model->getRawOriginal('title') right after save() → the DB row. Whichever step diverges tells you whether it's a state-path problem, a $translatable omission, or a column-type problem.
Extension points. For field types the package doesn't wrap, compose the translatable wrapper around a native Filament field instead of forking — and if you need a bespoke locale resolver (e.g., per-tenant languages), that belongs in the package config or a small service binding, not in every resource.
Version note. v2.0.15 is a patch release on the 2.x line (jumping 2.0.13 → 2.0.15); semver says no breaking changes, so an in-place composer update is safe. The published changelog is only a compare link with no per-release notes — treat any 2.1 / 3.0 bump as requiring a read of the diff, since this package's release hygiene won't hand you a migration guide.
Testing tip. Build one feature test per translatable field asserting that saving ['en' => 'a', 'ar' => 'b'] persists both and that the table renders the active locale. It's ~10 lines and it catches every regression the patch releases in this range could plausibly introduce.
How can I help you explore Laravel packages today?