webard/filament-translatable
Filament Translatable provides an intuitive tabbed interface for managing multilingual content in Filament admin panels. It supports spatie/laravel-translatable and other backends, letting you build translatable form fields across multiple locales with minimal setup.
1. Verify the package name before installing. The repository is happenv-com/filament-translatable, the PHP namespace is Happenv\FilamentTranslatable, and the README's composer command is composer require happenv-com/filament-translatable — while your package identifier is webard/filament-translatable. Confirm the actual Packagist name with composer search filament-translatable and pin an explicit constraint ("happenv-com/filament-translatable": "^4.0") so a rename can't silently break CI.
2. Match the version to your Filament major. The matrix is explicit:
| Filament | Package |
|---|---|
| 3.x | 2.x (old namespace) |
| 4.x | 4.x |
| 5.x | 4.x |
Upgrading Filament 3 → 4 means a namespace change too; budget a find-and-replace pass over every use statement.
3. Install and publish assets.
composer require happenv-com/filament-translatable
php artisan filament:assets
filament:assets is not optional — the tab/flag styling ships as a Filament asset. Skipping it is the single most common "it's installed but looks broken" report.
4. Register the plugin and set locales in your panel provider. This is the first place to look when anything is off; nearly all behaviour is configured here rather than in a config file.
use Happenv\FilamentTranslatable\FilamentTranslatablePlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugin(
FilamentTranslatablePlugin::make()
->locales(['en' => 'English', 'pl' => 'Polski'])
->defaultLocale('en')
);
}
If you omit defaultLocale(), it falls back to config('app.fallback_locale') — which is fine until someone changes APP_LOCALE in a deploy and the "required" locale quietly moves.
5. Prepare the model. For the default Spatie backend, follow Spatie's docs: the HasTranslations trait plus a $translatable array, with the columns cast to JSON in the migration.
class Product extends Model
{
use HasTranslations;
public array $translatable = ['name', 'description'];
}
6. First use case — the macro. One line turns any field multilingual:
TextInput::make('name')->translatable()
That renders a tab per locale with the field cloned inside each. If your admin only needs one or two translated fields, stop here; you likely never need the full component.
Translations componentUse the macro for isolated fields (name, slug, excerpt). Use the component when fields belong together and should share one tab strip:
use Happenv\FilamentTranslatable\Forms\Component\Translations;
Translations::make('translations')
->schema([
TextInput::make('name')->required(),
RichEditor::make('description'),
TextInput::make('meta_title'),
])
The component name is required (it's used to route per-tab actions), and each instance in a form must have a unique name — Translations::make('seo') and Translations::make('content') for a page with two translation zones. Two components both named translations will interleave state and produce duplicated-tab bugs that look like a Livewire problem but aren't.
After ->translatable(), the fluent chain is no longer a TextInput — it's the Translations component. That's the package's cleverest trick and its biggest footgun. Order matters:
TextInput::make('price')
->requiredDefaultLocale() // field methods FIRST
->decorateTranslationField('pl', fn (TextInput $f) => $f->suffix('PLN'))
->decorateTranslationField('en', fn (TextInput $f) => $f->prefix('USD'))
->translatable() // context switches here
->vertical()
->displayFlagsInLocaleLabels(true)
->flagWidth('48px')
->displayNamesInLocaleLabels(false);
Anything after translatable() that only exists on TextInput (e.g. ->maxLength(), ->required()) will throw a BadMethodCallException. Keep field configuration above the line, container configuration below it. This is also the cheapest way to get a "compact single-field" experience without hand-rolling tabs.
Instead of ->required() on every locale, scope it:
->requiredLocale('en') — required only for English.->requiredDefaultLocale() — required for whatever defaultLocale() resolves to.Combine with per-locale decoration to make the difference visible to editors, e.g. a suffix('*') on required locales. Note these are field methods, so they go before ->translatable().
Hard-coding locales breaks the moment marketing adds Italian. Drive them from a table:
FilamentTranslatablePlugin::make()
->locales(fn () => Language::query()->pluck('name', 'code')->all())
->defaultLocale(fn () => Language::query()->where('is_default', true)->value('code'))
The closure is re-evaluated per render, so cache it (Cache::rememberForever) or at minimum index the flags column — otherwise every form render adds a query, and a page with three Translations components does it three times.
Locale lists and backends are layered: plugin → component → field.
Translations::make('seo')->locales(['en', 'de'])
TextInput::make('name')->translatable()->translationMode(TranslationMode::Astrotomic)
Use the component-level override for a "partial translation" block (e.g. a legal notice that only exists in two languages) rather than maintaining a second panel.
Swap Astrotomic\Translatable\Translatable for Happenv\FilamentTranslatable\Traits\AstrotomicTranslatable on the model and set the mode:
FilamentTranslatablePlugin::make()->translationMode(TranslationMode::Astrotomic)
Forgetting either half produces writes that look successful in the UI but never land in the translations table.
Two independent knobs, often confused:
->prefixLocaleLabel() / ->suffixLocaleLabel() — annotates each field's label with the locale name, for horizontal or non-tabbed layouts where the tab itself isn't a strong enough cue.->preformLocaleLabelUsing(fn (string $locale, string $label) => "[{$label}]") — formats that annotation.->fieldTranslatableLabel(fn ($field, $locale) => __($field->getName(), locale: $locale)) — translates the label text itself per locale.The callback variants accept the $field when you inject it, so you can label some fields and not others — useful for marking only the editor-facing fields, not internal ones.
Since each tab has a locale context, per-tab actions (e.g. "copy from English", "auto-translate", "open in DeepL") attach to the Translations component and receive the current locale. This is the extension point to reach for when product asks for machine translation — you don't need a custom field type.
It is a form package. List tables, filters, and sorting are yours to handle: translated columns are JSON, so TextColumn::make('name') renders raw JSON or the fallback, not the current locale. Plan for either a JSON-path column (name->pl), a getTranslation() accessor, or a dedicated searchable index table if editors need to filter by translated content. Likewise, ->searchable() on a translated field needs a JSON whereJsonContains-style query — the package won't write it for you.
Method order is a hard rule. required() / requiredDefaultLocale() / decorateTranslationField() before translatable(); container options after. When you get an unexplained BadMethodCallException, this is the first thing to check.
Run php artisan filament:assets after every composer update, not just install. Upgrading from 3.x → 4.x changes asset hashes and cached bundles will render unstyled tabs until republished. In CI, assert the asset exists rather than trusting the deploy.
Unique component names, always. Two Translations::make('translations') in one form is silently broken. Adopt a convention now (name = the schema block, e.g. basic, seo, longform) and enforce it in review.
Don't mix translatable() inside a Translations schema. The README calls it out and it's easy to do reflexively: once a field lives inside the component, its translation is already handled. Adding the macro double-wraps it and the state paths stop matching the model.
Empty-string vs. null translations. Spatie stores whatever the form submits. An editor clearing an English field typically submits '', which Spatie will treat as present — so $model->translate('en') returns '' and your ?? $fallback never fires. If your front end depends on fallback behaviour, normalise on save (array_filter in a saving hook or a mutator) and test it.
JSON columns need the right driver. MySQL 5.7+/MariaDB 10.2+/Postgres are fine; SQLite stores JSON as text and works for the form but not for whereJsonLength-style queries. If you develop on SQLite and ship to MySQL, verify any translated-content filtering against MySQL before release.
Validation errors can hide in inactive tabs. Only one locale tab renders at a time. Filament generally surfaces errors on the tab strip, but if a requiredLocale('pl') failure looks like "the form just won't save", tell editors explicitly (and test) that the Polish tab carries the error. Consider enabling persistent tabs in the query string so validation round-trips return to the offending locale.
Flags are cosmetic and locale-code-sensitive. displayFlagsInLocaleLabels() maps the locale code to an icon. Region codes (pt_BR, zh_Hant) and non-country locales (eu, ca) may not resolve — check before enabling globally, and remember flags are off by default and flagWidth only matters once they're on.
Fallback locale ≠ default locale, but they can collide. defaultLocale() wins if set; otherwise app.fallback_locale. Keep them intentionally aligned (or intentionally not) and write it down — "required only for the default locale" is a business rule that will be re-litigated.
Debugging the state. When translations save to the wrong place, dd($form->getRawState()) in the form's mutateFormDataBeforeSave hook and compare against $model->getTranslations('name'). The mismatch tells you immediately whether it's a naming issue (duplicate component names), a backend issue (translationMode not propagated), or a trait issue (Spatie $translatable missing the field).
Test through the form, assert on the JSON. Use Livewire's fillForm and then assert the raw column:
livewire(EditProduct::class, ['record' => $product->id])
->fillForm(['name' => ['en' => 'Widget', 'pl' => 'Widżet']])
->call('save')
->assertHasNoFormErrors();
$this->assertSame('Widżet', $product->fresh()->getTranslation('name', 'pl'));
Because the package manipulates state paths, a green UI assertion plus a red DB assertion is your best signal that a backend misconfiguration is in play.
Pin the version and read the changelog. The ecosystem signals here are thin — low stars, no dependents — so treat upgrades as deliberate. The package publishes a changelog and tagged releases; skim them per bump rather than floating an open constraint, and check that the Filament 4/5 branch you're on is the one still receiving fixes.
Extension points to remember: decorateTranslationField($locale, $callback) for per-locale field tweaks, preformLocaleLabelUsing() for label formatting, fieldTranslatableLabel() for translated labels, per-component locales() for partial translation blocks, and per-tab actions for bulk operations like copy-from-default. Between those four you can cover most editor requests without forking — which, given the package's size, is where you want to stay.
How can I help you explore Laravel packages today?