DEV3 Technologies home

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();
                        }),
                ]),
            ]);
    }
}

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_batches table: run php artisan make:queue-batches-table and 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

  1. Run php artisan make:queue-batches-table && php artisan migrate, and start a worker with php artisan queue:work.
  2. Add the job to app/Jobs/ and adjust the Contact update fields to your schema.
  3. Call ContactsTable::configure($table) from your resource's table() method (or paste the bulk action into your existing toolbar).
  4. Select rows in the list, choose Unsubscribe, confirm in the modal.
  5. 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.

Related snippets