Search

Filament Actions: Modals, Bulk Actions and Confirmation Patterns

Filament Actions: Modals, Bulk Actions and Confirmation Patterns

Key takeaways

  • ✓ Since v4 there is one Filament\Actions\Action class. The same action works in tables, page headers, forms and infolists.
  • ✓ Scale confirmation to risk: requiresConfirmation() for routine changes, a typed confirmation for destructive ones, and a modal form when you need a reason or details.
  • ✓ Bulk actions receive a Collection of records. For large selections, use chunkSelectedRecords() and authorise records individually with authorizeIndividualRecords().
  • ✓ ->visible() controls UX, ->authorize() controls security. Use both on anything that matters.

Actions are the verbs of a Filament panel: approve, refund, archive, send, export. In Filament v3 they were split across several classes (table actions, page actions, form actions). Since v4 there’s a single Filament\Actions\Action that works everywhere, and the modal API has grown into a small UI toolkit of its own. This guide collects the patterns worth standardising on in a v5 panel, from the simplest to the most careful.

Also Read: Laravel and PHP

Where actions live

LocationMethodTypical actions
Table row->recordActions([...])Edit, Approve, Refund
Table header->headerActions([...])Create, Import, Export
Table toolbar (bulk)->toolbarActions([...])Bulk delete, bulk assign
Resource page headergetHeaderActions()Delete, Duplicate, Send
Inside a schemaActions::make([...]) or a field’s ->suffixAction()Generate slug, Fetch address

Because it’s the same class everywhere, an approve action you build for a table row can be reused on the view page header.

Pattern 1: Simple confirmation

For reversible, low-risk actions:

use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;

Action::make('publish')
    ->icon(Heroicon::OutlinedEye)
    ->color('success')
    ->requiresConfirmation()
    ->modalHeading('Publish this post?')
    ->modalDescription('It will be visible on the website immediately.')
    ->modalSubmitActionLabel('Yes, publish')
    ->visible(fn (Post $record) => $record->isDraft())
    ->authorize('publish')
    ->action(function (Post $record): void {
        $record->publish();

        Notification::make()->title('Post published')->success()->send();
    }),

authorize('publish') calls PostPolicy::publish(). If the policy says no, the action is hidden and can’t be triggered by a crafted Livewire request. visible() only handles the UI state. Our guide to roles and permissions in Filament covers the policy side.

Pattern 2: Typed confirmation for destructive actions

“Are you sure?” dialogs get clicked through out of habit. For actions you can’t undo, such as deleting a workspace or revoking every API key, make the user type something specific:

use Filament\Forms\Components\TextInput;

Action::make('deleteWorkspace')
    ->color('danger')
    ->icon(Heroicon::OutlinedTrash)
    ->modalIcon(Heroicon::OutlinedExclamationTriangle)
    ->modalHeading('Delete workspace permanently')
    ->modalDescription('This deletes all projects, files and members. It cannot be undone.')
    ->schema([
        TextInput::make('confirmation')
            ->label(fn (?Workspace $record) => "Type \"{$record?->slug}\" to confirm")
            ->required()
            ->in(fn (?Workspace $record): array => [$record?->slug])
            ->validationMessages(['in' => 'That does not match the workspace name.']),
    ])
    ->modalSubmitActionLabel('Delete forever')
    ->authorize('forceDelete')
    ->action(fn (Workspace $record) => $record->forceDelete()),

Validation runs on the server, so the check can’t be bypassed from the browser.

Also Read: Laravel and PHP

Pattern 3: Modal forms that collect details

When the action needs input, such as a refund amount or a rejection reason, use schema():

use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;

Action::make('refund')
    ->icon(Heroicon::OutlinedReceiptRefund)
    ->fillForm(fn (Order $record): array => [
        'amount' => $record->refundable_amount,
    ])
    ->schema([
        TextInput::make('amount')
            ->numeric()
            ->prefix('£')
            ->required()
            ->maxValue(fn (?Order $record) => $record?->refundable_amount),
        Select::make('reason')
            ->options(RefundReason::class)
            ->required(),
        Textarea::make('note')->rows(3),
    ])
    ->modalWidth(\Filament\Support\Enums\Width::Large)
    ->action(function (array $data, Order $record): void {
        $record->refund($data['amount'], $data['reason'], $data['note'] ?? null);
    }),

fillForm() pre-fills the fields. $data receives validated input. Since v4, enum-backed fields always give you an enum instance, so $data['reason'] is a RefundReason, not a string.

Pattern 4: Wizards for multi-step flows

For longer flows, such as onboarding a customer or configuring an integration, swap schema() for steps():

use Filament\Schemas\Components\Wizard\Step;

Action::make('onboard')
    ->steps([
        Step::make('Company')
            ->schema([
                TextInput::make('company_name')->required(),
                TextInput::make('vat_number'),
            ]),
        Step::make('Billing')
            ->schema([
                Select::make('plan')->options(Plan::class)->required(),
            ]),
        Step::make('Invite')
            ->schema([
                TextInput::make('admin_email')->email()->required(),
            ]),
    ])
    ->action(fn (array $data) => app(OnboardCustomer::class)($data)),

Each step validates before the user can move on. The final $data contains every step’s fields.

Pattern 5: Slide-overs for context-heavy work

Wide modals hide the table the user is working from. A slide-over keeps the list visible:

Action::make('viewTimeline')
    ->slideOver()
    ->modalContent(fn (Order $record) => view('filament.orders.timeline', ['order' => $record]))
    ->modalSubmitAction(false)
    ->modalCancelActionLabel('Close'),

Add stickyModalHeader() and stickyModalFooter() for long content, and closeModalByClickingAway(false) for forms where losing input would hurt.

Also Read: Best Filament Themes and PHP Starter Kits for 2026 (v5-Ready)

Pattern 6: Nested and chained actions

Modals can contain actions that open their own modals. Filament stacks them automatically. A common example is a “Reject” action with an extra footer button that sends the customer a message first:

Action::make('reject')
    ->schema([Textarea::make('reason')->required()])
    ->extraModalFooterActions(fn (Action $action): array => [
        $action->makeModalSubmitAction('rejectAndNotify', arguments: ['notify' => true])
            ->label('Reject & email customer'),
    ])
    ->action(function (array $data, array $arguments, JobApplication $record): void {
        $record->reject($data['reason']);

        if ($arguments['notify'] ?? false) {
            $record->customer->notify(new ApplicationRejected($data['reason']));
        }
    }),

makeModalSubmitAction() submits the same form with extra arguments, so you avoid building two nearly identical actions. For child actions that should close their parent modal when they finish, use cancelParentActions().

Pattern 7: Bulk actions that won’t fall over

Bulk actions receive the selected records as a Collection:

use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Illuminate\Database\Eloquent\Collection;

->toolbarActions([
    BulkActionGroup::make([
        BulkAction::make('assign')
            ->icon(Heroicon::OutlinedUserPlus)
            ->schema([
                Select::make('agent_id')->relationship('agent', 'name')->required(),
            ])
            ->action(function (Collection $records, array $data): void {
                $records->each->update(['agent_id' => $data['agent_id']]);
            })
            ->deselectRecordsAfterCompletion(),

        DeleteBulkAction::make()
            ->authorizeIndividualRecords('delete'),
    ]),
])

Three things to get right with bulk actions:

  1. Memory. “Select all” on 40,000 rows loads 40,000 models. Use ->chunkSelectedRecords(250) so records are processed in chunks, or dispatch a queued job from the action with the selected IDs.
  2. Authorisation. By default, bulk actions check the *Any policy method once, for speed. If some records may be off limits (another team’s, or locked), use authorizeIndividualRecords() to run the per-record policy.
  3. Bypassing model events. Model::whereIn(...)->update() is fast but skips observers, so your audit log never sees the change. $records->each->update() fires events. Choose deliberately.

Pattern 8: Grouping and keyboard shortcuts

Row actions add up quickly. Group secondary ones behind an ellipsis:

use Filament\Actions\ActionGroup;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;

->recordActions([
    EditAction::make(),
    ActionGroup::make([
        ViewAction::make(),
        Action::make('duplicate')->action(fn (Post $record) => $record->replicate()->save()),
        Action::make('archive')->requiresConfirmation()->action(fn (Post $record) => $record->archive()),
    ])->tooltip('More'),
])

For power users, bind shortcuts to page actions with ->keyBindings(['command+s', 'ctrl+s']).

Pattern 9: Actions inside forms

Actions aren’t only buttons in tables and headers. Attach them to fields as affix actions, for example a “generate slug” button next to the slug input:

use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\Str;

TextInput::make('slug')
    ->required()
    ->suffixAction(
        Action::make('generateSlug')
            ->icon(Heroicon::OutlinedSparkles)
            ->tooltip('Generate from title')
            ->action(fn (Get $schemaGet, Set $schemaSet) => $schemaSet('slug', Str::slug($schemaGet('title')))),
    ),

Inside schemas, actions can inject $schemaGet and $schemaSet to read and write other fields’ state. Use the parameter names exactly, because Filament injects utilities by name.

Also Read: Filament Blueprint Review (2026): Is PHP It Worth It for AI Agents?

Pattern 10: Stopping an action partway through

Sometimes you only find out inside the action that it can’t go ahead: the order was refunded by someone else a second ago, or an external API refused the request. Use halt() to keep the modal open with the user’s input intact, or cancel() to close it:

->action(function (array $data, Order $record, Action $action): void {
    if (! $record->fresh()->isRefundable()) {
        Notification::make()
            ->title('This order can no longer be refunded')
            ->body('It may have been refunded by someone else. Refresh to see the latest status.')
            ->danger()
            ->send();

        $action->halt();   // modal stays open
    }

    $record->refund($data['amount']);
})

Re-checking state with fresh() inside the action, not just in visible(), is what makes it safe against two admins clicking at the same time. For money, wrap the check and the change in a database transaction with a row lock (lockForUpdate()).

Notifications: tell users what happened

Every action that changes data should confirm it. Prebuilt actions have ->successNotificationTitle('Refund issued'). In custom actions, send a Notification yourself. For long-running work, queue a job and send a database notification when it finishes, so the user isn’t left watching a spinner.

Testing actions

use Filament\Actions\Testing\TestAction;
use function Pest\Livewire\livewire;

it('refunds an order from the table', function () {
    $order = Order::factory()->paid()->create(['total' => 100]);

    livewire(ListOrders::class)
        ->callAction(TestAction::make('refund')->table($order), data: [
            'amount' => 40,
            'reason' => RefundReason::Damaged,
        ])
        ->assertHasNoFormErrors()
        ->assertNotified();

    expect($order->fresh()->refunded_amount)->toEqual(40);
});

it('hides publish for published posts', function () {
    $post = Post::factory()->published()->create();

    livewire(ListPosts::class)
        ->assertActionHidden(TestAction::make('publish')->table($post));
});

More patterns, including bulk actions with ->table()->bulk(), are in our Filament + Pest testing guide.

Also Read: Claude Code & Cursor on Filament: AI Agent Rules That Work

FAQ

How do I add a confirmation modal to a Filament action?

Call ->requiresConfirmation() on the action. Customise it with modalHeading(), modalDescription(), modalSubmitActionLabel() and modalIcon().

How do I add a form to a Filament action?

Pass form components to ->schema([...]). The validated input arrives as array $data in ->action(). Use ->fillForm() to set default values.

How do I process thousands of records in a bulk action?

Use ->chunkSelectedRecords() to process in chunks, or dispatch a queued job with the selected keys. Add authorizeIndividualRecords() if permissions vary per record.

What’s the difference between visible() and authorize()?

visible() / hidden() only affect whether the button is rendered. authorize() checks a policy ability and also blocks execution if the action is triggered directly.

Sources and further reading

Related on The Web Tier: CSV import and export in Filament · Soft deletes and trash views

TWT Staff

TWT Staff

Writes about Programming, tech news, discuss programming topics for web developers (and Web designers), and talks about SEO tools and techniques

Your experience on this site will be improved by allowing cookies Cookie Policy