The orWhere That Returns Your Whole Table
September 15, 2026 • 5 min read
This query looks like it filters invoices down to one customer. It doesn’t.
Invoice::query()
->where('user_id', $user->id)
->where('status', 'sent')
->orWhere('due_at', '<', now())
->get();It returns every overdue invoice in the table, for every customer you have. No error, no warning, no failing test - just a result set that’s wrong in the one direction nobody checks.
The SQL and row counts below are from a real run against an invoices table of 100,000 rows: 500 customers with 200 invoices each, evenly split across draft, sent, paid and void, and 101 of user 7’s invoices past due.
What you asked for versus what you get
The intent is “this customer’s invoices that are either awaiting payment or overdue”. Three chained methods, reading left to right, look like they say exactly that. Here’s the SQL the builder actually produces:
select * from `invoices`
where `user_id` = 7 and `status` = 'sent' or `due_at` < '2026-09-15'AND binds tighter than OR - standard SQL, not a Laravel or MySQL quirk. Every engine reads that as:
where (`user_id` = 7 and `status` = 'sent') or (`due_at` < '2026-09-15')The second branch stands alone. Nothing in it mentions user_id, so every overdue row in the table satisfies the query on its own.
+---------------+------------------------+------------------------+
| rows_returned | belong_to_someone_else | distinct_users_exposed |
+---------------+------------------------+------------------------+
| 50043 | 49919 | 500 |
+---------------+------------------------+------------------------+The correct answer is 124 rows. This returns 50,043, and 49,919 of them belong to other people - every customer in the table.
User 7 only has 200 invoices. The result is 250 times larger than anything that customer could legitimately see, which is the part worth sitting with: this is not an off-by-a-few. A scoping bug of this shape doesn’t degrade the answer, it discards the scope.
Why it survives review
The bug is invisible at the call site. Each line is individually correct, the method names read like English, and the chain looks like a list of filters that accumulate. Nothing in the fluent syntax hints that one of these binds tighter than the others.
It also tends to survive testing. A factory that builds one customer with a handful of invoices produces a table where “this customer’s overdue invoices” and “all overdue invoices” are the same set. The test passes. The query is wrong. You need a second customer with overdue invoices in the fixture before the assertion can fail, and that’s exactly the row nobody bothers to create.
Then it reaches production, where the table has 500 customers in it.
The fix
Group the OR branch in a closure. Everything inside gets wrapped in parentheses:
Invoice::query()
->where('user_id', $user->id)
->where(function (Builder $query) {
$query->where('status', 'sent')
->orWhere('due_at', '<', now());
})
->get();select * from `invoices`
where `user_id` = 7 and (`status` = 'sent' or `due_at` < '2026-09-15')124 rows. The user_id condition now applies to both branches, because there is only one branch left at the top level.
The rule this comes down to: the moment a query contains a single orWhere, every AND condition that must always hold has to sit outside a group, and everything being OR’d together has to sit inside one. A flat chain can express one or the other, never both.
Scopes already do this
Here’s the part that catches people who already know about the closure fix. Move those same two lines into a query scope:
public function scopeOutstanding(Builder $query): void
{
$query->where('status', 'sent')
->orWhere('due_at', '<', now());
}Invoice::query()->where('user_id', $user->id)->outstanding()->get();select * from `invoices`
where `user_id` = 7 and (`status` = 'sent' or `due_at` < '2026-09-15')124 rows - identical SQL to the closure version. Eloquent slices the scope’s conditions off from the ones that came before it and wraps them in their own group, but only when that slice contains an or - the framework skips the parentheses otherwise to keep the SQL clean. You can read the check in Eloquent\Builder::groupWhereSliceForScope(). The effect is that a scope can’t leak its OR into the query that called it.
Which means the same two lines behave differently depending on where you put them. Inline, they break out of the query. In a scope, they don’t. That’s a good reason to reach for a scope for any condition with an OR in it, and a bad reason to assume you understand a query by reading its call site.
whereAny, whereAll and whereNone
whereAny and whereAll were first introduced in Laravel 10.47 following a pull request contribution by @musiermoore, with whereNone completing the set in Laravel 11.19 via a pull request from @einar-hansen. All three wrap their conditions in parentheses for you, which is why they get offered as the fix whenever this bug comes up. What they group, though, is one comparison across several columns:
Invoice::query()->where('user_id', 7)->whereAny(['status', 'reference'], 'like', '%sent%');select * from `invoices`
where `user_id` = 7 and (`status` like '%sent%' or `reference` like '%sent%')That’s a search box - match one term against any of these columns - and they’re genuinely good at it. What they can’t express is “status equals sent or due date is in the past”, because that’s two different comparisons. For that you still want the closure.
whereNone is the useful sibling, negating the whole group:
where `user_id` = 7 and not (`status` = 'void')Worth knowing they exist. Worth knowing they aren’t the fix for this.
In practice
Read the SQL. ->toRawSql() on any builder gives you the finished statement with bindings substituted, and the parentheses are either there or they aren’t:
Invoice::query()
->where('user_id', $user->id)
->where('status', 'sent')
->orWhere('due_at', '<', now())
->toRawSql();If you scan one thing in review, scan for an orWhere that isn’t inside a closure or a scope. Every one of them is a query whose other conditions may not apply, and the ones that matter are the queries where a user_id, a team_id or a tenant_id is doing the scoping. Those don’t return wrong data. They return someone else’s.