Dependent Dropdowns in Filament v4 with live() and Reactive Fields

Build a Filament dependent select cascade in v4: make the parent live(), load child options with a Get closure, reset stale values, and fix the edit form.

Steven Richardson
Steven Richardson
· 7 min read

You pick a country, and the state dropdown just sits there. Or it updates fine on the create page, then loads empty the moment you edit the record. Dependent dropdowns in Filament look trivial until the second field refuses to cooperate — and most examples you'll find are Filament v2 or v3, using namespaces that no longer exist in v4.

This is the pattern I reach for: a Filament dependent select cascade, country → state → city, that works on create and edit, resets cleanly, and doesn't hammer the database. Five small moves and a couple of gotchas, all on current v4 APIs.

Make the parent field live()#

A dependent select only reacts if the parent field tells Livewire to round-trip to the server the instant it changes. In Filament that's live(). Leave it off and the child's options() closure never re-runs, because Filament never re-renders the schema after the parent updates.

use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;

public static function form(Schema $schema): Schema
{
    return $schema->components([
        Select::make('country_id')
            ->relationship('country', 'name')
            ->live() // round-trips on change so dependent fields can react
            ->required(),

        // child select comes next…
    ]);
}

Watch the imports. In v4 the Get and Set utilities moved out of the forms package — if your IDE can't resolve Filament\Forms\Get from an old snippet, that's why. They now live under Filament\Schemas\Components\Utilities, because forms, infolists, and layouts all share one schema engine. You'll also see reactive() in older code; in v4 it's just an alias that calls live() under the hood, so prefer live().

Load the dependent select options with a Get closure#

The child select builds its option list from a closure. Inject the Get utility, read the parent's value with $get(), and return a collection keyed by id. Because the parent is live(), this closure re-runs on every country change.

use Illuminate\Support\Collection;

Select::make('state_id')
    ->label('State')
    ->options(fn (Get $get): Collection => State::query()
        ->where('country_id', $get('country_id'))
        ->pluck('name', 'id'))
    ->required(),

pluck('name', 'id') gives Filament the id => label shape a select expects. If you'd rather the value came back typed, Get has accessors for that — $get->integer('country_id') casts it for you, which matters once you're comparing against an enum-backed column. And if you ever outgrow the built-in select — say you need a combobox that calls an external API — the same Get and Set utilities work inside a custom Filament v4 form field.

Reset the child value when the parent changes#

Here's the bug everyone hits. Change the country and the state field keeps its old value, which now belongs to a different country. The options list refreshes, but the selected value is stale and will happily save. Clear it in afterStateUpdated() with the Set utility.

Select::make('country_id')
    ->relationship('country', 'name')
    ->live()
    ->afterStateUpdated(fn (Set $set) => $set('state_id', null))
    ->required(),

For a three-level cascade, null everything downstream when a parent changes — otherwise a city can outlive the state it belonged to:

->afterStateUpdated(function (Set $set) {
    $set('state_id', null);
    $set('city_id', null);
})

Disable the child until the parent is set#

Until a country is chosen, the state field has nothing to offer. Disable it so nobody clicks into an empty dropdown, and re-enable it the moment the parent is filled. A placeholder() that reacts to the same condition is a nice touch.

Select::make('state_id')
    ->label('State')
    ->options(fn (Get $get): Collection => State::query()
        ->where('country_id', $get('country_id'))
        ->pluck('name', 'id'))
    ->disabled(fn (Get $get): bool => ! filled($get('country_id')))
    ->placeholder(fn (Get $get): string => filled($get('country_id'))
        ? 'Select a state'
        : 'Pick a country first')
    ->required(),

Hydrate dependent values on the edit form#

This is where the old tutorials piled on workarounds. In v4 you usually need none of them. When state_id is a real column on the model, the edit page hydrates the form state from the record, the parent is live(), and the child's options() closure runs with the already-populated country_id — so the saved value resolves on its own. No manual hydration, no mount() hook.

The one exception is when the parent field isn't actually stored on the record — for example you persist city_id and only derive the country through the relationship. Then seed the derived parent inside the closure, guarding with a null-safe record so the create page doesn't blow up:

Select::make('state_id')
    ->options(function (?Address $record, Get $get, Set $set): Collection {
        // Only needed when the parent isn't a stored column:
        if ($record && blank($get('country_id'))) {
            $set('country_id', $record->state->country_id);
        }

        return State::query()
            ->where('country_id', $get('country_id'))
            ->pluck('name', 'id');
    }),

If your hierarchy is really about editing child records inside their parent, a stack of selects might be the wrong tool entirely — Filament v4 nested resources model parent/child as first-class pages with their own URLs and breadcrumbs.

Handle searchable dependent selects, repeaters, and performance#

Three things bite once the basic cascade works: searchable selects, repeaters, and the database load from all those round-trips.

Searchable children. Adding ->searchable() keeps options() working for modest lists, but for a large cities table switch to getSearchResultsUsing() so you're not pulling thousands of rows on every keystroke. It can read $get() too:

Select::make('city_id')
    ->searchable()
    ->getSearchResultsUsing(fn (string $search, Get $get): array => City::query()
        ->where('state_id', $get('state_id'))
        ->where('name', 'like', "%{$search}%")
        ->limit(50)
        ->pluck('name', 'id')
        ->all())
    ->getOptionLabelUsing(fn ($value): ?string => City::find($value)?->name),

Repeaters. Inside a Repeater, $get() and $set() are scoped to the current item. To reach a field that lives outside the repeater you have to walk up the data tree with ../: $get('../../country_id'). Forget that and your options closure silently returns an empty list with no error to chase. When the relationship behind the field is many-to-many, it's usually cleaner to drop the hand-rolled cascade for a relation manager with pivot fields.

Performance. Every live() change is a round-trip plus the options() query. A country list that never changes within a request shouldn't be re-queried each time — cache it. For genuinely expensive option queries, persist the result across requests the way I covered with Livewire 4's persisted computed properties. And v4 gives you two more levers: partiallyRenderComponentsAfterStateUpdated(['state_id']) re-renders only the child instead of the whole schema, and afterStateUpdatedJs() runs a JavaScript expression to update dependent state in the browser with no server round-trip at all.

Wrapping Up#

A dependent select in Filament v4 is four moves: live() on the parent, an options() closure that reads $get(), an afterStateUpdated() that nulls the stale child, and a disabled() guard for UX. Get the namespaces right — Get and Set sit under Filament\Schemas\Components\Utilities now — and the edit form takes care of itself.

If you're standing up a whole panel, the complete zero-to-production Filament v4 guide puts this in context, and once those dashboards start doing real work, polling intervals and deferred loading keep them fast.

FAQ#

How do I create dependent dropdowns in Filament?

Mark the parent select ->live(), then give the child select an ->options() closure that reads the parent's value with the Get utility, for example State::where('country_id', $get('country_id'))->pluck('name', 'id'). Add an ->afterStateUpdated() on the parent that nulls the child so a stale value can't survive a change. That combination is the whole pattern on both create and edit pages.

What is the difference between live() and reactive() in Filament?

There's no functional difference in current Filament — reactive() is a backward-compatibility alias that calls live() internally. The method was named reactive() in v2, renamed to live() in v3, and v4 keeps reactive() around only so old code doesn't break. Use live() in new forms, and reach for its options like live(debounce: '500ms') or live(onBlur: true) when you want to throttle the round-trips.

How do I reset a dependent field when the parent changes?

Add ->afterStateUpdated(fn (Set $set) => $set('child_id', null)) to the parent select, injecting the Set utility from Filament\Schemas\Components\Utilities. When the parent changes, this clears the child so it can't keep a value that no longer belongs to the new parent. In a multi-level cascade, null every field below the one that changed, not just the next one down.

Why isn't my Filament dependent select updating?

Almost always one of four things: the parent field is missing ->live(), so the schema never re-renders; the child's options() closure isn't actually reading $get('parent_id'); you imported Get from the old Filament\Forms namespace instead of Filament\Schemas\Components\Utilities; or the field is inside a Repeater and you used $get('country_id') instead of $get('../../country_id'). Check those in order and one of them will be the culprit.

How do I make a country/state/city cascade in Filament?

Chain three selects. Each child gets an options() closure filtered by the field directly above it — state by country_id, city by state_id. Every field that has a dependent below it is marked ->live() and nulls its descendants in afterStateUpdated(). Disable each child until its parent is filled with ->disabled(fn (Get $get) => ! filled($get('parent_id'))), and the same closures will rehydrate saved values correctly when you edit an existing record.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.