Designing Filament Table Actions for Complex Record States Without Losing Your Mind
State transitions deserve better than a status dropdown. Here's how to build guarded, confirmed, transactional Filament table actions — and bulk actions that won't exhaust memory.
09 Jun 2026, 11:51 UTC

Every admin panel eventually hits the same wall: a record isn't just "created" or "deleted" — it has a lifecycle. An order is pending, then paid, then maybe refunded. A user is invited, active, suspended. The naive approach is to dump every possible transition into your Filament table as a row action, which quickly produces a cluttered UI where an admin can accidentally refund an unpaid order.
The better approach — and the thesis of this piece — is to treat each state transition as a first-class, guarded action: visible only when valid, confirmed when destructive, transactional when executed, and reported back to the user. Filament's declarative action API supports all four properties with very little code.
Model the transition, not the button
A Filament table action is more than a button. An Action::make() definition bundles the trigger, an optional modal form, a visibility condition, and the execution closure. That bundling is what makes it a good home for state-machine logic: the rules of when a transition is allowed live next to the code that performs it.
Resist the urge to put a raw "Edit status" dropdown on the row. Explicit transitions (markAsPaid, suspend, refund) are self-documenting in the UI and map cleanly to authorization policies and audit logs later.
A worked example: refunding an order
Assume Filament v3, Laravel 10+, and an Order model with a status column. In your OrderResource's table() method:
use Filament\Tables\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\DB;
Action::make('refund')
->label('Refund')
->icon('heroicon-o-arrow-uturn-left')
->color('danger')
->visible(fn (Order $record): bool => $record->status === 'paid')
->requiresConfirmation()
->modalHeading('Refund this order?')
->modalDescription('This will mark the order as refunded and cannot be undone here.')
->action(function (Order $record): void {
DB::transaction(function () use ($record) {
$record->update(['status' => 'refunded']);
// dispatch refund job, write audit log, etc.
});
Notification::make()
->title('Order refunded')
->success()
->send();
})Three decisions are doing the work here. First, visible() hides the action on any order that isn't paid, so invalid transitions are impossible to trigger from the UI (still enforce the rule server-side in the closure or a policy — visibility is UX, not security). Second, requiresConfirmation() gives you a modal for free, which matters for destructive transitions. Third, the database work runs inside DB::transaction() so a failure halfway through doesn't leave the order in a half-refunded state.
Bulk actions: batch with respect for memory
Bulk actions receive an Eloquent Collection of the selected records. For a few dozen rows, a simple loop inside a transaction is fine:
use Filament\Tables\Actions\BulkAction;
BulkAction::make('markAsShipped')
->icon('heroicon-o-truck')
->requiresConfirmation()
->action(function (Collection $records): void {
DB::transaction(function () use ($records) {
$records->each->update(['status' => 'shipped']);
});
Notification::make()
->title($records->count() . ' orders marked as shipped')
->success()
->send();
})The catch: Filament hydrates the full selected models into that collection. If an admin selects 50,000 rows, you're holding 50,000 models in memory before your closure even runs. For large tables, prefer one of these:
- Dispatch a queued job from the action, passing only the selected IDs, and process in chunks (
Order::whereIn('id', $ids)->chunkById(500, ...)) inside the job. - Or run a single set-based query (
->whereIn(...)->update()) when you don't need per-model events or validation.
Also note the per-record guard problem: in the bulk example, visible() can't filter individual records. If some selected orders aren't shippable, filter inside the closure ($records->where('status', 'paid')) and report how many were skipped in the notification.
Trade-offs worth knowing
Modal forms on actions are powerful — you can collect a reason, an amount, a date — but for simple binary toggles (featured/unfeatured), a full action with a confirmation modal is heavier than a ToggleColumn directly in the table. Match the interaction weight to the risk of the operation.
Also, action closures run in the web request by default. Anything slower than a second or two (calling a payment gateway for a refund, generating PDFs) belongs in a queued job, with the notification sent when the job completes rather than from the action itself.
How to verify it works
- Load the resource's table page and confirm the Refund action appears only on orders with
status = 'paid'. - Trigger it, confirm the modal appears, and check the database row and any audit log after the closure runs.
- For the bulk action, select a mixed set of records and confirm the guard filters correctly and the notification count matches reality.
- For large selections, watch memory usage (e.g., via Laravel Telescope or a simple
memory_get_peak_usage()log) to decide when to move to a queued job.
The actionable takeaway: before adding another row action, ask three questions — when is it valid, is it destructive, and will it scale? Filament gives you visible(), requiresConfirmation(), and transactions/queues to answer each one deliberately. Your future admin users (and your audit log) will thank you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.