Row and Bulk Actions
An action is something an admin can do to one record from its row's menu, or to every checked record of a list from the selection bar: delete, ban, publish, export. Bonfire runs every action through one endpoint that checks the permission and the CSRF token, asks for confirmation when the action wants it, skips records the action may not touch, and runs large batches in the background when a queue is available.
The Users list (row and bulk delete) and the Logs list (bulk delete) are built this way.
Writing An Action
An action is one class that extends Bonfire\Actions\Action:
use Bonfire\Actions\Action;
use Bonfire\Actions\ActionContext;
use Bonfire\Actions\ActionResult;
use Bonfire\Actions\Confirm;
use Bonfire\Actions\Placement;
use Bonfire\Users\User;
class BanUsers extends Action
{
public string $label = 'Ban';
public string $icon = 'fas fa-ban';
public string $permission = 'users.manage-admins';
public Placement $placement = Placement::Both;
public bool $danger = true;
public function confirm(): ?Confirm
{
return Confirm::make('Ban {count} users?')
->body('They are signed out and cannot sign in until unbanned.')
->button('Ban');
}
public function allows(User $actor, object $record): bool
{
return $actor->id !== $record->id;
}
public function handle(ActionContext $context): ActionResult
{
foreach ($context->entities() as $user) {
$user->ban();
}
return ActionResult::message('{count} users banned.');
}
}
label and icon (a Font Awesome class) are shown on the action's button.
permission is needed to see and run the action. Leave it empty for anyone who can reach the admin area. Add a new
permission to your app's AuthGroups config.
placement is where the action is offered: Placement::Row in each row's menu, Placement::Bulk in the selection
bar, or Placement::Both.
danger styles the action as destructive, in red.
confirm() returns what to ask before the action runs, or null to run straight away. {count} in the title
becomes the number of records. ->typeToConfirm() makes the admin type that number before a large destructive run.
allows() decides, per record, whether the action may touch it. Records it rejects, and selected records that no longer exist, are skipped and reported ("3 users banned. Skipped 1.") rather than failing the batch.
handle() does the work and returns an ActionResult::message(), or ActionResult::error() when it fails. {count}
in the message becomes the number of records handled. A thrown exception is logged, and the admin sees an
"Unknown error" message.
The action's name in its URL comes from the class name: BanUsers is ban-users.
Warning
handle() may run in a queue worker, where there is no request, session or signed-in user. Use only what the
context gives you: $context->entities() (the records, one at a time), $context->chunks() (the records, a chunk
of up to 100 at a time) and $context->actor() (the admin who started the run).
Deleting
Bonfire\Actions\DeleteAction is a ready-made delete: a red Delete that asks first and deletes a chunk at a time.
Extend it with how your records are deleted, and usually your own permission and wording:
use Bonfire\Actions\DeleteAction;
class DeletePhotos extends DeleteAction
{
public string $permission = 'photos.delete';
protected function delete(array $records): void
{
model(PhotoModel::class)->delete(array_map(static fn ($photo) => $photo->id, $records));
}
}
Override confirm() for your own question and deleted() for the message once it's done. See
Bonfire\Users\Actions\DeleteUsers for a complete example.
Registering Actions
Actions belong to a named list. Register them in your module's initAdmin(), saying where the list's records come
from:
use Bonfire\Actions\ModelSource;
public function initAdmin()
{
service('actions')->for('photos')
->records(new ModelSource(PhotoModel::class))
->add(new DeletePhotos());
}
ModelSource finds records by primary key through a model. For records that don't live in a model, implement
Bonfire\Actions\RecordSource, which has one method, find(array $ids): array; the Logs list's LogStore is an
example.
Any module may add actions to any list, including Bonfire's own. These lists exist:
| List | Records |
|---|---|
users |
Bonfire\Users\User |
logs |
Objects whose id is a log file's name |
service('actions')->for('users')->add(new BanUsers());
Showing Actions In A List
Two view components render a list's actions for the signed-in admin, leaving out those they lack the permission for.
<x-row-actions> renders a row's menu. Pass the list and the record's ID. Anything in its slot, like an Edit link,
comes first:
<td class="text-end">
<x-row-actions list="photos" id="<?= $photo->id ?>">
<li><a href="<?= url_to('photo-edit', $photo->id) ?>" class="dropdown-item">Edit</a></li>
</x-row-actions>
</td>
The row menu shows only the actions the admin may run on that record: their permission, and their allows() for
it. To ask allows(), the component loads the record through the list's record source, one lookup per row.
<x-bulk-actions> goes inside <x-selection-bar>, in a form whose row checkboxes are named selects[] with the
record's ID as their value:
<form method="post">
<?= csrf_field() ?>
<table class="table bf-table">
<!-- ... -->
<td class="bf-select-cell">
<input type="checkbox" name="selects[]" value="<?= $photo->id ?>" class="form-check-input">
</td>
<!-- ... -->
</table>
<x-selection-bar>
<x-bulk-actions list="photos" />
</x-selection-bar>
</form>
Confirmation
With JavaScript, an action that asks first opens <x-confirm-dialog>, a dialog that is in the Admin layout once. It
shows the title with the count filled in, the body, and a red button for dangerous actions.
Without JavaScript, the action's button posts straight to the endpoint, which answers with a confirmation page holding the same question and a button to go ahead. Either way, an action never runs until it's confirmed.
The dialog also handles two other kinds of confirmation, so they look the same:
- htmx's
hx-confirmattribute:<a hx-get="..." hx-confirm="Remove the image?">. - A
data-confirmattribute on any link or submit button: the link is followed, or the form submitted, once the admin confirms.data-confirm-buttonsets the button's label anddata-confirm-danger="1"makes it red.
<a href="<?= url_to('photo-purge', $photo->id) ?>" data-confirm="Purge this photo?" data-confirm-danger="1">Purge</a>
Running In The Background
Each action decides whether its runs are queued with its $queue property:
Queue::Auto(the default) runs in the request, unless it has more records than$queueAbove(500 by default).Queue::Alwaysalways queues.Queue::Neveralways runs in the request.
use Bonfire\Actions\Queue;
class ExportPhotos extends Action
{
public Queue $queue = Queue::Always;
// ...
}
Queued runs need the codeigniter4/queue package, which Bonfire suggests but doesn't require:
composer require codeigniter4/queue
php spark migrate --all
php spark queue:work bonfire-actions
Bonfire registers its bonfire-action job with the queue for you. Keep a queue:work bonfire-actions worker running,
or queued runs will wait forever.
Without the package, Queue::Auto runs in the request, a chunk at a time, and Queue::Always refuses with a message
explaining what's missing.
A queued run is recorded in the action_runs table with its totals and status. The admin who started it sees a toast
that shows its progress, updated every two seconds, and its message once it ends.