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. After a run it shows the processed and
skipped counts, or, when the run fails, the endpoint’s message (bulk.bar.failed when it gives
none); the next selection change brings the count back. 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 the table configures one itself. A bundle-wide
data_tables.extensions.select default does not count as the table’s own choice: it is replaced
for tables that declare bulk actions, unless configureDataTable() changed it in place. Configuring SelectStyle::SINGLE on the table alongside bulk
actions throws — a bulk action over one row is a row action.
When the current user may run none of the declared actions, the button disappears, and so do the checkboxes the bundle added for them. A Select extension the table configured itself stays.
What the Handler Receives
BulkRecords yields the selected entities lazily, one chunk per query, and the runner flushes each
chunk before loading the next, so a selection of 50,000 rows never runs as a single query or a
single flush. Doctrine still keeps every loaded entity managed until the request ends, so memory
grows with the selection whatever the chunk size: only the size of the selections you allow bounds
it, through selectCurrentPageOnly() or the table’s own scope. 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.
Skipping a row from the handler
When a rule is about the data rather than the user, decline the row with $context->skip(). It moves
the entity you were just handed from the processed to the skipped tally, so the result the user sees
matches what the handler did:
use App\Enum\OrderStatus;
use Pentiminax\UX\DataTables\Model\BulkAction;
use Pentiminax\UX\DataTables\Mutation\BulkActionContext;
use Pentiminax\UX\DataTables\Mutation\BulkRecords;
BulkAction::new('ship', 'Mark as shipped')
->handler(static function (BulkRecords $records, BulkActionContext $context): void {
foreach ($records as $order) {
if (OrderStatus::Paid !== $order->status) {
$context->skip();
continue;
}
$order->status = OrderStatus::Shipped;
}
});
Call it once per entity, while iterating. It does not undo changes already made to the entity. Use a per-row permission instead when the rule is about what the current user may do.
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. A table that calls highlightUpdates() writes
DT_RowId from its idField instead. Either way, that field must be a mapped, unique Doctrine field:
the rows are loaded back by its values, so a value shared by several rows would pull in every one of
them, including rows outside the current search and filters. 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;
}
}
Table scope
An explicit selection is checked against the table’s permanent scope, which is what
customizeQueryBuilder() builds, before any entity is loaded. Identifiers outside it never reach
the handler and are counted as skipped. Interactive search, ordering, and filters do not apply: a
row the user filtered away is still theirs to act on.
This is what makes a tenant-scoped table safe against forged identifiers, with or without a per-row resolver:
protected function customizeQueryBuilder(QueryBuilder $qb, DataTableRequest $request): QueryBuilder
{
return $qb
->andWhere('e.owner = :owner')
->setParameter('owner', $this->security->getUser());
}
“Select all matching” resolves its identifiers through the same query, so both selection modes share one boundary. Scope on the Security user rather than on a request parameter: request parameters are client-controlled, so they are a convenience, not a boundary.
A data provider you build yourself in createDataProvider() must receive the scope through its
configureBaseQueryBuilder argument; without it the check covers the whole entity table.
A custom data provider that does not implement ScopedIdentifierProviderInterface, and an API
Platform backed table, keep the previous behavior: the handler receives every identifier that
exists, and must scope itself.
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}.
Events
The table element dispatches datatables:bulk:start before the request, then
datatables:bulk:success or datatables:bulk:error. Each carries the action name; start adds the
selection, success the parsed response, and error either the refused response or the network
error:
this.element.addEventListener('datatables:bulk:success', (event) => {
const { action, result } = event.detail
console.info(`${action}: ${result.processed} processed, ${result.skipped} skipped`)
})
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,
bulk.bar.skipped and bulk.bar.failed. The count-bearing ones accept a {count} placeholder.