DEV Community

Cover image for UX DataTables in 2026: typed columns, server-side processing, API Platform, Mercure and inline editing
Pentiminax
Pentiminax

Posted on

UX DataTables in 2026: typed columns, server-side processing, API Platform, Mercure and inline editing

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
Enter fullscreen mode Exit fullscreen mode

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'),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

And render it in Twig:

{{ render_datatable(table) }}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

All columns inherit the common knobs from AbstractColumnsetVisible(), 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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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?'));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. The user clicks Edit.
  2. The controller fetches a pre-filled form from GET /datatables/ajax/edit-form.
  3. On submit, POST /datatables/ajax/edit-form validates and persists.
  4. On error, only the modal body is re-rendered in place; on success the table reloads automatically.

Column types map to form types automatically (BooleanColumnCheckboxType, ChoiceColumnChoiceType, DateColumnDateType, …). 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
{
}
Enter fullscreen mode Exit fullscreen mode

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 maps hydra:member / hydra:totalItems back 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 {}
Enter fullscreen mode Exit fullscreen mode

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
{
}
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

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/floatNumberColumn, boolBooleanColumn, DateTimeInterfaceDateColumn):

php bin/console make:datatable User
Enter fullscreen mode Exit fullscreen mode

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,
    ]));
}
Enter fullscreen mode Exit fullscreen mode

Wrapping up

UX DataTables has grown from "render a DataTable from PHP" into a complete, typed layer over DataTables.net:

  • one AbstractDataTable class 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:datatable scaffolding 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)