Filament Bulk Actions: Tell Users Exactly How Many Records Were Skipped (and Why)

2026-04-28 Filament v4/5

You select 10 users, hit "Delete selected", and Filament shows a green "Done" notification. But three of them were admins your policy quietly refused to delete. The user has no idea why the table still shows those rows. Let's fix that.

Filament has four pieces that make this work together:

  • authorizeIndividualRecords() filters out records the policy denies,
  • reportBulkProcessingFailure() lets you log per-record failures inside the action
  • successNotificationTitle() / failureNotificationTitle() give you $successCount and $totalCount for an honest summary.

Also, the DenyResponse object on the policy explains why records were skipped.


The Policy

The policy method is the single source of truth for who can be deleted. Returning true allows it, returning a DenyResponse rejects it with a message that gets surfaced in the notification.

app/Policies/UserPolicy.php

use App\Models\User;
use Filament\Support\Authorization\DenyResponse;
use Illuminate\Auth\Access\Response;
 
public function delete(User $user, User $model): bool|Response
{
if ($user->id === $model->id) {
return DenyResponse::make('cannot_delete_self', message: function (int $failureCount, int $totalCount): string {
return 'You cannot delete your own account.';
});
}
 
if (! $model->is_admin) {
return true;
}
 
return DenyResponse::make('cannot_delete_admin', message: function (int $failureCount, int $totalCount): string {
if (($failureCount === 1) && ($totalCount === 1)) {
return 'You cannot delete an admin user.';
}
 
if ($failureCount === $totalCount) {
return 'All selected users were admins and were skipped.';
}
 
if ($failureCount === 1) {
return 'One of the selected users was an admin and was skipped.';
}
 
return "{$failureCount} of the selected users were admins and were skipped.";
});
}

The first argument to DenyResponse::make() is a key. If 5 records all hit the same cannot_delete_admin deny, Filament groups them into a single message and gives you $failureCount = 5, $totalCount = 5. If you have multiple deny reasons (admin, self), each gets its own message in the notification.

Don't forget to register the policy if you're not using auto-discovery.


The Bulk Action

app/Filament/Resources/Users/Tables/UsersTable.php

use App\Models\User;
use Filament\Actions\BulkAction;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
 
return $table
->columns([
TextColumn::make('name')->searchable(),
TextColumn::make('email')->searchable(),
IconColumn::make('is_admin')->boolean()->label('Admin'),
])
->toolbarActions([
BulkAction::make('delete')
->label('Delete selected')
->icon(Heroicon::OutlinedTrash)
->color('danger')
->requiresConfirmation()
->authorizeIndividualRecords('delete')
->action(function (BulkAction $action, Collection $records) {
$records->each(function (Model $record) use ($action) {
$record->delete() || $action->reportBulkProcessingFailure(
'deletion_failed',
message: function (int $failureCount, int $totalCount): string {
if ($failureCount === $totalCount) {
return 'All selected users failed to delete.';
}
 
if ($failureCount === 1) {
return 'One of the selected users failed to delete.';
}
 
return "{$failureCount} of the selected users failed to delete.";
},
);
});
})
->successNotificationTitle('Users deleted')
->failureNotificationTitle(function (int $successCount, int $totalCount): string {
if ($successCount) {
return "{$successCount} of {$totalCount} users deleted";
}
 
return 'No users were deleted';
})
->deselectRecordsAfterCompletion(),
]);


How the Pieces Fit

  1. authorizeIndividualRecords('delete') runs the policy delete method against every selected record before the closure executes. Records that return DenyResponse are stripped out of $records, and their deny messages are collected for the notification.
  2. Inside action(), you only see records that already passed authorization. If $record->delete() still returns false (a model event aborted it, for example), reportBulkProcessingFailure() records that failure under the 'deletion_failed' key. The || short-circuit means the failure is only reported when delete fails.
  3. After the loop, Filament tallies up: $successCount is records that finished without any reported failure, $totalCount is everything the user originally selected (including the ones authorization filtered out). If $successCount === $totalCount, the success title fires. Otherwise the failure title fires, and any deny/processing messages are listed in the notification body.

The notification ends up looking like the screenshot at the top: a clear "8 of 10 users deleted" headline, with one line per skip reason underneath.


Multi-Reason Notifications

The reason DenyResponse takes a key as the first argument is so you can mix multiple skip reasons in one action. In the policy above, deleting yourself uses 'cannot_delete_self' and deleting an admin uses 'cannot_delete_admin'. If a user selects themselves and two admins, the notification shows both messages independently:

0 of 3 users deleted You cannot delete your own account. 2 of the selected users were admins and were skipped.

Same mechanism works for processing failures. Pass different keys to reportBulkProcessingFailure() and you can distinguish "couldn't delete because of a foreign key" from "couldn't delete because the API was down".


If your action doesn't need authorization but still has runtime reasons records can fail (network errors, validation, locked rows), you can skip authorizeIndividualRecords() and use only reportBulkProcessingFailure(). The notification mechanics are identical.

A few of our Premium Examples: