Type to search columns, filters, options, and extensions.

↑↓ to navigate · Enter to open · Esc to close

Documentation

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

MethodDescription
`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

MethodDescription
`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.