Skip to content

Fix HY093 invalid parameter number when combining search() with an outer join - #234

Open
kima92 wants to merge 1 commit into
nicolaslopezj:masterfrom
kima92:fix/hy093-binding-order-with-outer-join
Open

Fix HY093 invalid parameter number when combining search() with an outer join#234
kima92 wants to merge 1 commit into
nicolaslopezj:masterfrom
kima92:fix/hy093-binding-order-with-outer-join

Conversation

@kima92

@kima92 kima92 commented Jul 11, 2026

Copy link
Copy Markdown

Problem

mergeQueries() produces a query with mis-ordered bindings when a searchable query is combined with an outer join and then paginated, throwing:

SQLSTATE[HY093]: Invalid parameter number

Reproduction

The trigger is a bound condition inside a join() closure (its binding lands in the query's join group):

Model::query()
    ->join('pivot', function ($join) use ($someId) {
        $join->on('pivot.model_id', '=', 'models.id')
             ->where('pivot.some_id', $someId);   // binding lives in the "join" group
    })
    ->search($term)
    ->paginate();                                 // paginate() wraps the query in a COUNT sub-query -> HY093

The first relevance LIKE ? ends up bound to $someId, every binding shifts by one, and the parameter count no longer matches the placeholders.

Note: a top-level ->where('pivot.some_id', $someId) does not trigger this — that binding sits in the where group, the same group the sub-query's bindings are flattened into, so it is appended after them and stays aligned. Only a binding in a group that getBindings() emits before wherejoin, from or select (e.g. a condition inside a join() closure) — is misordered.

Root cause

mergeQueries() wraps the relevance query in a raw FROM sub-query and then flattens every binding into the where group:

$mergedBindings = array_merge_recursive($clone->getBindings(), $original->getBindings());
$original->withoutGlobalScopes()->setBindings($mergedBindings);

getBindings() returns a flat array, so this discards Laravel's binding-type information. When the final SQL is assembled, Builder::getBindings() concatenates the buckets in a fixed order — select → from → join → where → having → order → …. Any binding that legitimately belongs to an earlier bucket on the outer query (e.g. a condition inside a join() closure, whose binding is in the join bucket) is therefore ordered ahead of the sub-query's own bindings — even though, positionally, the sub-query's placeholders appear first in the compiled SQL. The lists no longer line up → HY093.

Fix

Register the sub-query with fromRaw() so its bindings land in the from bucket (the correct position, before join/where), and leave the outer query's own bindings untouched in their respective buckets:

$original->withoutGlobalScopes();
$original->getQuery()->fromRaw($subQuery, $clone->getBindings());
  • No change to the generated SQLfromRaw() compiles the sub-query identically to the previous from(raw(...)); only the binding placement changes.
  • The withoutGlobalScopes() behavior is preserved (global scopes are already baked into the sub-query).
  • Binding order now always matches placeholder order, so any combination of joins / wheres on the outer query paginates correctly.

Testing

Verified against a real Laravel 12 app (MySQL): a searchable model paginated with a join() closure carrying a bound where reproduces HY093 on the current code and succeeds after this change; the full matrix (search+join, search-only, join-only, neither) passes. A top-level where (in the where group) was confirmed unaffected either way. Generated SQL diffed byte-for-byte identical to the previous implementation.

…ter join

mergeQueries() flattened every binding into the "where" group via
array_merge_recursive() + setBindings(), discarding Laravel's binding-type
information. getBindings() re-assembles bindings in a fixed group order
(select, from, join, where, ...), so a binding belonging to an earlier group on
the outer query -- e.g. a bound condition inside a join() closure, whose binding
is in the "join" group -- was ordered ahead of the sub-query's own bindings,
even though the sub-query's placeholders come first in the compiled SQL. The
off-by-one produced "SQLSTATE[HY093]: Invalid parameter number" when a
searchable query was combined with such a join and paginated.

A top-level where() is unaffected: its binding is in the "where" group, the same
group the sub-query bindings are flattened into, so it stays aligned.

Register the relevance sub-query with fromRaw() so its bindings stay in the
"from" group and the outer query's own bindings remain in their groups, keeping
binding order aligned with placeholder order. No change to the generated SQL.
@kima92
kima92 force-pushed the fix/hy093-binding-order-with-outer-join branch from 16a69c2 to d5cbfb7 Compare July 11, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant