A while ago I wrote a first post introducing UX DataTables, a Symfony bundle that integrates the DataTables.net library into Symfony applications.
Since then the bundle has changed a lot. The builder-service approach from the original post is gone; the bundle now revolves around a single base class you extend, with strongly-typed columns, server-side processing handled for you, row actions, an inline edit modal, and first-class integrations with API Platform and Mercure.
This post is a tour of where the bundle stands today.
Installation
composer require pentiminax/ux-datatables
Requirements:
- PHP 8.3+
- Symfony 7.0 / 8.0
- Symfony StimulusBundle (shipped with Symfony UX)
The frontend ships as the @pentiminax/ux-datatables ES module. DataTables.net and its extensions are lazy-loaded by the Stimulus controller, so you only download the JavaScript you actually use.
The new mental model: one class per table
Instead of building a table inline in your controller, you now describe a table once in a dedicated class extending AbstractDataTable, decorated with the #[AsDataTable] attribute pointing at your entity:
use App\Entity\User;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Column\BooleanColumn;
use Pentiminax\UX\DataTables\Column\DateColumn;
use Pentiminax\UX\DataTables\Column\NumberColumn;
use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
#[AsDataTable(User::class)]
final class UserDataTable extends AbstractDataTable
{
public function configureColumns(): iterable
{
return [
NumberColumn::new('id', 'ID'),
TextColumn::new('firstName', 'First name'),
TextColumn::new('email', 'Email'),
BooleanColumn::new('active', 'Active'),
DateColumn::new('createdAt', 'Created at'),
];
}
}
Wire it into a controller. In most cases your controller only needs to render the page — the bundle ships its own AJAX controller and wires the data endpoint for you:
#[Route('/users', name: 'app_users')]
public function index(UserDataTable $table): Response
{
return $this->render('user/index.html.twig', [
'table' => $table,
]);
}
And render it in Twig:
{{ render_datatable(table) }}
That is the whole loop. The bundle registers a built-in controller behind the /datatables/ajax/data route (no route import needed — it's loaded automatically). For server-side tables, the AJAX URL is resolved automatically: each AbstractDataTable is addressed by a signed HMAC token, so the frontend calls the shared endpoint, the bundle looks the table up in its registry, runs the query and returns the JSON. You don't write a data endpoint, and you don't expose your table classes by name in the URL.
If you ever need the table's data to flow through your own route (a custom endpoint, extra auth, a non-standard payload), the same class can still do it manually:
#[Route('/users', name: 'app_users')]
public function index(UserDataTable $table, Request $request): Response
{
$table->handleRequest($request);
if ($table->isRequestHandled()) {
return $table->getResponse(); // JSON for a DataTables AJAX call
}
return $this->render('user/index.html.twig', ['table' => $table]);
}
handleRequest() detects whether the incoming request is a DataTables AJAX call; if it is, getResponse() returns the JSON payload, otherwise you render the page.
Strongly-typed columns
Columns are PHP objects, created with a ::new() factory and configured fluently. There are dedicated types for the common cases instead of stringly-typed configuration:
| Column | Use for |
|---|---|
TextColumn |
Plain text or HTML |
NumberColumn |
Integers, floats |
MoneyColumn |
Monetary amounts (currency, cents) |
DateColumn |
Dates / datetimes |
BooleanColumn |
True/false, optionally an AJAX toggle switch |
ChoiceColumn |
Finite value sets (statuses, enums) |
EmailColumn |
Clickable mailto: links |
ImageColumn |
Image URLs rendered as thumbnails |
UrlColumn |
Links from raw URLs, routes, or callables |
TemplateColumn |
Custom server-side Twig rendering |
ActionColumn |
Row action buttons |
Each variant exposes intent-revealing methods:
TextColumn::new('content')->html()->utf8();
NumberColumn::new('price')->formatted();
MoneyColumn::new('price')->currency('EUR')->storedAsCents();
BooleanColumn::new('active')->renderAsSwitch();
All columns inherit the common knobs from AbstractColumn — setVisible(), setWidth(), setOrderable(), setSearchable(), disableGlobalSearch(), setExportable(), setDefaultContent(), and more. Column titles can be Symfony translation keys; they are resolved automatically.
For computed columns backed by a SELECT alias rather than a mapped field, setOrderExpression() lets you provide the raw DQL used in the ORDER BY, so sorting works on derived values too:
NumberColumn::new('invoiceCount', 'Invoices')
->setOrderExpression('invoiceCount') // SELECT alias added in customizeQueryBuilder()
->setSearchable(false);
Server-side processing, handled for you
For large datasets you flip a single switch and the bundle takes care of paging, ordering, global search and per-column search against Doctrine:
public function configureDataTable(DataTable $table): DataTable
{
return $table
->serverSide(true)
->pageLength(25);
}
Under the hood a chain-of-responsibility query pipeline applies global search, per-column search and ordering, using a set of search strategies (contains, starts-with, equals, greater-than, in-list, …). Need to constrain the result set? Override customizeQueryBuilder():
protected function customizeQueryBuilder(QueryBuilder $qb, DataTableRequest $request): QueryBuilder
{
return $qb
->andWhere('e.active = :active')
->setParameter('active', true);
}
If you need to enrich a whole page without an N+1 (load metrics, project to DTOs), override projectPage(): it receives the already-paginated page of entities and returns a parallel list, so columns read the projection while actions still receive the source entity.
Client-side mode is just as easy — provide inline data and DataTables does paging/search in the browser. The bundle hydrates the rows for you through the same column pipeline.
Row actions and per-row permissions
Row buttons are declared with configureActions(). Edit, detail and delete are first-class, and you can point them at a URL or a route resolver:
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Action::detail('View')->setIcon('bi bi-eye')
->linkToUrl(fn (User $u) => $this->router->generate('user_show', ['id' => $u->getId()])))
->add(Action::edit('Edit')->setIcon('bi bi-pencil'))
->add(Action::delete('Delete')->askConfirmation('Delete this user?'));
}
Actions integrate with Symfony Security. A static attribute is checked once before rendering; a per-row check receives the row and decides visibility individually:
Action::delete()
->permission('ROLE_ADMIN'); // evaluated once
Action::edit()
->permission('EDIT', fn (User $u) => $u); // voter evaluated per row
The same permission() mechanism is available on columns, so you can hide a whole column from users who shouldn't see it.
Inline editing with an auto-generated form
Action::edit() opens a Bootstrap 5 modal containing a Symfony Form generated from your columns. No form class to write:
- The user clicks Edit.
- The controller fetches a pre-filled form from
GET /datatables/ajax/edit-form. - On submit,
POST /datatables/ajax/edit-formvalidates and persists. - On error, only the modal body is re-rendered in place; on success the table reloads automatically.
Column types map to form types automatically (BooleanColumn → CheckboxType, ChoiceColumn → ChoiceType, DateColumn → DateType, …). Identifier columns are rendered disabled, and hideWhenUpdating() removes a column from the form entirely.
Both the Twig template and the JavaScript modal adapter are overridable — globally, via the attribute, or per table — so you can swap Bootstrap for a native <dialog> or any UI kit.
This requires symfony/form (and symfony/twig-bundle to render the modal).
API Platform integration
If your backend already exposes Hydra collections through API Platform, the bundle can drive a table straight off them. It is opt-in — add apiPlatform: true:
#[AsDataTable(entityClass: Book::class, apiPlatform: true)]
final class BookDataTable extends AbstractDataTable
{
}
With that enabled, the bundle:
- auto-detects columns from API Platform property metadata (respecting serialization groups),
- resolves the collection URL and wires AJAX automatically,
- activates a Hydra adapter on the frontend that translates DataTables query params into API Platform params (
page,itemsPerPage,order[field],q, filters) and mapshydra:member/hydra:totalItemsback into the DataTables response shape.
The global search box is mapped to the q parameter, so wiring a free-text search is just a QueryParameter on your resource:
#[ApiResource(operations: [
new GetCollection(parameters: [
'q' => new QueryParameter(
filter: new OrFilter(new FreeTextQueryFilter(new PartialSearchFilter())),
properties: ['email', 'firstName', 'lastName'],
),
]),
])]
final class User {}
Real-time refresh with Mercure
Tables can refresh themselves when the underlying data changes elsewhere. Enable Mercure on the attribute:
#[AsDataTable(Book::class, mercure: true)]
final class BookDataTable extends AbstractDataTable
{
}
The Stimulus controller opens an EventSource to your Mercure hub and calls ajax.reload() on each message — no full page reload, current paging preserved. Topics can be auto-resolved (from API Platform metadata or an IRI template) or declared explicitly, and you can tune withCredentials and a client-side debounceMs to protect the backend under bursty updates:
$dataTable->mercure(
topics: ['admin/books', 'admin/authors'],
withCredentials: true,
debounceMs: 300,
);
When symfony/mercure is installed, the bundle also registers a MercureUpdatePublisher and broadcasts after inline edits, so an edit in one browser refreshes every open table.
Scaffolding with make:datatable
You rarely start from a blank file. The MakerBundle command reads a Doctrine entity and generates the table class for you, mapping field types to the right column (int/float → NumberColumn, bool → BooleanColumn, DateTimeInterface → DateColumn):
php bin/console make:datatable User
It produces a ready-to-edit class in src/DataTables/ with configureColumns() and a mapRow() skeleton.
Extensions
The DataTables.net extensions are exposed through PHP helpers and lazy-loaded frontend adapters, configured in configureExtensions():
| Extension | Use for |
|---|---|
| Buttons | CSV / Excel / PDF / print export |
| Select | Row / column / cell selection |
| Column Control | Per-column order and search controls |
| Responsive | Mobile-friendly collapsing |
| KeyTable | Keyboard navigation |
| Scroller | Virtualized rendering for huge tables |
| Fixed Columns | Pin edge columns while scrolling |
| ColReorder | User-driven column reordering |
public function configureExtensions(DataTableExtensions $extensions): DataTableExtensions
{
return $extensions->addExtension(new ButtonsExtension([
ButtonType::CSV,
ButtonType::EXCEL,
ButtonType::PDF,
]));
}
Wrapping up
UX DataTables has grown from "render a DataTable from PHP" into a complete, typed layer over DataTables.net:
- one
AbstractDataTableclass per table, with the#[AsDataTable]attribute, - a dozen strongly-typed column types,
- server-side processing, custom query builders and page projection,
- row actions with Symfony Security checks,
- inline editing via auto-generated Symfony Forms in an overridable modal,
- API Platform and Mercure integrations,
-
make:datatablescaffolding and the full set of DataTables.net extensions.
The full documentation lives at pentiminax.github.io/ux-datatables, and the source is on GitHub.
Feedback and contributions are very welcome.
Top comments (0)