Server-Side Processing
Move filtering, ordering, and paging to the backend for large datasets.
Minimal Setup
When a reusable AbstractDataTable enables server-side processing and does not define its own
Ajax source, UX DataTables wires the bundle Ajax endpoint automatically:
use Pentiminax\UX\DataTables\Model\DataTable;
protected function configureDataTable(DataTable $table): DataTable
{
return $table
->serverSide()
->processing();
}
The rendered table will call the built-in ux_datatables_ajax_data route. The request includes an
opaque table token, not the PHP class name, and the bundle resolves the matching tagged
AbstractDataTable service before calling handleRequest() internally.
Make sure the bundle routes are imported once in your app:
// config/routes/ux_datatables.php
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
return static function (RoutingConfigurator $routes): void {
$routes->import('@DataTablesBundle/config/routes.php');
};
Use an explicit ajax() URL when the endpoint is not an AbstractDataTable service, when you want a
custom route, or when you are using a plain DataTable instance:
use Pentiminax\UX\DataTables\Model\DataTable;
$dataTable = new DataTable('products');
$dataTable
->ajax('/api/products')
->serverSide()
->processing();
How Request Handling Works
With the current bundle defaults, ajax() configures a GET request. DataTables sends
server-side parameters such as draw, start, length, search, and order to that URL,
and DataTableRequest::fromRequest() reads them from the query string.
With automatic Ajax wiring, the page controller only renders the table. Manual request handling is
still useful when your table posts back to the same route or when you configured a custom Ajax URL.
In that case, do not rely on $request->isXmlHttpRequest() to detect a DataTables request. The
supported flow is:
- Call
handleRequest($request) - Check
isRequestHandled() - Return
getResponse()only when the request actually contains a DataTables payload
What matters here is the DataTables payload, not whether the browser transport happens to be
implemented as XHR or fetch.
Request Parameters Sent By DataTables
| Parameter | Purpose |
|---|---|
| `draw` | Request sequence number |
| `start` | Offset |
| `length` | Page size |
| `search[value]` | Global search term |
| `order[n][column]` | Sorted column index |
| `order[n][dir]` | Sort direction |
| `columns[n][name]` | Column name |
| `columns[n][search][value]` | Per-column filter |
Parse Request With DataTableRequest
use Pentiminax\UX\DataTables\DataTableRequest\DataTableRequest;
$requestDto = DataTableRequest::fromRequest($request);
Use this DTO when you need direct access to paging, ordering, or search values. If you are using
AbstractDataTable in a controller, the usual entry point is still handleRequest() followed by
isRequestHandled().
Typical Controller With Automatic Ajax
use App\DataTables\ProductsDataTable;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/products', name: 'app_products')]
public function index(ProductsDataTable $table): Response
{
return $this->render('product/index.html.twig', [
'table' => $table
]);
}
Manual Same-Route Handling
use App\DataTables\ProductsDataTable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/products', name: 'app_products')]
public function index(ProductsDataTable $table, Request $request): Response
{
// Needed only when the table Ajax URL points to this route.
$table->handleRequest($request);
if ($table->isRequestHandled()) {
return $table->getResponse();
}
return $this->render('product/index.html.twig', [
'table' => $table
]);
}
Forwarding Page Query Parameters
With automatic Ajax, the browser sends its request to the bundle endpoint rather than to the page URL, so query parameters present on the page (?q=...&pending=...) never reach your server-side query. forwardQueryParameters() captures the named parameters at render time and forwards them on every Ajax call, letting you read them in customizeQueryBuilder() — no dedicated relay controller required.
use Pentiminax\UX\DataTables\DataTableRequest\DataTableRequest;
use Pentiminax\UX\DataTables\Model\DataTable;
public function configureDataTable(DataTable $table): DataTable
{
return $table
->serverSide()
->forwardQueryParameters(['q', 'pending']);
}
protected function customizeQueryBuilder(QueryBuilder $qb, DataTableRequest $request): QueryBuilder
{
$httpRequest = $this->getHttpRequest();
if (null !== $pending = $httpRequest?->query->get('pending')) {
$qb->andWhere('e.pending = :pending')->setParameter('pending', $pending);
}
return $qb;
}
The forwarded values are a snapshot taken when the page renders and are sent unchanged on every paging/search/sort reload. Only parameters actually present in the request are forwarded. This works with automatic Ajax and with manual ajax() / ajaxRequestData(); it is not applied in API Platform mode.
Sorting Computed Columns
Server-side ordering resolves a column to <alias>.<field> and runs ORDER BY on it. A column
whose value is computed (an aggregate, a count, a derived expression) has no matching entity
field, so Doctrine throws:
[Semantical Error] line 0, col … near 'invoiceCount':
Error: Class App\Entity\Customer has no field or association named invoiceCount
To sort such a column in the database, expose the value as a SELECT alias and tell the column to
order by it:
- In
customizeQueryBuilder(), add the aggregate withaddSelect(... AS HIDDEN <alias>). TheHIDDENkeyword keeps the scalar out of the hydrated result, so entity hydration (and any page projector) is unaffected — the alias exists only forORDER BY. - On the column, call
setOrderExpression('<alias>')soOrderFilteruses the alias verbatim instead of<alias>.<field>.
use Pentiminax\UX\DataTables\Column\NumberColumn;
use Pentiminax\UX\DataTables\DataTableRequest\DataTableRequest;
use Doctrine\ORM\QueryBuilder;
public function configureColumns(): iterable
{
yield NumberColumn::new('invoiceCount', 'Invoices')
->setOrderExpression('invoiceCount') // matches the HIDDEN alias below
->setSearchable(false) // search would still resolve e.invoiceCount → error
->disableGlobalSearch();
}
protected function customizeQueryBuilder(QueryBuilder $qb, DataTableRequest $request): QueryBuilder
{
return $qb->addSelect(
'(SELECT COUNT(inv.id) FROM App\Entity\Invoice inv WHERE inv.customer = e.id) AS HIDDEN invoiceCount'
);
}
Prefer a correlated scalar subquery (as above) over a leftJoin + groupBy: joining a
to-many relation inflates the counts (cartesian product) and changes pagination. A scalar subquery
keeps one row per root entity, so LIMIT/OFFSET paginate correctly.
The count query the provider builds for recordsFiltered resets the SELECT and ORDER BY parts,
so the HIDDEN alias and ORDER BY are dropped there automatically — no extra handling needed.
Best Practices
- Use
isRequestHandled()instead of$request->isXmlHttpRequest(). - Let
AbstractDataTableauto-wire Ajax when the default bundle route is enough. - Always whitelist sortable/searchable fields in your query layer.
- Keep backend response time predictable (indexes, query limits).
- Prefer
AbstractDataTablefor reusable query behavior. - Keep your server-side endpoint aligned with the URL configured in
ajax(). - Ensure your table resolves a provider in server-side mode, either through
createDataProvider()or#[AsDataTable(...)]; otherwisegetResponse()falls back to an empty payload.