Filament approve and reject actions with reason
Reusable Filament 4 table actions that approve or reject pending records, require a reason on rejection and guard the state transition.
FilamentPermissionsfilamentworkflowapprovalactions
Code
<?php
namespace App\Filament\Actions;
use App\Models\Concerns\Approvable;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Model;
/** Use in recordActions([...]) of any table whose model uses the Approvable trait. */
class ApprovalActions
{
public static function approve(): Action
{
return Action::make('approve')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->visible(fn (Model $record): bool => $record->isPending() && auth()->user()->can('approve', $record))
->action(function (Model $record): void {
$record->approve(auth()->user());
Notification::make()->title('Approved')->success()->send();
});
}
public static function reject(): Action
{
return Action::make('reject')
->icon('heroicon-o-x-circle')
->color('danger')
->modalHeading('Reject this item')
->schema([
// The reason is stored and shown to the author, so it is mandatory.
Textarea::make('reason')->required()->minLength(10)->maxLength(500),
])
->visible(fn (Model $record): bool => $record->isPending() && auth()->user()->can('approve', $record))
->action(function (Model $record, array $data): void {
$record->reject(auth()->user(), $data['reason']);
Notification::make()->title('Rejected')->warning()->send();
});
}
}
<?php
namespace App\Models\Concerns;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/** Expects columns: status, reviewed_by, reviewed_at, rejection_reason. */
trait Approvable
{
public function isPending(): bool
{
return $this->status === 'pending';
}
public function approve(User $reviewer): void
{
$this->transition('approved', $reviewer);
}
public function reject(User $reviewer, string $reason): void
{
$this->transition('rejected', $reviewer, $reason);
}
private function transition(string $to, User $reviewer, ?string $reason = null): void
{
DB::transaction(function () use ($to, $reviewer, $reason) {
// Conditional update: two reviewers clicking at once cannot both win.
$changed = static::query()
->whereKey($this->getKey())
->where('status', 'pending')
->update([
'status' => $to,
'reviewed_by' => $reviewer->getKey(),
'reviewed_at' => now(),
'rejection_reason' => $reason,
]);
if ($changed === 0) {
throw new RuntimeException('This item has already been reviewed.');
}
$this->refresh();
});
}
}
Needs: filament/filament@^4.0
How it works
What it solves
Proposals, listings, documents and payouts often need a human sign-off. The interface part is simple, two buttons, but the details matter: a rejection must carry a reason, buttons must disappear when an item is no longer pending, and two reviewers must not both succeed on the same row.
How it works
ApprovalActions exposes two static builders that return Filament Action objects, so any resource can drop them into recordActions(). Both use visible() to show only for pending records and only when the policy ability approve passes. Approve asks for a simple confirmation. Reject opens a modal with a required, length-limited reason field, and the text arrives in $data.
The state change lives in the Approvable trait rather than in the action. transition() runs inside a transaction and uses a conditional update, where status = pending. The affected-row count tells whether this call won; zero means someone else already decided, and an exception is thrown, which Filament shows as an error. That makes the workflow safe without explicit locks.
After a successful change, the model is refreshed so the table and any following notification see the new status, reviewer and timestamps.
Adapting it
Add more states by extending transition(). Send mail or database notifications to the author after approve() or reject(). For two-step approval, add an approver_level check inside the policy.
Pitfalls
- The visibility closure is not security. Always enforce the same rule in the policy that the action and any API endpoint use.
- Throwing a bare
RuntimeExceptionshows a generic error; catch it in the action and send a friendly notification if you prefer. - Log the reason in an audit table if rejections must be traceable after later edits.
How to use it
- Add
status(defaultpending),reviewed_by,reviewed_atandrejection_reasoncolumns to your table. - Add
use Approvable;to the model and create anapproveability in its policy. - Put the actions in
recordActions([ApprovalActions::approve(), ApprovalActions::reject()]). - Open the list as a reviewer: pending rows show both buttons.
- Notify the author after a decision by adding a notification call in the action closures.
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.