laravel/framework: Fix cursorPaginate() losing union select and join bindings
Contributed by xurshudyan
Part of the framework v13.35.0 release
This release fixes a pagination edge case that has bitten anyone building feeds from multiple tables: when cursorPaginate() was used on a union query whose segments carried bindings outside of their where clauses — think selectRaw() placeholders or conditional join()s — the first page rendered fine, but the second page crashed with SQLSTATE[HY093]: Invalid parameter number. PR #61841 reworks how cursor constraints are reconciled with union bindings in the query builder so that every binding type survives the rewrite. It is a targeted bug fix for the 13.x series with no public API changes, so upgrading from v13.34.0 requires nothing more than pulling the patch.
Category: Bug fix (9)
The symptom: a feed that only paginates once
A typical setup is a merged timeline built from several tables, with a discriminator column produced by selectRaw():
DB::table('videos')->select('id', 'created_at')->selectRaw('? as type', ['video'])
->union(
DB::table('news')->select('id', 'created_at')->selectRaw('? as type', ['news'])->where('published', true)
)
->orderBy('created_at')
->cursorPaginate(15);
Request page one and everything works: no cursor is present, so the builder runs the query as-is. Follow the "next" link and Laravel has to splice the cursor conditions into each union segment before re-running the query. That rewrite is where the bindings fell apart — the 'news' value was silently dropped from the binding list, leaving more placeholders in the SQL than values to fill them, and the driver rejected the statement.
Root cause: rebuilding only the where bindings
Cursor pagination has to modify union queries, and a union's bindings live in their own bucket on the parent builder. Since the refactor in #50884, paginateUsingCursor() handled this by resetting the bucket with setBindings([], 'union') up front and then re-adding each union builder's getRawBindings()['where'] as cursor constraints were attached.
That only works if where clauses are the sole source of bindings in a union. The moment a segment uses selectRaw('? as type', ['video']) or a join() closure with a condition like where('authors.active', true), those bindings belong to the select and join buckets — and the rebuild ignored them entirely. The compiled statement still contained their placeholders, so the counts no longer matched and page two failed.
The fix: collect everything, after the cursor is applied
The new flow in paginateUsingCursor() separates the two concerns. Cursor conditions are added to the union builders first, without any binding bookkeeping interleaved; once that is done, the stale union bucket is cleared exactly once and each union builder's complete binding set is re-collected:
$addCursorConditions($this, null, null, 0);
// The cursor constraints were added to the union queries, so their bindings need to be collected again...
$this->setBindings([], 'union');
$this->getUnionBuilders()->each(function ($unionBuilder) {
$this->addBinding($unionBuilder->getBindings(), 'union');
});
The key change is getBindings() instead of getRawBindings()['where']. getBindings() merges every binding bucket of the union builder in compiled order — select, join, where, and so on — including the cursor wheres that were just added. Order is preserved per segment, which is exactly what the union compiler expects.
What page two compiles to now
The PR adds a regression test, testCursorPaginateWithUnionSelectAndJoinBindings, that unions a videos query with a news query joined to authors. With a cursor applied, it asserts this SQL (formatted for readability):
(select "id", "created_at", ? as type from "videos" where ("created_at" > ?))
union
(select "id", "created_at", ? as type from "news"
inner join "authors" on "authors"."id" = "news"."author_id" and "authors"."active" = ?
where ("created_at" > ?))
order by "created_at" asc limit 17
and this binding list:
['video', $ts, 'news', true, $ts]
Five placeholders, five values, in the order each segment declares them: the select binding and cursor where for the base query, then the select, join, and cursor where for the unioned side. Previously this was the textbook failure case; now it paginates indefinitely.
Upgrade impact
There is nothing to migrate. The change is confined to paginateUsingCursor() in Illuminate\Database\Concerns\BuildsQueries, and the signature of cursorPaginate() is untouched. Behavior without a cursor (the first request) is identical, since the re-collection only runs when a cursor is present. Two practical notes: if you worked around the bug by avoiding selectRaw() in unioned, paginated queries, or by manually re-adding bindings after pagination, you can remove those workarounds. And if your application silently swallowed HY093 errors on deep pagination, it is worth re-testing those feeds after upgrading, because second and subsequent pages will now execute correctly rather than throwing.
Takeaways
cursorPaginate()no longer dropsselectRaw()andjoin()bindings from union queries when a cursor is applied.- The fix replaces the where-only binding rebuild from #50884 with a full re-collection via
getBindings()after cursor constraints are added. - Affected applications saw
SQLSTATE[HY093]: Invalid parameter numberon page two and beyond; that failure is resolved. - No API or config changes, and first-page behavior is unchanged — upgrading from v13.34.0 is a simple patch bump.
- A regression test covering union select and join bindings is included.