Skip to content

Doctrine ORM and DBAL

Edit this page

Pass a Doctrine ORM QueryBuilder directly to the paginator. The adapter clones it before applying limits, offsets, counts or cursor predicates. The application's original builder is never mutated.

Filtered lists

Build the filtered query in the repository:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/Repository/OrderRepository.php
public function queryForStatus(?string $status): QueryBuilder
{
    $query = $this->createQueryBuilder('orders')
        ->orderBy('orders.createdAt', 'DESC');

    if (null !== $status) {
        $query
            ->andWhere('orders.status = :status')
            ->setParameter('status', $status);
    }

    return $query;
}

Then paginate it in the controller:

1
2
// src/Controller/OrderController.php
$orders = $paginator->paginate($repository->queryForStatus($status));

This numbered-pagination example keeps the repository ORDER BY. For cursor pagination, leave the source query unordered and configure the order with cursor()->orderBy() instead.

Counts and joins

The adapter uses a plain query when joins cannot duplicate root rows. For collection-valued or unknown joins, it uses Doctrine's paginator and counts distinct root entities.

The bundle deliberately rejects inferred counts for GROUP BY, HAVING and multiple root aliases. Provide an explicit total:

1
2
3
4
5
// src/Controller/ReportController.php
$rows = $paginator
    ->query($reportQuery)
    ->total(fn (): int => $reportRepository->countRows($filters))
    ->paginate();

The callable is lazy and evaluated at most once. It may also be an invokable service injected into the controller. If the UI does not need a total, use lookahead() instead.

Cursor indexes

Match the index to the equality filters and the cursor order. For a tenant feed ordered by creation date and identifier:

1
2
3
-- migrations/Version20260101000000.php (equivalent SQL)
CREATE INDEX event_tenant_cursor_idx
    ON event (tenant_id, created_at DESC, id DESC);

The matching query filters on the tenant and orders on the indexed columns:

1
2
3
4
5
6
// src/Controller/EventController.php
$events = $paginator
    ->cursor($repository->queryForTenant($tenant))
    ->orderBy(['createdAt', 'id'], 'DESC')
    ->context('tenant:'.$tenant->getId())
    ->paginate();

Use EXPLAIN on production-like data. An index that works for the first page should also serve the cursor boundary predicate.

Cursor restrictions

Cursor fields must be non-nullable mapped scalar fields on the root entity. Associations, unmapped aliases, nullable fields and invalid field names are rejected against ORM metadata. Supported Doctrine types include small, regular and big integers, string, GUID, float, decimal, boolean, datetime and datetimetz fields.

The adapter appends every missing entity identifier field to make the effective order deterministic. The direction passed to orderBy() applies to every requested and appended field. Mixed directions are not supported.

Metadata validation prevents DQL injection, but it is not an application sorting policy. Map request sort values to a fixed allow-list before passing field names to orderBy().

The source query must contain one root entity and no existing ORDER BY. Configure its complete order through cursor()->orderBy().

Doctrine DBAL

Pass a DBAL QueryBuilder through the same API when the application needs SQL-level control or associative rows without ORM hydration:

1
2
3
4
5
6
7
8
9
10
11
12
// src/Controller/AuditLogController.php
$query = $connection->createQueryBuilder()
    ->select('entry.id', 'entry.created_at', 'entry.message')
    ->from('audit_entry', 'entry')
    ->andWhere('entry.tenant_id = :tenant')
    ->setParameter('tenant', $tenant->getId());

$entries = $paginator
    ->cursor($query)
    ->orderBy(['entry.created_at', 'entry.id'], 'DESC')
    ->perPage(50)
    ->paginate();

The DBAL adapter supports numbered, lookahead and cursor pagination. It clones the source before adding limits, count wrapping, boundary predicates or order, and returns rows from fetchAllAssociative().

Cursor tokens are automatically bound to the DBAL SQL, the parameter types and the normalized parameter values. Add context() only when the same query must be isolated further by an application boundary that its parameters do not represent.

Unlike ORM, DBAL has no mapping metadata and does not append a primary key. It validates column-name syntax, not whether a column exists or may be exposed as a sort mode. Keep the complete order in application code and map request values through an allow-list.

For numbered pagination, the exact count wraps the filtered source query in a subquery and removes its order and limits. For cursor pagination:

  • pass column names or qualified column names, never request input;
  • select every cursor column under its unqualified name;
  • leave the source without ORDER BY: cursor pagination owns it;
  • use only non-null cursor values;
  • include a unique final column such as the primary key;
  • use one direction for every cursor column;
  • add an index matching equality filters followed by the cursor columns.

The last point is essential for deep-page performance. Cursor pagination avoids OFFSET, but the database still needs a matching index to seek efficiently from the boundary.

DBAL 4.4 or later is required. Version 4.4 introduced the public QueryBuilder::sub() API used to wrap exact-count queries. The adapter is registered only when DBAL is installed.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version