Bulk Actions
Run one PHP handler over every row the user selected, with per-row authorization
Bulk actions let a user select rows and run a single PHP handler over all of them. Declare them in
configureBulkActions(); the bundle adds the checkbox selection, the Bulk actions button above
the table, and the endpoint that executes the handler.
Declaring Bulk Actions
use App\Entity\Order;
use App\Service\OrderApprover;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Column\NumberColumn;
use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Enum\Icon;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\BulkAction;
use Pentiminax\UX\DataTables\Model\BulkActions;
use Pentiminax\UX\DataTables\Model\DataTable;
use Pentiminax\UX\DataTables\Mutation\BulkActionContext;
use Pentiminax\UX\DataTables\Mutation\BulkRecords;
#[AsDataTable(Order::class)]
final class OrdersDataTable extends AbstractDataTable
{
public function __construct(private readonly OrderApprover $approver)
{
}
public function configureDataTable(DataTable $table): DataTable
{
return $table->serverSide();
}
public function configureColumns(): iterable
{
yield NumberColumn::new('id', 'ID');
yield TextColumn::new('reference', 'Reference');
yield TextColumn::new('status', 'Status');
}
public function configureBulkActions(BulkActions $actions): BulkActions
{
return $actions->add(
BulkAction::new('approve', 'Approve')
->icon(Icon::Check)
->askConfirmation('Approve {count} orders?')
->setPermission('ORDER_APPROVE', static fn (Order $order): Order => $order)
->successMessage('Orders approved.')
->handler(function (BulkRecords $records, BulkActionContext $context): void {
foreach ($records as $order) {
$this->approver->approve($order);
}
})
);
}
}
The table is a service, so the handler closure captures whatever the table injected. The bundle never calls an application URL: the handler runs inside the bundle’s endpoint.
What the User Sees
A Bulk actions button sits in the table’s top-right layout cell. It is always rendered and stays disabled until a row is checked; clicking it opens a dropdown listing the configured actions.
Below the toolbar row, a full-width band shows the current selection — 5 records selected on the
left, Select all 50 and Deselect all on the right — and, after a run, the processed and skipped
counts. Move the button with BulkActions::position(); the band follows it.
Declaring a bulk action also enables the Select extension in multi style with a checkbox column
and a header checkbox, unless you configured one yourself. Configuring SelectStyle::SINGLE
alongside bulk actions throws — a bulk action over one row is a row action.
What the Handler Receives
BulkRecords yields the selected entities lazily, chunk by chunk, so a selection of 50,000 rows
never materializes as 50,000 objects. It is Countable and IteratorAggregate, plus map(),
filter(), first(), isEmpty() and toArray().
BulkActionContext carries entityClass, dataTableClass, action, objectManager,
selectedCount, and the two running counters above.
BulkAction API
| Method | Description |
|---|---|
`BulkAction::new($name, $label = '', $className = '')` | Create an action; $name must be unique within the table |
`label($label)` | Override the button label |
`setClassName($className)` | CSS classes for the button |
`icon($icon)` | A Lucide Icon case or a raw icon class string |
`askConfirmation($message, $buttonLabel = null)` | Confirm through the table modal adapter before running; {count} is replaced client-side |
`successMessage($message)` | Message shown in the bar instead of the processed count |
`setPermission($attribute, $subjectResolver = null)` | Static permission, or a per-row one when a resolver is given |
`chunk($size)` | Rows loaded and flushed per batch (default 250) |
`deselectRecordsAfterCompletion($deselect = true)` | Clear the selection once the run succeeds (default true) |
`handler($handler)` | Required closure receiving BulkRecords and BulkActionContext |
BulkActions API
| Method | Description |
|---|---|
`add($action)` | Add an action; a duplicate name throws |
`remove($name)` | Remove an action by name |
`selectCurrentPageOnly($only = true)` | Forbid selecting every matching row; the server rejects such a request too |
`setIdField($field)` | Override the row identifier written as DT_RowId; Doctrine identifiers are detected automatically, otherwise the default is id |
`position($layoutPosition)` | DataTables layout cell hosting the button (default topEnd) |
Selecting Every Matching Row
By default the bar offers to extend the selection to every row matching the current search, filters and ordering — not just the current page. The browser then sends the DataTables request it was displaying, and the server re-runs it without pagination to resolve the identifiers, minus the rows unchecked afterwards.
This requires a data provider implementing IdentifierCollectingDataProviderInterface;
DoctrineDataProvider does. The provider is asked for the field the selection speaks in — the one
written as DT_RowId, which setIdField() may point away from the primary key — so a custom
implementation must answer with values from that field. Any other provider answers 400 for a select-all, and so does a table
declaring selectCurrentPageOnly().
Authorization
Every bulk action goes through Permission::DT_EXECUTE_ACTION, exactly like a row action:
- a static permission (
setPermission('ORDER_APPROVE')) is checked twice — once while rendering, so a denied action is never drawn, and once in the endpoint before any entity is loaded; - a per-row permission (
setPermission('ORDER_APPROVE', fn (Order $o) => $o)) is checked for each loaded entity. Denied rows are excluded from the handler and counted as skipped; the run is not aborted.
use Pentiminax\UX\DataTables\Model\BulkAction;
use Pentiminax\UX\DataTables\Security\ActionPermissionContext;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class OrderBulkVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
return $subject instanceof ActionPermissionContext && $subject->action instanceof BulkAction;
}
}
Ajax Endpoints
POST /datatables/ajax/bulk — body: dataTable (the signed action token), action (the action
name), ids, allMatching, deselectedIds, query (the displayed DataTables request, only for a
select-all). The CSRF token travels in the X-CSRF-Token header.
The response is {"success": true, "processed": n, "skipped": m}.
Translations
The chrome is translated server-side from the DataTables catalog: bulk.bar.trigger (the button
label), bulk.bar.selected, bulk.bar.selectAllMatching, bulk.bar.allMatchingSelected,
bulk.bar.clear, bulk.bar.confirm, bulk.bar.cancel, bulk.bar.processed and
bulk.bar.skipped. The count-bearing ones accept a {count} placeholder.