Usage
Learn how to build and render DataTables in your Symfony application
This guide covers the main ways to create and render DataTables in your Symfony application.
Declaring a Table
Every table is a class extending AbstractDataTable. Declare the columns in
configureColumns() and the DataTables options in configureDataTable():
namespace App\DataTables;
use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;
final class UsersDataTable extends AbstractDataTable
{
public function configureColumns(): iterable
{
yield TextColumn::new('firstName', 'First name');
yield TextColumn::new('lastName', 'Last name');
}
public function configureDataTable(DataTable $table): DataTable
{
return $table->data([
['firstName' => 'John', 'lastName' => 'Doe'],
['firstName' => 'Jane', 'lastName' => 'Smith'],
]);
}
}
Table classes are autoconfigured services. Inject one into a controller and pass it to the template:
namespace App\Controller;
use App\DataTables\UsersDataTable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class HomeController extends AbstractController
{
#[Route('/', name: 'app_homepage')]
public function index(UsersDataTable $table): Response
{
return $this->render('home/index.html.twig', [
'table' => $table,
]);
}
}
When the rows only become available in the controller, keep configureDataTable() for the options
and call setData() on the injected table instead.
All options and data are passed as-is to DataTables. Refer to the DataTables documentation for available client-side options.
Rendering in Twig
Use the render_datatable() function to render your table:
{{ render_datatable(table) }}
Adding HTML Attributes
Pass HTML attributes as a second argument:
{{ render_datatable(table, {'class': 'my-table table-striped'}) }}
{{ render_datatable(table, {
'class': 'table table-bordered',
'data-custom': 'value'
}) }}
The table id attribute always comes from the short name of the table class, for example id="UsersDataTable". Do not pass a separate id through render_datatable().
Extending the Default Behavior
Create a custom Stimulus controller to extend DataTables functionality:
// assets/controllers/mytable_controller.js
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
connect() {
this.element.addEventListener('datatables:pre-connect', this._onPreConnect)
this.element.addEventListener('datatables:connect', this._onConnect)
this.element.addEventListener('datatables:reconnect', this._onConnect)
}
disconnect() {
this.element.removeEventListener('datatables:pre-connect', this._onPreConnect)
this.element.removeEventListener('datatables:connect', this._onConnect)
this.element.removeEventListener('datatables:reconnect', this._onConnect)
}
_onPreConnect(event) {
// The table is not yet created
// Access the config that will be passed to DataTable constructor
console.log(event.detail.config)
// Define a custom render callback
event.detail.config.columns[0].render = function (data, type, row, meta) {
return '<a href="' + data + '">Download</a>'
}
}
_onConnect(event) {
// The table was just created
console.log(event.detail.table)
// Listen to DataTables events
event.detail.table.on('init', (e) => {
console.log('Table initialized')
})
event.detail.table.on('draw', (e) => {
console.log('Table redrawn')
})
}
}
Then attach your controller in Twig:
{{ render_datatable(table, {'data-controller': 'mytable'}) }}
Available Events
| Event | Description | Detail Properties |
|---|---|---|
| datatables:pre-connect | Fired before table initialization | config: Configuration object |
| datatables:pre-init | Fired after extension plugins have loaded, just before the table is constructed | config: Configuration object, DataTable: the loaded DataTable class |
| datatables:connect | Fired after table is created | table: DataTable instance |
| datatables:reconnect | Fired when a controller connects to a table that is already built, after DataTables restructures the DOM | table: DataTable instance |
pre-connect fires before extension plugins (like ColumnControl) have loaded, so DataTable.ext
and extension-specific registries such as DataTable.ColumnControl.content aren’t populated yet.
Use pre-init instead when you need one of those to exist — for example, registering a custom
ColumnControl content type. See
Custom Content Types.
Surviving the Reconnect Cycle
DataTables restructures the DOM right after the table is built: it moves the <table> into its own
wrapper, and extensions such as Responsive or ColumnControl add more markup. Stimulus sees the
element leave and re-enter the document, so every controller declared on that table — yours
included — is disconnected and connected again once, shortly after the initial connect.
datatables:connect marks the table being built and therefore fires only once. The reconnect
dispatches datatables:reconnect instead, with the same table instance in its detail. Anything
your controller sets up in a connect handler and tears down in disconnect() must be redone on
reconnect, otherwise it silently stops working a moment after page load:
connect() {
this.element.addEventListener('datatables:connect', this._onTable)
this.element.addEventListener('datatables:reconnect', this._onTable)
}
disconnect() {
this.element.removeEventListener('datatables:connect', this._onTable)
this.element.removeEventListener('datatables:reconnect', this._onTable)
}
_onTable = (event) => {
this._bind(event.detail.table)
}
Bind row-level listeners by delegation rather than on nodes you look up once. DataTables replaces
the <tbody> rows on every draw, so a handler attached to a cached tbody, tr or cell stops
firing after the next redraw:
// Fires for every row, including rows drawn later
event.detail.table.on('click', 'tbody tr', (e, dt, type, indexes) => {
console.log(dt.row(e.currentTarget).data())
})
Working with Multiple Tables
You can have multiple tables on the same page:
use App\DataTables\OrdersDataTable;
use App\DataTables\UsersDataTable;
public function index(UsersDataTable $usersTable, OrdersDataTable $ordersTable): Response
{
return $this->render('dashboard/index.html.twig', [
'usersTable' => $usersTable,
'ordersTable' => $ordersTable,
]);
}
<h2>Users</h2>
{{ render_datatable(usersTable) }}
<h2>Orders</h2>
{{ render_datatable(ordersTable) }}
Best Practices
- Use meaningful table IDs - They’re used for state saving and DOM identification
- Define columns explicitly - Even when loading data via Ajax, define your column structure
- Configure server-side processing for large datasets - Client-side processing works well up to ~10,000 rows
- Use AbstractDataTable for reusable tables - Encapsulate table logic in dedicated classes