Why Bulk Actions Deserve More Attention
Most Filament tutorials show DeleteBulkAction and move on. In real applications, bulk actions are where business logic concentrates — mass approvals, batch exports, multi-record state transitions — and they need proper authorization, user feedback, and reliable async dispatch.
This article focuses on Filament v4's bulk action API, which ships with the unified Schema system and a cleaner modal builder.
Defining a Custom Bulk Action
Register bulk actions inside table() on your resource's ListRecords page or directly in the Table definition:
use Filament\Tables\Actions\BulkAction;
use Illuminate\Database\Eloquent\Collection;
BulkAction::make('approve')
->label('Approve Selected')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('Approve Applications')
->modalDescription('Only applications you are authorised to approve will be processed.')
->modalSubmitActionLabel('Approve Now')
->action(function (Collection $records): void {
$records
->filter(fn ($r) => auth()->user()->can('approve', $r))
->each(fn ($r) => ApproveApplicationJob::dispatch($r));
})
->deselectRecordsAfterCompletion()
The requiresConfirmation() call renders a modal automatically. The modalDescription string is the right place to set user expectations about partial authorization.
Per-Record Authorization Inside the Action
Filament's authorize() callback controls whether the bulk action button is visible, but it does not filter individual records. Always filter inside action() itself:
->authorize(fn (): bool => auth()->user()->can('approve-applications'))
->action(function (Collection $records): void {
$authorized = $records->filter(
fn ($record) => auth()->user()->can('approve', $record)
);
if ($authorized->isEmpty()) {
Notification::make()
->title('Nothing to approve')
->warning()
->send();
return;
}
$authorized->each(fn ($r) => ApproveApplicationJob::dispatch($r));
Notification::make()
->title("{$authorized->count()} application(s) queued for approval")
->success()
->send();
})
This pattern prevents privilege escalation when a user selects records they own alongside records they do not.
Adding a Custom Confirmation Form
Sometimes you need extra input before the action runs — a reason field, a scheduled date, a confirmation string. Filament v4 lets you attach a Schema-based form to any bulk action modal:
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\DatePicker;
BulkAction::make('schedule_review')
->label('Schedule Review')
->form([
DatePicker::make('review_date')
->label('Review Date')
->required()
->minDate(today()->addDay()),
Textarea::make('notes')
->label('Internal Notes')
->maxLength(500),
])
->action(function (Collection $records, array $data): void {
$records->each(
fn ($r) => $r->update([
'review_date' => $data['review_date'],
'review_notes' => $data['notes'],
'status' => 'pending_review',
])
);
})
->deselectRecordsAfterCompletion()
The $data array is automatically validated against the form rules before action() fires.
Chunking Large Selections Safely
When a user selects thousands of records, loading them all into a Collection will exhaust memory. Dispatch a single job that re-queries by IDs instead:
->action(function (Collection $records): void {
$ids = $records->modelKeys();
ProcessBulkApprovalJob::dispatch($ids, auth()->id());
Notification::make()
->title('Bulk approval queued')
->body(count($ids) . ' records will be processed in the background.')
->success()
->send();
})
Inside ProcessBulkApprovalJob, chunk the IDs:
public function handle(): void
{
$approver = User::findOrFail($this->approverId);
collect($this->ids)->chunk(100)->each(function ($chunk) use ($approver) {
Application::whereIn('id', $chunk)
->get()
->filter(fn ($r) => $approver->can('approve', $r))
->each(fn ($r) => $r->markApproved($approver));
});
}
Key Takeaways
- Always filter records by policy inside
action()— theauthorize()callback only gates visibility. - Use
->form([...])on aBulkActionto collect structured input before execution. - For large selections, pass only IDs to a queued job and re-query with chunking.
deselectRecordsAfterCompletion()prevents stale checkbox state after async operations.- Send contextual
Notificationfeedback so users know when partial authorization reduced the scope.