Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Translatable Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.


Implementation Patterns

Macro mode vs. the Translations component

Use 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.

The macro's context switch

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.

Locale-aware validation

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().

Locale source from the database

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.

Per-component and per-field overrides

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.

Astrotomic users

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.

Readable labels and per-field labelling

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.

Custom actions per tab

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.

Where the package stops

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.


Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style