wotz/filament-translatable-tabs
Filament Translatable Tabs adds locale tabs to Filament forms, letting you edit translatable fields for each language in a clean tabbed interface. Simplify multilingual content management in Laravel admin panels.
Confirm the dependency stack first. The package sits on top of spatie/laravel-translatable and Filament's form Tabs component. Before installing, verify:
spatie/laravel-translatable is installed and your model uses HasTranslations.public array $translatable = ['title', 'body'].Install and publish.
composer require wotz/filament-translatable-tabs
php artisan vendor:publish --tag="filament-translatable-tabs-config"
Read the published config before touching anything else — with a small package like this, the config file and the service provider are the real documentation.
Wire up the styling. v3.1.0 renamed/migrated plugin.css to Tailwind v4. Add the package's view path to your panel theme so the locale-tab CSS is actually compiled into your bundle:
/* resources/css/filament/admin/theme.css */
@import '/../../../../vendor/wotz/filament-translatable-tabs/resources/css/plugin.css';
Then register the theme on the panel with ->viteTheme('resources/css/filament/admin/theme.css'). If you skip this on Filament v4 you may get unstyled tab rails/badges; on Filament v3 the v4-compiled CSS may not apply at all.
Register the plugin (if the package ships one). In your PanelProvider:
->plugin(TranslatableTabsPlugin::make())
If there's no plugin class, skip this — the package is likely just a set of form/table components or a trait.
First use case. Take a Post resource with translatable title and body. In PostResource::form():
use Wotz\FilamentTranslatableTabs\Forms\Components\TranslatableTabs;
public static function form(Form $form): Form
{
return $form->schema([
TranslatableTabs::make()
->schema(fn (string $locale) => [
TextInput::make("title.{$locale}")
->label('Title')
->required($locale === config('app.fallback_locale')),
RichEditor::make("body.{$locale}")
->label('Body'),
]),
]);
}
The exact factory signature is the thing to check first in src/ — the callback-per-locale pattern is the common shape, but confirm whether it's ->schema(), ->tabs(), or a ->locales() chain.
Where to look first, in order: README.md → config/filament-translatable-tabs.php → the main form component class → resources/views/ (tab labels and locale badges live here) → resources/css/plugin.css → tests/. With one star and zero dependents, the test suite (if present) is the most reliable spec you'll get.
Per-locale field builder. The dominant pattern is a single closure that receives a locale and returns a schema array. Keep the closure pure and derive labels from the locale code rather than hardcoding:
TranslatableTabs::make()->schema(fn (string $locale) => [
TextInput::make("title.{$locale}")
->label(__('fields.title').' ('.strtoupper($locale).')'),
])
Lean on the built-in locale badges. Since v3.1.0 the locale badges are named, meaning tab headers render a readable locale label rather than a raw code. Don't reimplement a tab label yourself before checking whether ->locales() / the badge label helper already gives you what you want — and if you do override the Blade view, expect to re-apply v3.1.0's badge markup.
Extract to a shared method. If you have more than two translatable resources, put the tab block in a trait or static helper so locale handling stays consistent:
trait HasTranslatableContentTabs
{
protected static function contentTabs(): array
{
return [TranslatableTabs::make()->schema(/* ... */)];
}
}
Mix with non-translatable tabs. Wrap the package output inside a standard Filament Tabs when you need a "Settings" or "SEO" tab alongside the locale tabs:
Tabs::make()->tabs([
Tab::make('Content')->schema([TranslatableTabs::make()->schema(...)]),
Tab::make('Settings')->schema([Toggle::make('published')]),
])
Table columns. Reuse the same locale list for display. Most implementations read app()->getLocale(); for an admin table you usually want the fallback locale explicitly, or a ->formatStateUsing() that shows the fallback when the current locale is empty:
TextColumn::make('title')
->formatStateUsing(fn ($state, $record) => $record->getTranslation('title', app()->getLocale())
?: $record->getTranslation('title', config('app.fallback_locale')))
Locale sources. Decide once whether locales come from config('app.locales'), the package config, or a DB table — then pass them explicitly (->locales([...])) rather than relying on the package default. Explicit wins in multi-panel apps.
Integration with uploads. For translatable images, keep the file column non-translatable and store per-locale variants in a separate relation or JSON column; translatable file uploads inside tabs tend to fight with Filament's FileUpload state path and cleanup.
viteTheme do not auto-load package CSS. Import plugin.css into your theme entrypoint (see Getting Started) and keep it imported across upgrades — a Filament theme rebuild after upgrading to v3.1.0 is the common "the badges disappeared" fix.title.{locale} so Spatie's JSON column hydration works. If a field renders but saves nothing, the state path is almost always the culprit — check ->getStatePath() in a dd() inside the schema closure.->required() on the fallback locale only, otherwise editors cannot save partial translations. Same for ->maxLength() if you want per-locale limits.->visible() conditions that were meant to prevent saving.->live() / ->afterStateUpdated() dependencies between a field in the en tab and one in the fr tab won't re-render, because only the active tab is mounted. Keep cross-field reactivity within a single locale.title sorts the raw JSON string. Use a json_extract-based ->sortable(query: ...) or a generated/virtual column, otherwise ordering is meaningless.composer.json may be loose (e.g. ^3.0) while the code — now including its CSS — targets a newer major. Pin Filament and this package ("wotz/filament-translatable-tabs": "3.1.0") in your own composer.json, and run the form in isolation before rolling out to every resource.php artisan filament:clear-cached-components, php artisan view:clear and php artisan optimize:clear. Form component and view caching often masks config and CSS changes.Tabs with Tab::make($locale)->schema([...]) written by hand. If that works and the package doesn't, the issue is the locale/state-path wiring, not your fields. If it renders but looks wrong, the issue is CSS, not PHP.src/: a ->locales() setter, a ->fallbackLocale() setter, the Blade view (if the tabs render via a custom view, override it with ->view('vendor/...') — but note the badge markup changed in v3.1.0, so re-check your override against the new view), and any configureUsing() static. These are the seams you'll need if the default tab labels or ordering don't match your panel.packages/ with a path repository rather than depending on Packagist for anything production-critical, and add a smoke test that renders the form in each configured locale.How can I help you explore Laravel packages today?