Filament bulk action with confirmation and queued batch
A Filament 4 bulk action that asks for confirmation, then fans the selected rows into a queued job batch and notifies the user when it finishes.
FilamentQueue jobsfilamentbulk-actionqueuebatch
Code
<?php
namespace App\Filament\Resources\Contacts;
use App\Jobs\UnsubscribeContactsChunk;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Bus;
class ContactsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')->searchable(),
TextColumn::make('status')->badge(),
])
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('unsubscribe')
->icon(Heroicon::OutlinedNoSymbol)
->color('danger')
->requiresConfirmation()
->modalHeading('Unsubscribe selected contacts?')
->modalDescription('They will stop receiving all campaigns. This can take a moment for large selections.')
->schema([Textarea::make('reason')->maxLength(200)])
->deselectRecordsAfterCompletion()
->action(function (Collection $records, array $data): void {
$userId = auth()->id();
// One small job per 500 ids keeps each job short and retryable.
$jobs = collect($records->modelKeys())
->chunk(500)
->map(fn ($ids) => new UnsubscribeContactsChunk($ids->values()->all(), $data['reason'] ?? null))
->all();
Bus::batch($jobs)
->name('contacts-unsubscribe')
->allowFailures()
->then(fn () => Notification::make()
->title('Contacts unsubscribed')
->success()
->sendToDatabase(\App\Models\User::find($userId)))
->dispatch();
Notification::make()->title('Queued for processing')->body($records->count().' contacts')->info()->send();
}),
]),
]);
}
}
<?php
namespace App\Jobs;
use App\Models\Contact;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class UnsubscribeContactsChunk implements ShouldQueue
{
use Batchable, Queueable;
public int $tries = 3;
/** @param list<int> $ids */
public function __construct(public array $ids, public ?string $reason = null) {}
public function handle(): void
{
// A cancelled batch (for example from a dashboard) should stop quietly.
if ($this->batch()?->cancelled()) {
return;
}
Contact::query()
->whereKey($this->ids)
->where('status', '!=', 'unsubscribed') // idempotent on retry
->update([
'status' => 'unsubscribed',
'unsubscribed_at' => now(),
'unsubscribe_reason' => $this->reason,
]);
}
}
Needs: filament/filament@^4.0
How it works
What it solves
Applying a change to thousands of selected rows inside the web request will time out. This pattern keeps the Filament bulk-action UX (select, confirm, done) but moves the real work to the queue, split into small retryable chunks.
How it works
requiresConfirmation() opens a modal with a custom heading and description, and schema() adds an optional reason field whose value arrives in $data. The action closure collects the selected primary keys, splits them into chunks of 500 and wraps each chunk in a UnsubscribeContactsChunk job. A collection chunk-then-map builds the job list.
Bus::batch() dispatches them together. allowFailures() lets other chunks finish if one fails, and then() runs only after every job succeeds, sending a database notification to the user who started it. The user instantly gets an "info" toast, so the interface never feels stuck.
Inside the job, Batchable provides batch()?->cancelled() for a clean early exit, and the where('status', '!=', ...) guard makes a retry harmless.
Adapting it
Swap the model and the update payload. Change the chunk size according to how heavy each row is. For a destructive action, keep color('danger') and a precise modal text.
Pitfalls
- Batches need the
job_batchestable: runphp artisan make:queue-batches-tableand migrate. - A queue worker must be running, or nothing happens after the toast.
- Closures given to
then()are serialized, so capture ids (as done here) rather than large objects.
How to use it
- Run
php artisan make:queue-batches-table && php artisan migrate, and start a worker withphp artisan queue:work. - Add the job to
app/Jobs/and adjust theContactupdate fields to your schema. - Call
ContactsTable::configure($table)from your resource'stable()method (or paste the bulk action into your existing toolbar). - Select rows in the list, choose Unsubscribe, confirm in the modal.
- Watch
php artisan queue:work: one job per 500 rows, then the user receives a notification.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.