A client's order form had an "Edit line items" slide-over with a repeater in it. Users would fill in five or six rows, realise the customer had no shipping address on file, click the nested "Add address" action — and come back to an empty repeater. Every row gone. The ticket said "the form clears itself", which sounds like a bug in the app, and the workaround the team had settled on was to remove the nested action entirely and tell people to go create the address on another page first.
It was not a bug in the app. It was Filament's default modal behaviour, and since v5.2 there has been a one-line fix for it.
Understand why the parent modal used to close#
Filament mounts actions in a stack on the Livewire component, but until v5.2 it only ever rendered the top of that stack. When a child action mounted, the parent's modal was closed and then reopened once the child was dismissed. The parent action stayed mounted — what did not survive was the rendered modal and, critically, the unsaved form state inside it. Anything the user had typed and not submitted was gone by the time the parent came back.
overlayParentActions() was added in Filament v5.2.0 (and backported to v4.7.0) to render the whole stack instead: the parent stays mounted and visible, dimmed and offset behind the child. The current stable release as I write this is v5.10.3, so if you are on anything recent you already have it.
Turn on overlayParentActions for the nested action#
The method goes on the child action, not the parent. This is the example straight out of the docs — a repeater's built-in delete action confirming on top of the slide-over that contains it:
use Filament\Actions\Action;
use Filament\Schemas\Components\Repeater;
Action::make('editItems')
->slideOver()
->schema([
Repeater::make('items')
->schema([
// ...
])
->deleteAction(
fn (Action $action) => $action
->requiresConfirmation()
->overlayParentActions(),
),
])
->action(function (array $data): void {
// ...
})
Without it, confirming the deletion of one repeater row tears down and rebuilds the entire slide-over. With it, the confirmation dialog floats on top and the slide-over never moves. If you are already building custom Filament actions with confirmation modals and notifications, this is a single chained call on each nested one.
Stack a nested action inside a repeater row#
The headline case is not the delete confirmation, it is a nested action that opens its own form while the parent's repeater is half-filled. Put the action on the field it relates to with suffixAction(), or on the repeater item with extraItemActions(), and give it overlayParentActions():
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Repeater;
Action::make('editOrder')
->slideOver()
->schema([
Select::make('shipping_address_id')
->relationship('shippingAddress', 'line_one')
->suffixAction(
Action::make('createAddress')
->icon('heroicon-m-plus')
->modalHeading('New shipping address')
->overlayParentActions() // keeps the slide-over mounted
->schema([
TextInput::make('line_one')->required(),
TextInput::make('postcode')->required(),
])
->action(function (array $data): void {
// ...
}),
),
Repeater::make('items')
->schema([
// the rows the user has already typed
]),
])
The repeater rows stay in Livewire's state because the parent component is never re-rendered from scratch. If you are not yet comfortable with how Filament hydrates repeater state between requests, the Repeater and Builder guide to nested, repeatable form data covers the mechanics that make this work.
Open a create modal from a relation manager#
The second real case is a relation manager row action that needs to create a related record before it can finish. Same method, same placement — on the action that opens second:
use App\Models\Courier;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
Action::make('assignCourier')
->schema([
Select::make('courier_id')
->options(fn () => Courier::query()->pluck('name', 'id'))
->required(),
])
->extraModalFooterActions([
Action::make('createCourier')
->overlayParentActions()
->schema([
TextInput::make('name')->required(),
])
->action(function (array $data, Action $parentAction): void {
$courier = Courier::create($data);
// write straight into the parent's Select
$parentAction->fillData([
'courier_id' => $courier->getKey(),
]);
}),
])
->action(function (array $data): void {
// ...
})
Relation managers are where this pays off most, because they are usually already a modal — see the many-to-many relation manager guide for the pivot handling that normally sits alongside this.
Keep validation and state isolated between layers#
Each mounted action validates its own schema. A nested action that fails validation reports errors inside its own modal and leaves the parent untouched — no dirty state, no close. That isolation is the default and you do not have to configure it.
What you do have to think about is reading across the boundary. Inject $parentAction and call getRawData() for the parent's current, unvalidated values, or getValidatedData() when the child genuinely depends on the parent being valid:
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
Action::make('generateInvoiceNumber')
->overlayParentActions()
->schema([
TextInput::make('prefix')->required(),
])
->mountUsing(function (Action $parentAction, Schema $schema): void {
// throws ValidationException before this modal opens
$parentAction->getValidatedData();
$schema->fill();
})
->action(function (array $data, Action $parentAction): void {
$parentAction->fillData([
'reference' => "{$data['prefix']}-123",
]);
})
Call getValidatedData() from mountUsing() rather than action() when the child has a modal, so the parent's errors surface before the child opens on top of them. Two caveats from the docs: getValidatedData() validates without submitting, so it skips beforeStateDehydrated(), mutateDataUsing(), beforeFormValidated() and afterFormValidated() — a freshly uploaded file will still be a TemporaryUploadedFile, not a stored path. For anything more than two levels, inject $mountedActions and index into the stack instead of walking $parentAction repeatedly.
Hand the new record back to the parent form#
fillData() is the supported way to push a value up. It accepts nested arrays or dot notation, silently ignores keys that are not fields in the parent's schema, and leaves validation until the parent is submitted. The point is that the user does not reopen the Select and hunt for the record they just created.
Resist the urge to do this with a Livewire event. An after() callback that dispatches a refresh at the parent component will re-mount the parent form and wipe exactly the state overlayParentActions() just protected. Fill the field, not the form.
Decide what Escape and backdrop clicks should close#
By default, closing a child modal — Escape, backdrop click, or the close button — closes only that modal and leaves the parents mounted. That is almost always what you want in a two-level stack. Two methods change it:
use Filament\Actions\Action;
// cancels parents when the child action RUNS
Action::make('delete')
->requiresConfirmation()
->cancelParentActions()
->action(fn () => /* ... */);
// cancels parents when the child modal is CLOSED
Action::make('saveAsDraft')
->cancelParentActionsOnClose()
->action(fn () => /* ... */);
Both accept the name of a parent to unwind back to — cancelParentActions('createPost') cancels that action and its children while leaving anything above it open. And if a backdrop click on the dimmed parent is discarding work, turn it off on the child with closeModalByClickingAway(false).
Check focus and keyboard behaviour#
Stacked modals are where dialog accessibility quietly breaks, because the layer underneath is still in the DOM and still focusable. Filament autofocuses the new modal, but run the keyboard walkthrough yourself before you ship:
| Action | Expected result |
|---|---|
| Open the nested action | Focus moves into the child modal, not the dimmed parent |
| Tab repeatedly | Focus cycles inside the child only, never reaching parent fields |
| Escape once | Child closes, focus returns to the control that opened it |
| Escape again | Parent closes (or stays, if that is your configured behaviour) |
If autofocus is fighting you, ModalComponent::autofocus(false) in a service provider disables it application-wide. Check the parent layer with the accessibility inspector: anything beneath the top modal should be inert to the tab order.
Know when a stack is the wrong answer#
Two levels reads as "a step deeper". Three reads as a maze, and no amount of z-index tuning fixes that. The honest signals that you have outgrown stacking: the user needs to compare the two layers side by side, the inner modal has its own multi-step flow, or the stack is tall enough that people lose track of what pressing Escape will throw away.
Both alternatives are better than a third modal. A linear sequence belongs in a wizard — turning a Filament v5 create page into a multi-step wizard is usually a smaller change than people expect. Anything with real estate requirements belongs on its own page, which is what a custom Filament v5 page hosting Livewire widgets is for. A very wide parent form is also a hint that the layout, not the modal depth, is the problem — tabs, sections and split will usually buy you back the room.
Test the flow with Pest#
The assertion that matters is that the parent's state survived. Mount the parent, fill it, mount the child through a TestAction array, run the child, then assert the parent's schema state is still set:
use App\Filament\Resources\Orders\Pages\EditOrder;
use App\Models\Courier;
use App\Models\Order;
use Filament\Actions\Testing\TestAction;
use function Pest\Livewire\livewire;
it('keeps parent form state when a nested action runs', function () {
$order = Order::factory()->create();
livewire(EditOrder::class, ['record' => $order->getKey()])
->mountAction('editOrder')
->fillForm([
'items' => [
['sku' => 'ABC-1', 'quantity' => 2],
['sku' => 'ABC-2', 'quantity' => 1],
],
])
// the array addresses the child through its parent
->mountAction([
TestAction::make('editOrder'),
TestAction::make('createCourier')->schemaComponent('courier_id'),
])
->fillForm(['name' => 'Evri'])
->callMountedAction()
->assertHasNoFormErrors()
->assertSchemaStateSet([
'items.0.sku' => 'ABC-1',
'items.1.sku' => 'ABC-2',
]);
expect(Courier::where('name', 'Evri')->exists())->toBeTrue();
});
Add a second test that submits the child with invalid data and asserts assertHasFormErrors() plus the parent state still intact — that is the regression that will bite you. The broader setup for this kind of suite is in testing Filament v5 resources with Pest.
Gotchas and Edge Cases#
Escape handling has had bugs. Issue #19277 — Escape on the child also closing the parent — is closed and fixed. Issue #19561 is still worth knowing about: pressing Escape twice quickly closes both modals, but the parent flickers back open for a fraction of a second first. It is most visible on slow connections; throttle to Slow 4G in devtools if you want to see it.
Two modals means two scroll locks. Body scroll locking applied by both layers can leave the page unscrollable after the stack closes on some browsers. Worth an explicit manual check rather than trusting it.
Modal width does not cascade. A default-width child inside a wide slide-over looks like a mistake. Set modalWidth() on the child deliberately.
Livewire 4.4 broke some test helpers. Reports around Filament 5.7–5.10 say assertActionMounted() and callAction() assertions could return null because the mounted action stack is read via data_get(). If an assertion fails in a way that makes no sense, check your Livewire patch version before you rewrite the test.
Wrapping Up#
Add overlayParentActions() to every nested action that opens over a form the user has already touched — repeater rows, relation manager row actions, and any Select with a "create new" suffix action. Then run the keyboard walkthrough and write the state-survival test, because the method fixes the data loss but not the focus management. If the result still feels deep, the flow probably wants a multi-step wizard instead, and either way the Pest resource testing setup is what keeps it from regressing.
FAQ#
How do I open a modal inside another modal in Filament?
Nest the action. Put a child Action inside the parent action's schema() (on a field via suffixAction(), or on a repeater item via extraItemActions()), or in the parent's extraModalFooterActions(). Filament mounts it as a child of the parent action, and by default it opens its modal in place of the parent's. Add overlayParentActions() to the child if you want both visible at once.
What does overlayParentActions() do in Filament?
It tells Filament to render a child action's modal on top of its parent's modal rather than closing the parent first. The parent stays mounted and visible underneath, dimmed and offset, so its unsaved form state survives. It is called on the child action, not the parent, and it was added in Filament v5.2.0 and backported to v4.7.0.
Why does my Filament parent modal close when I open a nested action?
Because that is the default. Filament temporarily closes the parent action's modal when a child action mounts, and reopens it after the child is dismissed. The parent action itself stays mounted, but the rendered modal and any unsubmitted form state inside it do not survive the round trip. Calling overlayParentActions() on the child stops the parent from being closed at all.
How do I stop losing repeater data when a nested Filament modal opens?
Add overlayParentActions() to every action that can open from inside that parent modal, including the repeater's own delete action. Then avoid pushing data back with a Livewire event that re-mounts the parent form — use $parentAction->fillData() to write into specific fields instead, which updates the value without rebuilding the schema and discarding the rows.
Can Filament action modals be nested more than two levels deep?
Technically yes — Filament tracks the full stack and exposes it as $mountedActions, and cancelParentActions() accepts a parent name so you can unwind to a specific level. In practice two levels is the limit that reads clearly to users. At three, people lose track of what Escape will discard, and a wizard or a dedicated page is a better answer than a third modal.
How do I handle focus and escape keys in stacked Filament modals?
Filament autofocuses the newly opened modal and traps focus within the top layer; you can disable autofocus globally with ModalComponent::autofocus(false). For Escape, the default is that closing a child leaves its parents mounted — use cancelParentActionsOnClose() on the child if Escape should abandon the whole flow, optionally passing a parent action name to unwind only part of the stack. Always verify with a keyboard-only pass that focus returns to the triggering control on close.