You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Hydra] Empty cursor-paginated collection omits hydra:next and hydra:previous #7953
When a cursor-paginated Hydra collection returns zero results (e.g. a filter excludes every row), the response's hydra:view contains only @id and @type. Both hydra:next and hydra:previous are missing, leaving clients with no way to navigate back or retry without out-of-band knowledge of the resource's cursor field and items-per-page setting.
Steps to Reproduce
Declare a resource with paginationViaCursor: [['field' => 'id', 'direction' => 'DESC']] and 10 rows.
Request a filtered range that yields no rows:
GET /so_manies?order[id]=desc&id[gt]=10
Expected Behavior
Pre-4.x behavior (asserted by the legacy Behat scenario "Cursor-based pagination with range filtered items" in features/hydra/collection.feature):
hydra:previous is the existing cursor filter shifted by items_per_page in the reverse direction; hydra:next is the cursor filter with the operator inverted (gt → lt).
When $objects is empty, current() and end() both return false, so both branches are skipped and neither key is emitted.
Suggested Fix
When $objects is empty and the request URL contains a cursor filter parameter for a field declared in paginationViaCursor, synthesize both URLs from $parsed['parameters'] directly:
hydra:next — same URL with the cursor operator inverted (gt ↔ lt), value preserved.
hydra:previous — same URL with the cursor operator preserved, value shifted by items_per_page in the cursor direction.
If no cursor filter is present in the request and the result is empty, synthesis is not possible — keep the current behavior (omit the keys).
Additional Context
Discovered while migrating features/hydra/collection.feature to functional tests on the JSON-LD refactor branch. The empty-filtered cursor scenario had to be relaxed because of this gap; non-empty scenarios still pass and assert the exact URLs from the legacy expectation.
The PR for this issue ships the minimal regression fix only (synthesize hydra:next/hydra:previous from URL parameters when the cursor-paginated collection is empty). During review we identified follow-up work that's out of scope here but worth tracking.
Scope shipped in the PR
populateDataWithCursorBasedPagination() gains an empty-collection branch that reads $parsed['parameters'], inverts the operator for next, shifts the value by items_per_page for previous. No change to the non-empty path.
New fixture under tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.php uses the canonical QueryParameter form — ComparisonFilter(new ExactFilter()) + SortFilter, with nativeType: new BuiltinType(TypeIdentifier::INT) — so we don't ship new usages of the legacy #[ApiFilter(RangeFilter::class, …)] / OrderFilter that are slated for deprecation in 4.4 and removal in 6.0 (see TODO-filters.md).
Known limitations to address as follow-ups
Numeric-only cursor shift.cursorPaginationFieldsFromUrl() casts the URL value via (float) and computes $currentValue ± $itemsPerPage. This is correct for sequential auto-increment integer PKs (the common case) but silently breaks for date, UUID, or string cursors. A type-aware shift is needed before we can claim general cursor-pagination support on non-numeric fields.
paginationViaCursor is decoupled from the filter system. The declaration today is [['field' => 'id', 'direction' => 'DESC']] — operation-level metadata that names a property and a direction, but knows nothing about the filter that actually parses ?id[gt]=10. As a result, the normalizer has to reverse-engineer the URL parameter shape (operator-keyed arrays, array_key_first to extract the operator, hard-coded gt/lt) instead of asking the filter system.
The cleaner shape is to express the cursor configuration as a reference to a QueryParameter:
#[ApiResource(
parameters: [
'id' => newQueryParameter(
filter: newComparisonFilter(newExactFilter()),
property: 'id',
nativeType: newBuiltinType(TypeIdentifier::INT),
),
],
// declarative cursor: points at the parameter that owns the cursor URL surface
paginationViaCursor: [['parameter' => 'id', 'direction' => 'DESC']],
)]
With this shape, nativeType becomes implicit, the URL key and operator schema are owned by the filter, and the normalizer stops parsing filter syntax. It also opens the door to non-Doctrine cursor pagination (Laravel, ES) since the contract is metadata-only.
Cursor synthesis logic belongs in a resolver service.PartialCollectionViewNormalizer currently mixes three concerns: collection serialization, cursor URL inspection, and next/previous shift math. Extracting a CursorPaginationFieldsResolver (or similar) under src/State/Pagination/ would:
Let both the populated path (today: PropertyAccessor::getValue($firstObject, $field)) and the empty path (today: URL parsing) go through one API.
Make the JSON:API / GraphQL / hypothetical-format implementations consume the same logic.
Centralize the URL-key contract introduced in (2).
Suggested target
Points 1, 2, 3 form a coherent 4.4 task: refactor paginationViaCursor to reference QueryParameter, build the resolver service, drop the URL surgery from the normalizer. Backwards compatibility for the existing ['field' => …, 'direction' => …] declaration is straightforward (resolver falls back to looking up a parameter by property name when no parameter key is given).
The regression fix in the PR stays as the 4.3 / 4.4 patch landing now; the architectural work happens on top.
Activity
fix(hydra): emit hydra:next and hydra:previous on empty cursor-pagina…
fix(hydra): emit hydra:next and hydra:previous on empty cursor-pagina…
soyuka commentedon May 11, 2026
Findings while preparing the fix
The PR for this issue ships the minimal regression fix only (synthesize
hydra:next/hydra:previousfrom URL parameters when the cursor-paginated collection is empty). During review we identified follow-up work that's out of scope here but worth tracking.Scope shipped in the PR
populateDataWithCursorBasedPagination()gains an empty-collection branch that reads$parsed['parameters'], inverts the operator fornext, shifts the value byitems_per_pageforprevious. No change to the non-empty path.tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.phpuses the canonicalQueryParameterform —ComparisonFilter(new ExactFilter())+SortFilter, withnativeType: new BuiltinType(TypeIdentifier::INT)— so we don't ship new usages of the legacy#[ApiFilter(RangeFilter::class, …)]/OrderFilterthat are slated for deprecation in 4.4 and removal in 6.0 (seeTODO-filters.md).Known limitations to address as follow-ups
Numeric-only cursor shift.
cursorPaginationFieldsFromUrl()casts the URL value via(float)and computes$currentValue ± $itemsPerPage. This is correct for sequential auto-increment integer PKs (the common case) but silently breaks for date, UUID, or string cursors. A type-aware shift is needed before we can claim general cursor-pagination support on non-numeric fields.paginationViaCursoris decoupled from the filter system. The declaration today is[['field' => 'id', 'direction' => 'DESC']]— operation-level metadata that names a property and a direction, but knows nothing about the filter that actually parses?id[gt]=10. As a result, the normalizer has to reverse-engineer the URL parameter shape (operator-keyed arrays,array_key_firstto extract the operator, hard-codedgt/lt) instead of asking the filter system.The cleaner shape is to express the cursor configuration as a reference to a
QueryParameter:#[ApiResource( parameters: [ 'id' => new QueryParameter( filter: new ComparisonFilter(new ExactFilter()), property: 'id', nativeType: new BuiltinType(TypeIdentifier::INT), ), ], // declarative cursor: points at the parameter that owns the cursor URL surface paginationViaCursor: [['parameter' => 'id', 'direction' => 'DESC']], )]With this shape,
nativeTypebecomes implicit, the URL key and operator schema are owned by the filter, and the normalizer stops parsing filter syntax. It also opens the door to non-Doctrine cursor pagination (Laravel, ES) since the contract is metadata-only.Cursor synthesis logic belongs in a resolver service.
PartialCollectionViewNormalizercurrently mixes three concerns: collection serialization, cursor URL inspection, and next/previous shift math. Extracting aCursorPaginationFieldsResolver(or similar) undersrc/State/Pagination/would:PropertyAccessor::getValue($firstObject, $field)) and the empty path (today: URL parsing) go through one API.Suggested target
Points 1, 2, 3 form a coherent 4.4 task: refactor
paginationViaCursorto referenceQueryParameter, build the resolver service, drop the URL surgery from the normalizer. Backwards compatibility for the existing['field' => …, 'direction' => …]declaration is straightforward (resolver falls back to looking up a parameter by property name when noparameterkey is given).The regression fix in the PR stays as the 4.3 / 4.4 patch landing now; the architectural work happens on top.
test(hydra): use canonical QueryParameter form in Issue7953 fixture
test(hydra): isolate Issue7953 fixture table to fix behat collision
test(hydra): add cursor pagination empty collection regression test
fix(hydra): emit hydra:next and hydra:previous on empty cursor-pagina…