Laravel Filament v3 Custom Actions: Bulk, Table, and Header Actions Done Right
#filament #laravel #filament-actions #php

Laravel Filament v3 Custom Actions: Bulk, Table, and Header Actions Done Right

3 min read Mohamed Said Mohamed Said

Filament v3 Custom Actions: Bulk, Table, and Header Actions Done Right

Filament ships with a generous set of built-in actions, but production apps quickly outgrow them. You need bulk operations that dispatch jobs, header actions that resolve services from the container, and row-level actions that enforce policy before they run. This article shows concrete patterns for each.


1. Extracting Actions Into Dedicated Classes

Inlining action logic inside getTableActions() works for demos. For anything real, extract to a class:

// app/Filament/Actions/ArchiveOrderAction.php
namespace App\Filament\Actions;

use App\Jobs\ArchiveOrderJob;
use App\Models\Order;
use Filament\Tables\Actions\Action;

class ArchiveOrderAction
{
    public static function make(): Action
    {
        return Action::make('archive')
            ->label('Archive')
            ->icon('heroicon-o-archive-box')
            ->requiresConfirmation()
            ->authorize(fn (Order $record) => auth()->user()->can('archive', $record))
            ->action(function (Order $record): void {
                dispatch(new ArchiveOrderJob($record->id));
            })
            ->successNotificationTitle('Order queued for archiving');
    }
}

Register it in your resource:

public function getTableActions(): array
{
    return [
        ArchiveOrderAction::make(),
        EditAction::make(),
    ];
}

The authorize() callback receives the resolved model, so policy checks stay close to the action without touching the resource.


2. Bulk Actions That Dispatch Batched Jobs

Bulk actions receive a Collection of selected records. Pair them with Bus::batch() for reliable fan-out:

use Filament\Tables\Actions\BulkAction;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Bus;

BulkAction::make('reprocess')
    ->label('Reprocess Selected')
    ->icon('heroicon-o-arrow-path')
    ->requiresConfirmation()
    ->deselectRecordsAfterCompletion()
    ->action(function (Collection $records): void {
        $jobs = $records->map(
            fn ($record) => new ReprocessOrderJob($record->id)
        )->all();

        Bus::batch($jobs)
            ->name('reprocess-orders')
            ->allowFailures()
            ->dispatch();
    });

allowFailures() means one bad record does not abort the entire batch — critical when operating on hundreds of rows.


3. Header Actions With Service Injection

Header actions live outside the table loop, making them ideal for operations that need infrastructure services:

use Filament\Actions\Action;
use App\Services\ReportExporter;

protected function getHeaderActions(): array
{
    return [
        Action::make('export')
            ->label('Export CSV')
            ->action(function (ReportExporter $exporter): \Symfony\Component\HttpFoundation\StreamedResponse {
                return $exporter->streamCsv(
                    $this->getTableQuery()->get()
                );
            }),
    ];
}

Filament resolves the ReportExporter from the service container automatically via the action() closure's type-hint — no manual app() call needed.


4. Guarding Actions With Visibility vs. Authorization

Two separate concerns are often conflated:

  • visible() — controls whether the action renders at all (UI concern).
  • authorize() — throws a 403 if the action is invoked without permission (security concern).

Always set both:

Action::make('refund')
    ->visible(fn () => auth()->user()->hasRole('billing'))
    ->authorize(fn (Order $record) => auth()->user()->can('refund', $record))
    ->action(fn (Order $record) => $record->issueRefund());

Relying only on visible() is a security hole — a crafty user can POST the action directly.


5. Passing Extra Data Through mountUsing and form

When an action needs user input before executing, combine form() with mountUsing() to pre-populate fields:

Action::make('reschedule')
    ->form([
        DateTimePicker::make('scheduled_at')->required(),
    ])
    ->mountUsing(fn (ComponentContainer $form, Order $record) =>
        $form->fill(['scheduled_at' => $record->scheduled_at])
    )
    ->action(fn (array $data, Order $record) =>
        $record->update(['scheduled_at' => $data['scheduled_at']])
    );

Takeaways

  • Extract actions to dedicated classes early; inline closures do not scale.
  • Use Bus::batch() inside bulk actions for reliable, observable fan-out.
  • Header action closures support full container injection via type-hints.
  • Always pair visible() with authorize() — they are not interchangeable.
  • mountUsing() lets you pre-fill action forms from the current record state.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I reuse a custom action class across multiple Filament resources?
Yes. Because the action is a plain static factory method returning an `Action` instance, you can call `ArchiveOrderAction::make()` from any resource's `getTableActions()` or `getHeaderActions()` without duplication.
Q02 How do I test that an action dispatches the correct job?
Use `Queue::fake()` before triggering the action in your Pest test, then assert with `Queue::assertPushed(ReprocessOrderJob::class)`. Filament's `livewire()->callTableAction()` helper fires the full action lifecycle including your closure.
Q03 What is the difference between `action()` and `successRedirectUrl()` in a header action?
`action()` is the handler closure that runs your business logic. `successRedirectUrl()` is an optional follow-up that redirects the user after a successful action — useful after creating a related resource or triggering an export.

Continue reading

More Articles

View all