Every admin panel ends up needing one-off destructive flows. Refund a transaction. Archive a project. Recompute a ranking. Most teams reach for raw Livewire, lose the polish of Filament's modal system, and hand-roll the same confirmation and notification glue every time.
Filament v4 covers all of it declaratively. In about twenty-five minutes you can ship a RefundAction with a confirmation modal, a typed reason field, a real side-effect, and the toast notifications a non-developer would expect. This walkthrough builds exactly that — the patterns generalise to archive, recompute, force-sync, or any action you'd rather not write twice.
Define a custom Action on a Resource page or table#
In v4 there is one Action class and one namespace: Filament\Actions\Action. The old split between Filament\Tables\Actions\Action, Filament\Pages\Actions\Action, and Filament\Forms\Components\Actions\Action is gone. You define an Action once and place the same instance in a table row, a header bar, a bulk dropdown, or an infolist — wherever you need it.
Start by adding a header action to the Order list. Open app/Filament/Resources/OrderResource/Pages/ListOrders.php:
<?php
namespace App\Filament\Resources\OrderResource\Pages;
use App\Filament\Resources\OrderResource;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
class ListOrders extends ListRecords
{
protected static string $resource = OrderResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('refund')
->label('Refund Order')
->icon('heroicon-o-receipt-refund')
->color('danger'),
];
}
}
Reload the page and you have a clickable button. It doesn't do anything yet — that's the next four sections. If you'd rather attach the action to a single row, drop the same Action::make('refund') call into the table's ->actions([...]) array on the Resource. The same instance also works inside ->bulkActions([...]) if you wrap it with ->requiresConfirmation() and operate on a collection. Filament v4's relation managers expose the same Action API — the patterns from Filament v4 many-to-many relation managers chain into anything you build here.
Add requiresConfirmation with a heading and description#
requiresConfirmation() flips the action from "execute immediately" to "open a confirmation modal first." Three text knobs make that modal readable:
Action::make('refund')
->label('Refund Order')
->icon('heroicon-o-receipt-refund')
->color('danger')
->requiresConfirmation()
->modalHeading('Refund this order?')
->modalDescription('This issues a full refund through Stripe and emails the customer a receipt. There is no undo.')
->modalSubmitActionLabel('Yes, refund the order')
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('danger'),
modalHeading() becomes the modal's <h2>, modalDescription() is the prose underneath it, and modalSubmitActionLabel() rewrites the default "Confirm" button. Every one of those defaults to something generic, but generic is dangerous for destructive flows — a user who sees "Are you sure?" / "Confirm" three times a day stops reading. Spell out the consequence and label the button with the verb of the action.
The modalIcon() plus modalIconColor('danger') pair pulls the standard red warning glyph into the modal header. Useful for any irreversible step and free once you've already pulled Heroicons into the project.
Collect input via the action's form() method#
A confirm-then-execute action is fine for "archive" or "republish." Refunds need a reason. Pass a flat array of Filament form components into ->form([...]):
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
Action::make('refund')
->label('Refund Order')
->icon('heroicon-o-receipt-refund')
->color('danger')
->requiresConfirmation()
->modalHeading('Refund this order?')
->modalDescription('Issues a refund through Stripe and notifies the customer.')
->modalSubmitActionLabel('Issue refund')
->form([
Select::make('reason_code')
->label('Reason')
->options([
'duplicate' => 'Duplicate charge',
'fraudulent' => 'Fraudulent transaction',
'requested_by_customer' => 'Customer request',
'product_defect' => 'Product defect or damage',
])
->required()
->native(false),
Textarea::make('internal_notes')
->label('Internal notes')
->rows(3)
->required()
->maxLength(500),
TextInput::make('amount')
->label('Refund amount')
->prefix('£')
->numeric()
->required()
->minValue(0.01),
]),
Filament collects every field's state into a flat $data array keyed by the field name and hands it to your action closure. Validation runs automatically — required(), numeric(), and maxLength() work the same way they do on a Resource form, and the modal won't submit until everything is green.
If your action needs a richer input than the built-in fields cover, the same custom field you'd ship into a Resource form schema slots straight into the modal. The Filament v4 custom form field walkthrough covers the field-class plus Blade-view pattern; once registered, a custom field reads $data['my_field'] in this closure just like a TextInput.
Run the side-effect inside the action() closure#
The ->action() closure is where your business logic lives. Filament resolves its arguments using the same utility injection it uses everywhere — type-hint what you need:
use App\Models\Order;
use App\Services\StripeRefunder;
use Filament\Actions\Action;
Action::make('refund')
// ... config above
->action(function (array $data, Order $record, StripeRefunder $stripe) {
$stripe->refund(
order: $record,
amountInPence: (int) round($data['amount'] * 100),
reasonCode: $data['reason_code'],
internalNotes: $data['internal_notes'],
);
$record->update([
'status' => 'refunded',
'refunded_at' => now(),
]);
}),
A few things to notice. $record is auto-injected for row and bulk actions and resolves to the Eloquent model bound to that row. For a header action with no implicit record, omit the parameter and look the record up yourself. StripeRefunder is type-hinted so Filament resolves it from the container — the same DI you'd get in a controller method. And $data is the flat array Filament built from the modal form, with every value already cast through the field's validation rules.
This is also the right place to dispatch background work. If the refund flow needs to issue the Stripe call, email the customer, recompute the ledger, and post to Slack, don't run all four in the request — push them onto a queue. The patterns in Laravel queue chains vs batches cover the trade-offs; for an admin action like this, a chain works because the Slack post should only fire after the email succeeds.
Wire up success and failure notifications#
The single most common bug in custom Filament actions: the closure runs, the data updates, and the user gets no feedback. Filament does not show a notification automatically. You set the success and failure titles explicitly:
Action::make('refund')
// ... config above
->action(function (array $data, Order $record, StripeRefunder $stripe) {
try {
$stripe->refund(
order: $record,
amountInPence: (int) round($data['amount'] * 100),
reasonCode: $data['reason_code'],
internalNotes: $data['internal_notes'],
);
$record->update([
'status' => 'refunded',
'refunded_at' => now(),
]);
} catch (StripeRefundException $e) {
report($e);
Notification::make()
->title('Refund failed')
->body($e->getMessage())
->danger()
->send();
throw $e;
}
})
->successNotificationTitle('Refund issued')
->failureNotificationTitle('Refund failed'),
successNotificationTitle() fires the moment your closure returns without throwing. failureNotificationTitle() is shown when Filament catches a Halt or validation exception thrown from the closure — but it does not fire for arbitrary exceptions that bubble up. For real third-party failures, build the notification manually with Filament\Notifications\Notification::make() so you control the body, the icon, and the persistence behaviour.
You can also reach the action instance inside the closure and override the title dynamically:
->action(function (array $data, Order $record, Action $action) {
// ...
$action->successNotificationTitle("Refund of £{$data['amount']} issued");
}),
That pattern is useful when the success message needs to include data the user just entered.
Render a custom Blade view for the modal body#
When modalDescription() isn't enough — say you want to render the order summary, the refund history, and an Alpine countdown that disables the submit button for three seconds — swap the body out for a Blade view:
Action::make('refund')
// ... config above
->modalContent(fn (Order $record) => view(
'filament.actions.refund-summary',
['order' => $record],
)),
modalContent() accepts a View instance or a closure that returns one. Filament resolves utilities the same way it does everywhere — type-hint Order $record, Action $action, or \Livewire\Component $livewire and they're injected. The view replaces the description text; if you want the form fields to render above or below your custom content, pair modalContent() with modalContentFooter() for the same closure pattern below the form.
Inside the view, you have full Blade and Alpine at your disposal:
{{-- resources/views/filament/actions/refund-summary.blade.php --}}
<div class="space-y-4 text-sm">
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-900">
<p class="font-medium">Order #{{ $order->reference }}</p>
<p class="text-gray-500">{{ $order->customer_name }} · {{ $order->placed_at->format('j M Y') }}</p>
<p class="mt-2 text-lg font-semibold">£{{ number_format($order->total, 2) }}</p>
</div>
@if ($order->refunds->isNotEmpty())
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900">
<p class="font-medium">This order has been partially refunded</p>
<p>£{{ number_format($order->refunds->sum('amount'), 2) }} already returned across {{ $order->refunds->count() }} refund(s).</p>
</div>
@endif
</div>
If the modal content is expensive to compute — a chart, a render of related records, a network call — pair the action with a lazy-loaded Livewire 4 island inside the Blade view. The island defers rendering until the modal opens, so the action button itself stays cheap.
Gotchas and Edge Cases#
modalDescription() does not re-evaluate when the value depends on $arguments passed via ->mountUsing(). There's an open report (filament#12877) tracking it — the workaround is to set the description inside the mountUsing() callback itself: fn ($action) => $action->modalDescription(...).
failureNotificationTitle() only fires when Filament's action runner catches a controlled Halt exception or a validation failure. Any other exception bubbles up the normal Livewire/Laravel error path. If you want a guaranteed failure toast for arbitrary exceptions, wrap the work in a try/catch inside the closure and call Notification::make()->danger()->send() yourself.
Authorization runs through ->authorize() and accepts the same arguments as a Laravel policy. ->authorize('refund', $record) checks OrderPolicy::refund($user, $order) — and Filament hides the button entirely when the check returns false. Don't rely on ->visible() for security; that controls UI but not the action runner.
Bulk versions of an Action need a different injection signature. The closure receives Collection $records rather than a single Model $record, and $data is still the modal form state. Filament wires the same Action class into both contexts when you place it in ->actions([...]) versus ->bulkActions([...]) — the closure has to handle both, or you define two Action instances with different action closures.
The Action namespace consolidation is real but the migration path matters. If you're carrying any code from v3 in the same project, the Filament\Tables\Actions\Action import still resolves to a deprecated alias in v4 — Pint and PHPStan will flag it, but a stray copy/paste won't fail at runtime until the alias is removed.
Wrapping Up#
Once you've built one Action with confirmation, form, side-effect, and notifications, the same skeleton handles every other destructive flow in the panel. Define it once, place it in headers or rows or bulk dropdowns, and trust the modal system to handle the chrome.
If the next step is broadcasting that "refund issued" notification beyond the toast — to other admins in the same panel or to the customer — pair this pattern with Laravel Reverb for real-time notifications. And if you're hardening the admin panel more generally, Filament v4 many-to-many relation managers covers the related-record actions that share the same lifecycle as this one.
FAQ#
How do I add a confirmation modal to a Filament action?
Chain ->requiresConfirmation() onto your Action::make() call. That alone gives you a modal with default heading and submit text, but you should always set ->modalHeading(), ->modalDescription(), and ->modalSubmitActionLabel() to spell out the consequence and verb. For destructive actions, add ->modalIcon('heroicon-o-exclamation-triangle') and ->modalIconColor('danger') so the warning glyph appears in the modal header.
How do I show a success notification after a Filament action runs?
Set ->successNotificationTitle('Your title here') on the action. Filament fires that toast automatically when the ->action() closure returns without throwing. Pair it with ->failureNotificationTitle() for the controlled-failure path. For arbitrary exceptions or richer notifications, build one manually inside the closure with Filament\Notifications\Notification::make()->title(...)->success()->send().
How do I collect form input inside a Filament action modal?
Pass a flat array of Filament form components into ->form([...]) — TextInput::make(), Select::make(), Textarea::make(), or any custom field you've built. Filament renders them inside the modal, runs validation on submit, and hands the resulting state to your ->action() closure as a $data array keyed by the field names. Required fields, numeric casts, and max lengths work exactly as they do on a Resource form.
Can I render a custom Blade view inside a Filament action modal?
Yes. Pass a closure to ->modalContent() that returns a view(...) instance, and type-hint any utilities you need — Order $record, Action $action, or \Livewire\Component $livewire. The Blade file gets full access to Alpine, Tailwind, and your application's Heroicons, so you can render charts, related-record summaries, or interactive elements above the form fields. Pair with ->modalContentFooter() if you want content below the form too.
How do I customise the heading and submit button label of a Filament action modal?
Chain ->modalHeading('Your heading') and ->modalSubmitActionLabel('Your verb') onto the action. The default heading reads "Are you sure?" and the default submit reads "Confirm" — neither communicates anything specific, so rewrite both. You can also set ->modalDescription() for the prose underneath the heading and ->modalCancelActionLabel() for the cancel button. Every one of these accepts a closure if you need the text to depend on $record or $arguments.