happenv-com/filament-translatable
Filament Translatable provides a complete solution for managing multilingual content in Filament admin panels, adding intuitive tabbed translatable form fields with multiple translation backends, configurable locales, and support for Filament 3, 4, and 5.
composer require happenv-com/filament-translatable
php artisan filament:assets
Register the plugin in your panel provider and decide the locales up front — this is the one config decision everything else inherits:
use Happenv\FilamentTranslatable\FilamentTranslatablePlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugin(
FilamentTranslatablePlugin::make()
->locales(['en' => 'English', 'pl' => 'Polish', 'fr' => 'French'])
->defaultLocale('en')
->displayFlagsInLocaleLabels(true)
);
}
Before touching the UI, make the model translatable in the backend the package expects. With Spatie (the default) that means the HasTranslations trait plus a JSON column:
class Post extends Model
{
use Spatie\Translatable\HasTranslations;
public $translatable = ['title', 'body'];
}
// migration
$table->json('title')->nullable();
First use case — converting a single field into a tabbed multilingual input:
TextInput::make('title')
->requiredDefaultLocale()
->translatable()
That's the whole loop: model column, plugin locales, ->translatable(). Look at the generated Translations component (the macro swaps the field's context to it) as your first debugging surface if state looks wrong.
Macro for the 80% case. ->translatable() on a single field is the default. Remember the context switch: after ->translatable() you are talking to Translations, not TextInput, so all field-specific configuration must come first and all tab-layout configuration after:
TextInput::make('price')
->requiredDefaultLocale()
->decorateTranslationField('pl', fn (TextInput $f) => $f->suffix('PLN'))
->decorateTranslationField('en', fn (TextInput $f) => $f->prefix('USD'))
->translatable()
->vertical()
->displayFlagsInLocaleLabels(true)
->flagWidth('24px');
Translations component for grouped fields. When several fields share a locale context (title + slug + body), build one component around a schema instead of chaining macros. This keeps tab switching coherent and gives you one place to configure labels:
Translations::make('translations')
->schema([
TextInput::make('title'),
TextInput::make('slug'),
RichEditor::make('body'),
])
->prefixLocaleLabel()
->suffixLocaleLabel()
->fieldTranslatableLabel(fn ($field, $locale) => __("fields.{$field->getName()}", locale: $locale))
Per-component locale overrides. Editors rarely need every locale. Override locally rather than forking the plugin config:
Translations::make('translations')->locales(['en', 'es'])
Dynamic locale lists. A closure keeps locales in sync with a languages table, so adding a language is a DB row, not a deploy:
FilamentTranslatablePlugin::make()->locales(fn () => Language::pluck('code', 'name')->all())
Per-locale actions. Custom actions inside a tab receive the current locale — a good hook for "copy from English" or "auto-translate":
Translations::make('translations')
->schema([...])
->actions([ /* access $locale inside the action */ ]);
Mode switching is granular. translationMode(TranslationMode::Astrotomic) is settable on the plugin, a component, or a single field. Use it only when you have a mixed legacy codebase; otherwise set it once on the plugin.
->required() after ->translatable() targets the component, not the field. Call field methods before the macro. The README's caution is the single most common source of "my validation doesn't work" bugs.->translatable() inside a Translations schema. It's already translated; nesting produces duplicated state paths and confusing tab counts.Translations component needs a unique name. Two components both named translations will collide on action resolution and state keys. Name them by intent (translations_main, translations_seo).requiredLocale('en') and requiredDefaultLocale() are the correct tools. ->required() on the field will require every locale tab at once, which is rarely what you want.$record->title returns the app-locale value; for admin tables either use a locale switcher in the panel, or ->getStateUsing(fn ($record) => $record->getTranslation('title', 'pl')), or add a custom column that iterates locales.with('translations')), migrations, and any direct query filtering are entirely different from the Spatie path. Use the package's Happenv\FilamentTranslatable\Traits\AstrotomicTranslatable trait, not the vendor's.whereJsonContains('title->en', ...) or driver-specific JSON path queries on MySQL/Postgres. Full-text search across all locales means either whereRaw with JSON unnesting or a separate indexed search table.php artisan filament:assets after every update — flag icons and the component's JS are published assets, and stale assets are the usual cause of "tabs render but nothing switches."->getState() on the component (or dd($this->form->getRawState()) in a Livewire test) tells you exactly which field.locale keys are being hydrated. If a locale key is missing, it's a locales-config mismatch, not a rendering bug.preformLocaleLabelUsing(fn (string $locale, string $label) => "[{$label}]") for label formatting, decorateTranslationField() for per-locale field chrome, and the $field injection in prefixLocaleLabel() to apply labels selectively rather than to every field.requiredLocale() violation for the others — this catches trait/cast mistakes that are invisible in the browser until production data looks empty.How can I help you explore Laravel packages today?