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:

  1. Call handleRequest($request)
  2. Check isRequestHandled()
  3. 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:

  1. In customizeQueryBuilder(), add the aggregate with addSelect(... AS HIDDEN <alias>). The HIDDEN keyword keeps the scalar out of the hydrated result, so entity hydration (and any page projector) is unaffected — the alias exists only for ORDER BY.
  2. On the column, call setOrderExpression('<alias>') so OrderFilter uses 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 AbstractDataTable auto-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 AbstractDataTable for 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(...)]; otherwise getResponse() falls back to an empty payload.