outerweb/filament-translatable-fields
A Filament plugin that adds translatable form fields, letting you edit translations for each locale in tabs within your resources. Works with spatie/laravel-translatable to keep multilingual content organized and easy to manage in the admin panel.
Install and wire up the three moving parts.
composer require outerweb/filament-translatable-fields
php artisan vendor:publish --tag="filament-translatable-fields-config"
The config is the only place that defines which locales get tabs:
// config/filament-translatable-fields.php
return [
'locales' => [
'en' => 'English',
'nl' => 'Nederlands',
'de' => 'Deutsch',
],
];
The package assumes the underlying model already uses spatie/laravel-translatable — it does not set that up for you:
use Spatie\Translatable\HasTranslations;
class Post extends Model
{
use HasTranslations;
public array $translatable = ['title', 'body'];
}
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->json('title');
$table->json('body')->nullable();
$table->timestamps();
});
First use case — swap a plain TextInput for the wrapper in your Resource form:
use Outerweb\FilamentTranslatableFields\Filament\Fields\Translatable;
Translatable::make('title')
->fields([
TextInput::make('title')
->required()
->maxLength(255),
])
->columnSpanFull(),
The component renders a Filament Tabs with one tab per configured locale, and each inner field is registered under title.en, title.nl, etc. Filament's dot-notation state handling nests those into ['en' => ..., 'nl' => ...], which is exactly the shape Spatie's mutator expects on save. That convention is the entire package.
Where to look first: the README example, then src/Filament/Fields/Translatable.php (the package is essentially one small class plus a config and service provider — read it in five minutes), then config/filament-translatable-fields.php. Confirm whether your installed version uses ->fields([...]) or ->schema([...]) for the inner components; small packages like this occasionally rename methods between minor releases, and the README on main may not match the tag you installed.
One wrapper per translatable column, not per locale. Group related columns into Sections and let each Translatable own its own tab strip:
Section::make('Content')
->schema([
Translatable::make('title')
->fields([TextInput::make('title')->required()->maxLength(255)]),
Translatable::make('body')
->fields([RichEditor::make('body')->required()]),
])
->columnSpanFull(),
Pull it into a helper on the Resource so 10 resources don't drift:
protected static function translatableText(string $name, string $label): Translatable
{
return Translatable::make($name)
->label($label)
->fields([TextInput::make($name)->required()->maxLength(255)]);
}
Mixed field types work as-is. Textarea, RichEditor, MarkdownEditor, Toggle, Select, FileUpload all go inside ->fields([...]) — they are ordinary Filament components; the wrapper only renames their state path. This is why per-locale FileUpload with a locale-specific ->directory("posts/{$locale}") is the usual pattern: build the inner fields in a loop over your locale config instead of hand-writing three near-identical components.
Validation belongs on the inner field. ->required() on TextInput::make('title') ends up validating data.title.en, data.title.nl, etc. — i.e. required in every locale. If you only need the default locale required and the rest optional, apply ->required() conditionally per locale field, or add a closure rule on the wrapper that asserts at least one locale is non-empty.
Tables are a different code path — don't force the wrapper there. TextColumn::make('title') already works because Spatie's accessor resolves the current app locale, and ->searchable() goes through Spatie's where('title->en', 'like', ...) query scope. If you want a per-locale table column, use TextColumn::make('title.en') only if you've verified your Filament version hydrates it from attributesToArray() — otherwise fall back to ->formatStateUsing(fn ($state) => $state[app()->getLocale()] ?? '') on the plain column.
Slugs per locale. Pair each Translatable title with a per-locale slug field built the same way, and generate slugs in ->afterStateUpdated() on the inner TextInput using Str::slug($state). Don't try to auto-generate across locales from a single field — the tabs make that confusing and editors will fight it.
Repeaters and Builders generally work if you keep the wrapper inside the item schema and use a normal relative name (title), because Filament resolves the repeater's state path before the locale suffix is appended. Test one save round-trip before committing to it in a large form.
column.locale naming is load-bearing. If your column isn't in $translatable, or you renamed the wrapper to something that doesn't match a real JSON column, saving silently writes title.en as a top-level attribute instead of nesting — the form looks fine and the data is wrong. Debug with dd($model->getTranslations('title')) after a save, not $model->title.->required() on every locale is usually the wrong default. For long-form fields (body, meta description), forcing all locales before publish blocks authors. Require the default and treat the rest as optional, with a badge or hint on the default tab.->unique(ignoreRecord: true) on title.en will try WHERE title.en = ? and fail or return nonsense. Use a Rule::unique closure against whereJsonContains('title->en', $value) (or a generated column) instead.null — if an editor saves without touching it, you may persist "" and shadow the fallback. Filter empty strings out of the dehydrated state if that matters, or make each tab visibly empty.Tabs and TextInput::make('title.nl') fields. Recognizing this early saves a rewrite.RichEditors — lazy-load or scope which columns are translatable.Tabs, state path resolution, dehydrateState). A composer.json constraint like "outerweb/filament-translatable-fields": "^1.0" plus a smoke test in CI (create record, save two locales, assert getTranslations()) is cheap insurance.Translatable and override how ->fields() builds the inner components — don't fight the config file, and don't expect hooks that aren't there.How can I help you explore Laravel packages today?