AbstractDataTable
Create reusable, configurable data tables with Doctrine integration
AbstractDataTable is the recommended way to create reusable, configurable tables with built-in support for Doctrine entities and server-side processing.
It now acts primarily as a configuration class. Request handling, provider resolution, and row processing run through internal collaborators behind the existing public façade.
Overview
The class wires together:
- DataTable: ID, columns, and extensions
- DataProvider resolution: Chooses a manual provider or an auto-configured Doctrine provider
- Row processing pipeline: Maps entities into frontend-compatible arrays, then applies template rendering and row actions
- Actions: Optional row-level actions appended automatically when configured
Quick Start with #[AsDataTable]
For Doctrine-backed tables, use the #[AsDataTable] attribute:
use App\Entity\User;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Column\NumberColumn;
#[AsDataTable(User::class)]
final class UsersDataTable extends AbstractDataTable
{
public function configureColumns(): iterable
{
yield NumberColumn::new('id', 'ID');
yield TextColumn::new('lastName', 'Last name');
yield TextColumn::new('firstName', 'First name');
yield TextColumn::new('email', 'Email');
}
protected function mapRow(mixed $row): array
{
/** @var User $row */
return [
'id' => $row->getId(),
'lastName' => $row->getLastName(),
'firstName' => $row->getFirstName(),
'email' => $row->getEmail(),
];
}
}
The attribute automatically creates a DoctrineDataProvider configured with:
- Your entity class
- The internal row processing pipeline built from
mapRow() - The
customizeQueryBuilder()method
Defining Columns with Attributes
When your Doctrine entity already uses #[ORM\Column(...)], alias UX DataTables attributes to avoid naming conflicts:
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Pentiminax\UX\DataTables\Attribute as DataTable;
#[ORM\Entity]
final class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[DataTable\Column(title: 'ID')]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[DataTable\Column(title: 'Email', searchable: true, orderable: true)]
private string $email = '';
#[ORM\Column]
#[DataTable\Column(title: 'Active')]
private bool $active = true;
}
Then your table can rely on these attributes:
use App\Entity\User;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
#[AsDataTable(User::class)]
final class UsersDataTable extends AbstractDataTable
{
// No configureColumns() needed when using #[DataTable\Column] on the entity.
}
Using in a Controller
For server-side tables without a custom Ajax URL, the page controller only needs to render the table. UX DataTables adds the bundle Ajax endpoint during rendering and handles DataTables requests through that endpoint. The generated Ajax request uses an opaque table token so the PHP DataTable class name is not exposed in the browser URL or payload:
use App\DataTables\UsersDataTable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class UsersController extends AbstractController
{
#[Route('/users', name: 'app_users')]
public function index(UsersDataTable $table): Response
{
return $this->render('users/index.html.twig', [
'table' => $table,
]);
}
}
When you configure the table to use the same route as its Ajax endpoint, handle the request manually:
use App\DataTables\UsersDataTable;
use Pentiminax\UX\DataTables\DataTableRequest\DataTableRequest;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class UsersController extends AbstractController
{
#[Route('/users', name: 'app_users')]
public function index(UsersDataTable $table, Request $request): Response
{
// Needed only when this controller is also the configured Ajax endpoint.
$table->handleRequest($request);
if ($table->isRequestHandled()) {
return $table->getResponse();
}
// For client-side tables, fetch data before rendering
if (!$table->getDataTable()->isServerSide()) {
$table->fetchData(DataTableRequest::fromRequest($request));
}
return $this->render('users/index.html.twig', [
'table' => $table,
]);
}
}
Customizing the Query
Query Builder Configurator
Filter or modify the Doctrine query:
use Doctrine\ORM\QueryBuilder;
use Pentiminax\UX\DataTables\DataTableRequest\DataTableRequest;
#[AsDataTable(User::class)]
final class ActiveUsersDataTable extends AbstractDataTable
{
protected function customizeQueryBuilder(QueryBuilder $qb, DataTableRequest $request): QueryBuilder
{
return $qb
->andWhere('e.active = :active')
->setParameter('active', true)
->andWhere('e.deletedAt IS NULL');
}
public function configureColumns(): iterable
{
// ...
}
}
customizeQueryBuilder()’s constraints apply to recordsTotal as well as recordsFiltered and the
returned page — the deleted/inactive rows excluded above are never counted at all, not just hidden
from search results. Only the bundle’s own interactive search, ordering, and column/user filters
are layered on top for recordsFiltered; recordsTotal reflects your base dataset alone.
Dynamic Filtering
Use request parameters for dynamic filters:
protected function customizeQueryBuilder(QueryBuilder $qb, DataTableRequest $request): QueryBuilder
{
// Access the current Symfony HTTP request for custom parameters
if ($roleId = $this->getHttpRequest()?->query->get('role')) {
$qb->andWhere('e.role = :role')
->setParameter('role', $roleId);
}
return $qb;
}
Configuring the DataTable
Table Options
use Pentiminax\UX\DataTables\Model\DataTable;
#[AsDataTable(User::class)]
final class UsersDataTable extends AbstractDataTable
{
public function configureDataTable(DataTable $table): DataTable
{
return $table
->pageLength(25)
->ordering(handler: true, indicators: true)
->searching()
->serverSide()
->processing();
}
public function configureColumns(): iterable
{
// ...
}
}
Add ->ajax(url: '/users') only when you want to override the automatic bundle endpoint.
Extensions
use Pentiminax\UX\DataTables\Enum\ButtonType;
use Pentiminax\UX\DataTables\Enum\SelectStyle;
use Pentiminax\UX\DataTables\Model\DataTableExtensions;
use Pentiminax\UX\DataTables\Model\Extensions\ButtonsExtension;
use Pentiminax\UX\DataTables\Model\Extensions\ColumnControlExtension;
use Pentiminax\UX\DataTables\Model\Extensions\SelectExtension;
public function configureExtensions(DataTableExtensions $extensions): DataTableExtensions
{
return $extensions
->addExtension(new ButtonsExtension([ButtonType::CSV, ButtonType::EXCEL]))
->addExtension(new SelectExtension(SelectStyle::MULTI))
->addExtension(new ColumnControlExtension());
}
Row Actions
Override configureActions() to append a built-in action column without manually creating an
ActionColumn:
use Pentiminax\UX\DataTables\Model\Action;
use Pentiminax\UX\DataTables\Model\Actions;
public function configureActions(Actions $actions): Actions
{
return $actions
->add(
Action::edit()
->icon('bi bi-pencil')
)
->add(
Action::delete()
->askConfirmation('Delete this user?')
->displayIf('isDeletable', true)
);
}
When the table uses #[AsDataTable(Entity::class)], the entity class is injected automatically
into each action. See the Action Columns reference for the full API.
Available Methods
Configuration Methods
| Method | Description |
|---|---|
configureActions(Actions $actions) | Configure built-in row actions |
configureDataTable(DataTable $table) | Configure table options |
configureColumns() | Define columns (required) |
configureExtensions(DataTableExtensions $ext) | Configure all extensions |
Configuration Methods Must Be Pure
DataTable services are shared in the container: configure*() runs once and its
result is reused. Under PHP-FPM the process dies after each request, so the
configuration is rebuilt anyway. Under a worker runtime (FrankenPHP worker mode,
Swoole, RoadRunner) the same instance serves many requests, for many users.
The bundle tags every autoconfigured table with kernel.reset, so Symfony’s
ServicesResetter clears the per-request state between requests and the
configuration is rebuilt each time. Still, treat configure*() as pure: define
columns, actions, filters, extensions, and table options, and nothing else.
Do not read the current HTTP request, the security token, the session, the
locale, or any other per-request service from a configure*() method. Configure
the table declaratively and let the request-scoped boundaries do the rest:
setPermission()on columns and actions for authorization — it is evaluated per request, per row, at the output boundary.customizeQueryBuilder()andgetRequest()for anything that depends on the incoming request.setData()for data resolved by the controller.
Data Methods
| Method | Description |
|---|---|
createDataProvider() | Protected hook for manual DataProvider instances |
getDataProvider() | Public façade returning the resolved provider |
fetchData(DataTableRequest $request) | Fetch data using the provider |
getRequest() | Get the parsed DataTables request |
getHttpRequest() | Get the current Symfony HTTP request after handleRequest() |
mapRow(mixed $row) | Map a single row to array |
setData(array $data) | Set inline data through the same row-processing pipeline used by providers |
createRowMapper() | Final protected helper returning the internal row-processing mapper |
customizeQueryBuilder(QueryBuilder $qb, ...) | Modify Doctrine query before DataTables filters are applied |
projectPage(array $items) | Batch-transform an already-paginated page of source entities. An export is batched instead of paged — see Custom Exporters |
createSearchStrategyRegistry() | Register custom per-column search logic — see Search Strategies |
createSearchPredicateBuilder() | Replace how global search builds a column condition — see Search Strategies |
Request Handling
| Method | Description |
|---|---|
handleRequest(Request $request) | Handle Ajax requests |
isRequestHandled() | Check if request was handled |
getResponse() | Get JsonResponse for Ajax; returns an empty dataset when no provider is available |
getDataTable() | Get the configured DataTable |
getConfiguredDataTable() | Read the configured DataTable without rendering preparation or data hydration |
Manual Data Provider
Override createDataProvider() for custom data sources:
use Pentiminax\UX\DataTables\Contracts\DataProviderInterface;
use Pentiminax\UX\DataTables\DataProvider\ArrayDataProvider;
#[AsDataTable(User::class)] // Auto Doctrine wiring is skipped when createDataProvider() returns a provider
final class CustomUsersDataTable extends AbstractDataTable
{
protected function createDataProvider(): ?DataProviderInterface
{
// Static data
$rows = [
['id' => 1, 'name' => 'John Doe'],
['id' => 2, 'name' => 'Jane Smith'],
];
return new ArrayDataProvider($rows, $this->createRowMapper());
}
protected function mapRow(mixed $row): array
{
// For arrays, just return as-is
return is_array($row) ? $row : (array) $row;
}
}
createRowMapper() and setData() both run the built-in row-processing pipeline. That means
template columns and action URLs are resolved before the rows are stored for inline rendering.
Inline Data with setData()
When you already have rows in memory, setData() is the simplest way to populate an
AbstractDataTable without building a custom provider:
use App\Entity\User;
#[AsDataTable(User::class)]
final class UsersDataTable extends AbstractDataTable
{
public function configureColumns(): iterable
{
yield NumberColumn::new('id', 'ID');
yield TextColumn::new('email', 'Email');
}
public function configureActions(Actions $actions): Actions
{
return $actions->add(
Action::detail()->linkToUrl(static fn (User $user): string => '/users/'.$user->getId())
);
}
}
$table->setData($users);
Use setData() when your rows are domain objects or preloaded arrays and you still want
mapRow(), template columns, and typed action callables to work consistently.
If an Ajax request is handled and the resolved provider is null, getResponse() returns an
empty DataTables payload using the current draw value:
{
"draw": 3,
"recordsTotal": 0,
"recordsFiltered": 0,
"data": []
}
This avoids runtime errors, but in practice a server-side table should still expose a provider.
Core Interfaces
DataProviderInterface
interface DataProviderInterface
{
public function fetchData(DataTableRequest $request): DataTableResult;
}
RowMapperInterface
interface RowMapperInterface
{
public function map(mixed $row): array;
}
DataTableRequest
Contains pagination, search, ordering, and column metadata from the client request.
DataTableResult
Wraps data payload with total and filtered counts:
new DataTableResult(
recordsTotal: 1000,
recordsFiltered: 150,
data: $rows
);
API Platform Integration
API Platform specific behavior is documented in:
This includes:
| Capability | Description |
|---|---|
Ajax adapter mode (apiPlatform(true)) | Maps DataTables requests/responses to API Platform and Hydra shapes |
Auto Ajax wiring during getDataTable() / rendering | Auto-populates Ajax settings when resource metadata is available |
| Column auto-detection | Builds columns from readable API Platform resource properties |