Filament v3 Table Bulk Actions: Custom Confirmation, Progress Feedback, and Job Dispatch
#filament #laravel #queues #livewire

Filament v3 Table Bulk Actions: Custom Confirmation, Progress Feedback, and Job Dispatch

3 min read Mohamed Said Mohamed Said

The Problem With Default Bulk Actions

Filament ships with DeleteBulkAction and a handful of helpers, but real applications need bulk actions that:

  • Ask for extra input before running (e.g. a reason, a target status)
  • Dispatch work to a queue instead of blocking the request
  • Show the user meaningful feedback when the batch finishes

This article walks through all three concerns with production-ready code.


1. Custom Confirmation Modal With Extra Fields

The requiresConfirmation() helper gives you a yes/no dialog. For richer input, swap to form() on the action:

BulkAction::make('archive')
    ->label('Archive Selected')
    ->icon('heroicon-o-archive-box')
    ->form([
        Textarea::make('reason')
            ->label('Archive reason')
            ->required()
            ->maxLength(500),
    ])
    ->action(function (Collection $records, array $data): void {
        ArchiveRecordsJob::dispatch(
            $records->modelKeys(),
            $data['reason'],
            auth()->id(),
        );
    })
    ->deselectRecordsAfterCompletion()
    ->successNotificationTitle('Archive queued')
    ->color('warning');

The form() closure receives validated $data alongside $records. Filament renders the fields inside the confirmation modal automatically — no custom Livewire component needed.


2. Dispatching a Laravel Job Batch

Passing raw model keys (not Eloquent models) to the job keeps the serialized payload small and avoids stale model state:

// app/Jobs/ArchiveRecordsJob.php
final class ArchiveRecordsJob implements ShouldQueue
{
    use Queueable, Dispatchable, InteractsWithQueue, SerializesModels;

    public function __construct(
        private readonly array $ids,
        private readonly string $reason,
        private readonly int $actorId,
    ) {}

    public function handle(): void
    {
        Post::whereIn('id', $this->ids)
            ->lazyById(200)
            ->each(function (Post $post): void {
                $post->archive($this->reason, $this->actorId);
            });
    }
}

For very large selections, split into a job batch so each chunk runs independently and failures are isolated:

->action(function (Collection $records, array $data): void {
    $chunks = array_chunk($records->modelKeys(), 100);

    $batch = Bus::batch(
        collect($chunks)->map(
            fn (array $ids) => new ArchiveRecordsJob($ids, $data['reason'], auth()->id())
        )->all()
    )
    ->name('archive-posts-' . now()->timestamp)
    ->allowFailures()
    ->dispatch();

    // Persist batch ID so the UI can poll it
    session()->put('archive_batch_id', $batch->id);
})

3. Real-Time Progress Feedback

Store the batch ID in the session (or a DB record keyed to the user) and expose a Livewire polling component in your Filament page footer via a render hook:

// AppServiceProvider::boot()
Filament::registerRenderHook(
    PanelsRenderHook::BODY_END,
    fn (): View => view('filament.batch-progress'),
);
{{-- resources/views/filament/batch-progress.blade.php --}}
@if(session('archive_batch_id'))
<div wire:poll.2s="checkBatch">
    @livewire('batch-progress-indicator', [
        'batchId' => session('archive_batch_id')
    ])
</div>
@endif

The Livewire component calls Bus::findBatch($this->batchId) and exposes $batch->progress() (0–100) and $batch->finished(). When finished, dispatch a browser event to trigger a Filament notification and clear the session key.


4. Safety Details Worth Getting Right

Authorization — always gate the action:

->authorize(fn (): bool => auth()->user()->can('archive', Post::class))

Chunk sizelazyById inside the job prevents loading thousands of models into memory at once. Tune the chunk size to your row width.

allowFailures() — without this, a single failing job cancels the entire batch. For archiving, partial success is usually acceptable; log failures via ->catch() on the batch.

Idempotency — if a job retries, re-archiving an already-archived post should be a no-op. Guard with a status check at the top of handle().


Takeaways

  • Use form() on BulkAction for confirmation modals that collect extra input before dispatch.
  • Pass model keys (not models) to queued jobs to keep payloads lean.
  • Split large selections into a Bus::batch() with allowFailures() for resilient processing.
  • Persist the batch ID and poll Bus::findBatch() in a Livewire component for live progress.
  • Always authorize bulk actions explicitly; Filament does not infer policy checks automatically.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I access the form data inside the bulk action's `action()` closure?
Yes. When you define a `form()` on a BulkAction, Filament injects a validated `array $data` parameter alongside `Collection $records` in the `action()` closure. The keys match the field names you declared in the form.
Q02 How do I prevent the bulk action from timing out on very large selections?
Dispatch a queued job (or a Bus batch of jobs) from the action closure instead of processing records inline. The HTTP request returns immediately after dispatch, and the heavy work runs on your queue workers. Use `lazyById()` inside the job to avoid loading all records into memory at once.
Q03 Does `deselectRecordsAfterCompletion()` work when the action dispatches a job?
Yes. It deselects the checkboxes on the client side as soon as the action closure returns, regardless of whether the actual work is synchronous or queued. It is purely a UI concern.

Continue reading

More Articles

View all