doriiaan/filament-astrotomic
Filament Astrotomic integrates Astrotomic Translatable models into Filament, giving you locale-aware form fields, tabs and table columns so you can manage multilingual content in one place without custom glue code.
Prerequisites. This plugin only makes sense on top of astrotomic/laravel-translatable. Confirm three things before you install: your Filament major version, your Laravel version, and the Astrotomic version — all three appear in the plugin's composer.json. With 8 stars and 0 dependents the constraint range is likely narrow, so check rather than assume.
composer require astrotomic/laravel-translatable
composer require doriiaan/filament-astrotomic
The Astrotomic side is not handled by this plugin: publish its config, create the companion *_translations tables (translated columns live there, not as a JSON column on the parent table), and set public array $translatedAttributes = ['title', 'slug']; on the model.
Where to look first. The package is small — read vendor/doriiaan/filament-astrotomic/src end to end before you use it. There is almost certainly a Concerns/Traits directory containing the form and table helpers; that source is the real documentation. Then skim the README example resource, which is likely a single PostResource with a locale-tabbed form.
First use case. One resource, two locales, a tabbed form and a table column showing the current locale's value with fallback:
Astrotomic\Translatable\Translatable.Get that vertical slice working before touching slugs, search, or a third locale — the translation-storage model is the part that trips people up, and you want to see post_translations rows appear correctly on save first.
Per-locale tabs with a shared field builder. The dominant pattern: build the translated field set once in a closure and invoke it for each locale inside Tabs, so adding a field means editing one place.
Tabs::make('Translations')->tabs(
collect(config('app.locales'))->map(fn ($locale) => Tab::make(strtoupper($locale))
->schema($this->translatedFields($locale)))->all()
);
Keep locale list in exactly one source of truth (config('app.locales')) and feed both the form tabs and any locale switcher from it — the classic failure mode is the tab list and the $translatedAttributes list drifting apart.
Table columns must go through the relation. TextColumn::make('title') will not read the translation. Use the plugin's column helper if it provides one; otherwise:
TextColumn::make('title')
->getStateUsing(fn ($record) => $record->translate(app()->getLocale())?->title
?? $record->translate(config('app.fallback_locale'))?->title)
Eager load, always. Override getEloquentQuery() with ->with('translations'). Without it, a 50-row table issues one query per row per column; this is the single biggest performance trap with table-per-translation models.
Search and sort across translations. Translated attributes aren't on the parent table, so ->searchable() and ->sortable() silently do nothing useful. Use the query callback:
->searchable(query: fn ($query, $search) => $query->whereHas('translations',
fn ($q) => $q->where('title', 'like', "%{$search}%")->where('locale', app()->getLocale())))
Sorting requires an explicit join or a withAggregate on the translations relation.
Unique slugs per locale. Astrotomic won't generate or scope slugs for you. Either mutate in mutateFormDataBeforeCreate()/mutateFormDataBeforeSave(), or validate against the translations table:
Rule::unique('post_translations', 'slug')
->where('locale', $locale)
->ignore($record?->translate($locale)?->id)
Fallback: hand-rolled relation manager. If the trait doesn't cover your case (custom layouts, permissions per locale), a RelationManager over translations() with a locale Select is a ~60-line escape hatch and often clearer than fighting a thin plugin.
Copy-from-default action. A header action that iterates locales and fills empty tabs from the fallback locale is the highest-value convenience you'll add, and users expect it.
Maturity risk is the headline. 8 stars, 0 dependents, one maintainer. Pin the exact version in composer.json, note it in your upgrade docs, and budget for the possibility of forking — the trait surface is small enough that a fork is a day's work, which is also why adopting it is low-risk.
Don't confuse it with Filament's native translatable support. Filament's built-in ->translatable() on fields talks to JSON-column storage (Spatie). Astrotomic stores one row per locale. Mixing the two produces fields that render but never persist.
Validation errors land on the wrong tab. A required field failing on the fr tab leaves the user staring at the en tab with no visible error. Add tab error indicators (a badge or ->badge() driven by $errors), or hook the validation failure to activate the tab owning the first error.
Mass assignment. Translated attributes must be in $translatedAttributes, and the translation model must be fillable for them. Filament's fill() path goes through the parent model, so a missing $translatedAttributes entry silently drops input rather than erroring.
createAnother / reorder actions. Actions that re-save the record can produce duplicate locale rows if you've bypassed Astrotomic's save path (e.g. attaching translation rows manually in a RelationManager while the model also uses the trait). Pick one ownership model per resource.
Bulk actions and delete() cascade to translations only if the FK is set up with onDelete('cascade') — verify the published migration; otherwise you orphan rows that break translate() lookups later.
Debugging hydration. When a field looks empty, dump $livewire->data and $record->translations->pluck('title', 'locale') side by side. The gap between what the form state holds (often an array keyed by locale) and what the model exposes (translate($locale)->title, a string) explains most "it doesn't save" reports.
Extension points. If the shipped trait is too thin, the two seams worth subclassing are the field-schema builder and the table column state resolver — both are pure functions over a locale and a record, so they're safe to wrap without touching the package.
How can I help you explore Laravel packages today?