Doctrine ORM 3.7 umí stránkovat výsledky dotazu kurzorem místo klauzule OFFSET
Doctrine ORM 3.7.0 vyšlo 7. září 2026 a přineslo druhý způsob stránkování. CursorPaginator posouvá výpis podle hodnot sloupců, podle kterých je dotaz seřazený, místo aby databázi říkal, kolik řádků má přeskočit. Dosavadní třída Paginator je označená za zastaralou a zmizí v příští velké verzi.
Stránkovat dlouhý výpis znamená v SQL obvykle LIMIT a OFFSET: databáze projde dotaz, prvních pár set řádků zahodí a zbytek vrátí. Doctrine ORM na to má třídu Paginator, kterou zná každý, kdo v PHP psal výpis článků nebo objednávek. Ve verzi 3.7.0, vydané 7. září 2026, k ní přibyla druhá cesta: CursorPaginator si místo počtu přeskočených řádků pamatuje, kde předchozí stránka skončila.

Kde OFFSET přestává stačit
Návrh té změny jmenuje dvě potíže, které offsetové stránkování nad velkou tabulkou má. První je výkon: řádky, které klauzule OFFSET přeskakuje, musí databáze nejdřív najít, takže stá stránka stojí víc práce než první. Druhá je stálost výpisu. Když mezi dvěma požadavky někdo záznam přidá nebo smaže, posunou se všechny následující řádky, a čtenář jeden buď přeskočí, nebo ho uvidí na dvou stránkách po sobě.
Kurzor se ptá jinak. Nezajímá ho pořadové číslo řádku, ale jeho místo v setřídění, takže databáze místo přeskakování projede rozsah indexu.
Kurzorem je poslední řádek stránky
Podmínkou je jednoznačné řazení: kombinace hodnot, podle kterých se řadí, smí ve výsledku ukazovat na jediné místo. Dokumentace radí obvyklý recept, tedy řadit podle času a přidat k němu primární klíč jako rozhodčího.
Z posledního řádku stránky si paginator opíše hodnoty řadicích sloupců a zabalí je do kurzoru. Při dalším požadavku z nich složí podmínku, která ve výpisu ukáže rovnou za ně:
SELECT ...
FROM post p
WHERE (p.created_at < :cursor_val_0)
OR (p.created_at = :cursor_val_0 AND p.id < :cursor_id_1)
ORDER BY p.created_at DESC, p.id DESC
LIMIT 16 -- limit + 1
Ten jeden řádek navíc není překlep. Paginator si vyžádá o záznam víc, než je velikost stránky, a podle toho pozná, jestli za ní ještě něco následuje; přebytek pak zahodí. Na stránku zpět obrátí řazení a výsledek na konci otočí do původního pořadí.
V PHP je použití krátké. Sám paginator drží jen nastavení, dotaz i pozici dostane až metoda paginate():
use Doctrine\ORM\Tools\Pagination\CursorPaginator;
$query = $entityManager->createQuery(
'SELECT p FROM BlogPost p ORDER BY p.createdAt DESC, p.id DESC'
);
$paginator = new CursorPaginator(limit: 15);
$page = $paginator->paginate($query, $_GET['cursor'] ?? null);
foreach ($page as $post) {
echo $post->getTitle();
}
echo $page->getNextCursorAsString();
Meze dokumentace jmenuje dvě. Každý sloupec v ORDER BY musí odpovídat poli entity, takže surový výraz SQL ani počítaný sloupec neprojdou. A dotaz, který ORDER BY nemá vůbec, skončí výjimkou LogicException.
Co v kurzoru stojí
Kurzor je obyčejný JSON zakódovaný do base64 bezpečné pro adresu:
{"p.createdAt": "2024-01-15T10:30:00+00:00", "p.id": 42, "_isNext": true}
Klíče jsou cesty k polím z ORDER BY, hodnoty jsou databázová podoba údajů z posledního řádku a příznak _isNext odlišuje kurzor dopředu od kurzoru zpátky. Podepsaný ani šifrovaný není, takže si ho návštěvník přečte i přepíše.
Komentář v kódu vysvětluje, proč to autorům nevadí: jména sloupců se berou ze stromu dotazu DQL, ne z obsahu kurzoru, a hodnoty jdou do dotazu přes vázané parametry. Nejvíc, co přepsaný kurzor svede, je skok na jiné místo výpisu, a i tam dál platí podmínky WHERE původního dotazu. Květnové kolo oprav k tomu přidalo kontroly na vstupu: kurzor, který není řetězec, vyhodí výjimku InvalidCursor, rozbalené hodnoty musí být skalární nebo null a hloubka čteného JSONu je omezená na dvě úrovně.
Staré stránkování má nástupce
Druhá polovina změny se týká offsetu. Třída Paginator je od verze 3.7 zastaralá a zmizet má ve čtyřce; nahrazuje ji OffsetPaginator, postavený na stejný tvar jako ten kurzorový. Pozice se přestala číst potichu z dotazu přes setFirstResult() a setMaxResults() a předává se jako hodnotový objekt Window. Výsledkem je neměnná stránka WindowPage.
use Doctrine\ORM\Tools\Pagination\OffsetPaginator;
use Doctrine\ORM\Tools\Pagination\Window;
$page = (new OffsetPaginator(fetchJoinCollection: true))
->paginate($query, Window::fromPageNumberAndSize(1, 25));
echo $page->getTotalCount();
Praktický rozdíl je v tom, že paginator přestal být jednorázový. Nedrží dotaz ani pozici, takže jeden vystačí na celou aplikaci a dá se zaregistrovat jako služba. A protože je vrácená stránka neměnná, nezmění se pod rukama ve chvíli, kdy si z ní necháte vyrobit další.
Co ještě ve 3.7 přibylo
Vydání zavřelo pětatřicet pull requestů od třinácti přispěvatelů. Kromě stránkování se změnil LockMode::NONE, který se dosud na dvou místech choval jako pesimistický zámek a teď nedělá nic; kdo spoléhal na to, že mu find() znovu načte entitu, musí volat refresh() sám. Atribut JoinColumns je zastaralý a nahrazuje ho opakovaný JoinColumn. U částečných objektů se nově používají nativní líné objekty PHP místo vlastního řešení knihovny.
Na odpis je i řazení psané řetězcem ASC nebo DESC. Doctrine místo něj chce nativní výčtový typ SortDirection, který přinese PHP 8.6, a do jeho vydání si ho bere z balíčku symfony/polyfill-php86 mezi svými závislostmi. Sama knihovna běží dál na PHP 8.1 a novějším; registr Packagist zaznamenal vydání 7. září 2026 ve 21.35 středoevropského letního času.