Action Column
UX DataTables builds row actions from an Actions collection. The recommended entry point is AbstractDataTable::configureActions().
Recommended Usage with AbstractDataTable
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
| Method | Description |
|---|---|
| ``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
| Method | Description |
|---|---|
| `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 orsetData($objects): the callable receives the original source object- manual
DataTable::data([...])inline rows: the callable receives the row array
Custom Link Actions
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
| Method | Description |
|---|---|
| `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
BeforeColumnsgroup is prepended as a column namedactions_before; - the
AfterColumnsgroup is appended as a column namedactions.
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 HTMLPOST /datatables/ajax/edit-form— validates and persists the form data
These endpoints are only available when symfony/form is installed.