Action Column

UX DataTables builds row actions from an Actions collection. The recommended entry point is AbstractDataTable::configureActions().

use App\Entity\User;
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\Action;
use Pentiminax\UX\DataTables\Model\Actions;

#[AsDataTable(User::class)]
final class UsersDataTable extends AbstractDataTable
{
    public function configureColumns(): iterable
    {
        yield NumberColumn::new('id', 'ID');
        yield TextColumn::new('email', 'Email');
        yield TextColumn::new('status', 'Status');
    }

    public function configureActions(Actions $actions): Actions
    {
        return $actions
            ->setColumnLabel('Actions')
            ->add(
                Action::edit('Edit')
                    ->setClassName('btn btn-sm btn-warning')
                    ->icon(Icon::Pencil)
            )
            ->add(
                Action::delete('Delete')
                    ->setClassName('btn btn-sm btn-danger')
                    ->icon(Icon::Trash2)
                    ->askConfirmation('Delete this user?')
                    ->displayIf('status', 'draft')
            );
    }

    protected function mapRow(mixed $item): array
    {
        /** @var User $item */
        return [
            'id'     => $item->getId(),
            'email'  => $item->getEmail(),
            'status' => $item->getStatus(),
        ];
    }
}

When configureActions() returns at least one action, AbstractDataTable automatically appends an ActionColumn named actions.

Action API

Factory Methods

MethodDescription
``Action::delete($label = 'Delete', $className = 'btn btn-danger')``Create the built-in delete action
``Action::detail($label = 'Detail', $className = 'btn btn-primary')``Create a link action to a detail page
``Action::edit($label = 'Edit', $className = 'btn btn-warning')``Create an inline edit action (opens the configured edit modal)
``Action::new($name, $label = '', $className = '')``Create a custom link action identified by a unique `$name`. Several custom actions coexist as long as their names differ. Provide the destination with `linkToUrl()`

Fluent Configuration

MethodDescription
`label(string $label)`Override the button label
`setClassName(string $className)`Customize button classes
`icon(string|Icon $icon)`Render a CSS-class icon or a Lucide icon before the label
`htmlAttributes(array $htmlAttributes)`Set extra HTML attributes on the rendered action button/link
`askConfirmation(string $message)`Display a browser confirmation prompt before the action
`displayIf(string $field, mixed $value)`Show the action only when `row[field] === value`
`setIdField(string $idField)`Use another row key instead of `id`
`setEntityClass(string $entityClass)`Set the Doctrine entity class explicitly
`linkToUrl(string|callable $url)`Set the URL for detail actions (static string or per-row callable)
`linkToRoute(string $routeName, array|callable|null $params = null)`Target a Symfony route generated per row (static parameters array, or a callable receiving the raw row). Mutually exclusive with `linkToUrl()`: the last call wins
`asAjaxRequest(string|callable $csrfTokenId, string $method = 'POST')`Send the action as a same-origin Ajax request instead of navigating. `$method` accepts `POST` or `DELETE`. The CSRF token id is resolved per row and its value is posted as `_token`. See [Ajax Actions](#ajax-actions)
`collapsible(string $template, array $parameters = [])`Render a `detail` action as an expand/collapse control that lazily loads a Twig child row (the template receives the row as `entity`, plus any extra `$parameters`). Only meaningful for `Action::detail()`
`permission(string $attribute, ?callable $subjectResolver = null)`Restrict the action with a Symfony security attribute (role/voter/expression). Without a resolver it is evaluated once before serialization; with a resolver it is evaluated per row (the resolver receives the raw source row). Denied actions are removed and never serialized to the client
`position(?ActionsPosition $position)`Override the column placement for this action only (`null` inherits the `Actions` collection position). See [Column Position & Alignment](#column-position--alignment)

Action Icons

Pass an Icon enum case to render a Lucide SVG before the action label:

use Pentiminax\UX\DataTables\Enum\Icon;
use Pentiminax\UX\DataTables\Model\Action;

Action::edit('Edit')
    ->icon(Icon::Pencil);

Lucide is loaded lazily when the table contains at least one Lucide action or IconColumn. The SVG uses the action’s current text color and is sized to 1em.

String values remain backward compatible and are rendered as CSS classes:

Action::edit('Edit')
    ->icon('bi bi-pencil');

Lucide SVGs are decorative and hidden from assistive technologies. When an action has an empty label, provide an accessible name explicitly:

Action::delete('')
    ->icon(Icon::Trash2)
    ->htmlAttributes(['aria-label' => 'Delete']);

When linkToUrl() receives a callable, the argument depends on how the row was produced:

  • AbstractDataTable + provider or setData($objects): the callable receives the original source object
  • manual DataTable::data([...]) inline rows: the callable receives the row array

Each built-in factory has a reserved name, so adding two Action::detail() throws an InvalidArgumentException. To render several independent link actions in the same column, use Action::new() with a unique $name for each. Custom actions are rendered as <a href> links and support icon(), askConfirmation(), displayIf(), htmlAttributes(), permission(), and position() like any other action.

use App\Entity\Invoice;
use Pentiminax\UX\DataTables\Model\Action;
use Pentiminax\UX\DataTables\Model\Actions;
use Symfony\Component\Routing\RouterInterface;

public function configureActions(Actions $actions): Actions
{
    return $actions
        ->add(
            Action::new('view', 'View')
                ->icon('bi bi-eye')
                ->linkToUrl(fn (Invoice $invoice): string => $this->router->generate('invoice_view', ['id' => $invoice->getId()]))
        )
        ->add(
            Action::new('download', 'Download')
                ->icon('bi bi-download')
                ->linkToUrl(fn (Invoice $invoice): string => $this->router->generate('invoice_download', ['id' => $invoice->getId()]))
        );
}

Ajax Actions

linkToRoute() resolves a Symfony route per row, and asAjaxRequest() turns the action into a button that posts to that URL instead of navigating to it:

use App\Entity\Book;
use Pentiminax\UX\DataTables\Enum\Icon;
use Pentiminax\UX\DataTables\Model\Action;
use Pentiminax\UX\DataTables\Model\Actions;

public function configureActions(Actions $actions): Actions
{
    return $actions->add(
        Action::new('publish', 'Publish')
            ->icon(Icon::Send)
            ->linkToRoute('book_publish', fn (Book $book): array => ['id' => $book->getId()])
            ->asAjaxRequest('publish_book')
            ->askConfirmation('Publish this book?')
            ->permission('PUBLISH', fn (Book $book): Book => $book)
    );
}

The rendered button posts {"_token": "…"} with Content-Type: application/json and X-Requested-With: XMLHttpRequest, and is locked while the request is pending. The route only has to accept the method and validate the token:

#[Route('/books/{id}/publish', name: 'book_publish', methods: ['POST'])]
#[IsGranted('PUBLISH', subject: 'book')]
#[IsCsrfTokenValid('publish_book')]
public function publish(Book $book): Response
{
    // …

    return new Response(status: Response::HTTP_NO_CONTENT);
}

After a successful response the controller dispatches datatables:action:success, then:

  • follows the redirect when the endpoint returned one,
  • reloads a server-side table without losing its current page,
  • reloads the page for a client-side table.

A failed request dispatches datatables:action:error and re-enables the button.

Actions API

MethodDescription
`add(Action $action)`Register an action for the column
`remove(ActionType $type)`Remove a previously registered action
`setColumnLabel(string $label)`Customize the action column header
`position(ActionsPosition $position)`Place the actions column before or after the data columns (default: `AfterColumns`)
`alignment(ActionsAlignment $alignment)`Horizontally align the action cell. See [Column Position & Alignment](#column-position--alignment)

Actions stores actions keyed by a unique name. The built-in factories use their ActionType value as the name, so there can be only one delete, one detail, and one edit action per collection. Custom actions created with Action::new($name, ...) use the $name you provide, so any number of them can coexist as long as the names differ. Empty and duplicate names are rejected. The native names DELETE, DETAIL, EDIT, and CUSTOM are reserved for custom actions, case-insensitively.

The public enum values are:

use Pentiminax\UX\DataTables\Enum\ActionType;

ActionType::Delete; // value: DELETE
ActionType::Detail; // value: DETAIL
ActionType::Edit;   // value: EDIT
ActionType::Custom; // value: CUSTOM (used by Action::new())

Column Position & Alignment

By default, the action column is appended after the data columns. You can move it, align its content, and even split actions across two dedicated columns.

Collection-level position

Use Actions::position() to place the whole actions column before or after the data columns:

use Pentiminax\UX\DataTables\Enum\ActionsPosition;
use Pentiminax\UX\DataTables\Model\Actions;

public function configureActions(Actions $actions): Actions
{
    return $actions
        ->position(ActionsPosition::BeforeColumns)
        ->add(Action::edit('Edit'))
        ->add(Action::delete('Delete'));
}

The ActionsPosition enum exposes:

use Pentiminax\UX\DataTables\Enum\ActionsPosition;

ActionsPosition::BeforeColumns; // value: before
ActionsPosition::AfterColumns;  // value: after  (default)

Alignment

Use Actions::alignment() to horizontally align the content of the action cell:

use Pentiminax\UX\DataTables\Enum\ActionsAlignment;

$actions->alignment(ActionsAlignment::Center);

The alignment is applied as a dt-{value} CSS class on the column (for example, ActionsAlignment::Center adds dt-center).

use Pentiminax\UX\DataTables\Enum\ActionsAlignment;

ActionsAlignment::Left;   // value: left
ActionsAlignment::Center; // value: center
ActionsAlignment::Right;  // value: right

Per-action position

Action::position() overrides the placement for a single action, independently of the collection-level position. Pass null (the default) to inherit the Actions collection position, or an explicit ActionsPosition to pin that action.

When all actions resolve to the same position, a single ActionColumn named actions is produced (the default behavior). When actions are split across both positions, two action columns are produced:

  • the BeforeColumns group is prepended as a column named actions_before;
  • the AfterColumns group is appended as a column named actions.

A typical use case is pinning a collapsible detail toggle before the data while keeping edit/delete after it:

use Pentiminax\UX\DataTables\Enum\ActionsPosition;
use Pentiminax\UX\DataTables\Model\Action;
use Pentiminax\UX\DataTables\Model\Actions;

public function configureActions(Actions $actions): Actions
{
    return $actions
        ->add(
            Action::detail('Details')
                ->collapsible('data_tables/details.html.twig')
                ->position(ActionsPosition::BeforeColumns)
        )
        ->add(Action::edit('Edit'))
        ->add(Action::delete('Delete'));
}

This renders the detail toggle in an actions_before column placed before the data, and the edit/delete buttons in the trailing actions column.

Manual Usage with DataTableBuilderInterface

If you build tables manually, create an Actions collection and wrap it with ActionColumn::fromActions():

use App\Entity\User;
use Pentiminax\UX\DataTables\Column\ActionColumn;
use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Model\Action;
use Pentiminax\UX\DataTables\Model\Actions;

$actions = (new Actions())
    ->setColumnLabel('Operations')
    ->add(
        Action::delete()
            ->setEntityClass(User::class)
            ->setIdField('uuid')
            ->askConfirmation('Delete this user?')
    );

$table = $builder
    ->createDataTable('users')
    ->columns([
        TextColumn::new('email', 'Email'),
        ActionColumn::fromActions('actions', 'Operations', $actions),
    ])
    ->data([
        ['uuid' => '8f9f0c31', 'email' => 'john@example.com'],
    ]);

Manual tables must provide the entity class themselves because there is no #[AsDataTable] attribute to infer it from.

Inline Edit (Modal)

Action::edit() opens a configured edit modal with an auto-generated Symfony Form. See the dedicated Edit Modal page for full documentation: template overrides, column-to-form mapping, primary key handling, and examples.

Ajax Endpoints

All action endpoints are handled by built-in Stimulus controllers. Import the bundle routes:

// config/routes/ux_datatables.php
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;

return static function (RoutingConfigurator $routes): void {
    $routes->import('@DataTablesBundle/config/routes.php');
};

Delete Endpoint

DELETE /datatables/ajax/delete — payload: entity, id, topics (optional Mercure topics).

Edit Form Endpoints

  • GET /datatables/ajax/edit-form — returns the rendered modal HTML
  • POST /datatables/ajax/edit-form — validates and persists the form data

These endpoints are only available when symfony/form is installed.