Skip to content

The orWhere That Returns Your Whole Table

September 15, 2026 • 3 min read

If your app shows customers their outstanding invoices, the query probably started out like this. It’s meant to return the invoices that are sent and awaiting payment, or already past their due date:

Invoice::query()
    ->where('user_id', $user->id)
    ->where('status', 'sent')
    ->orWhere('due_at', '<', now())
    ->get();

It looks like it’s limited to one customer. Instead, it returns every overdue invoice in the table, for every customer you have.

I ran it against 100,000 invoices: 500 customers with 200 invoices each, split evenly across draft, sent, paid and void. For user 7, the right answer is 124 invoices. The query returned 50,043, and 49,919 of them belonged to the other 499 customers.

Let’s look at the SQL this builds, and two ways to group the orWhere so it stays inside the query.

Reading the SQL

Here’s what the builder produces:

select * from `invoices`
where `user_id` = 7 and `status` = 'sent' or `due_at` < '2026-09-15'

AND binds tighter than OR in SQL, so every database reads it as:

where (`user_id` = 7 and `status` = 'sent') or (`due_at` < '2026-09-15')

The second branch doesn’t mention user_id, so any overdue invoice matches it on its own.

Grouping it in a closure

Wrap the OR branch in a closure, and Laravel puts it in parentheses:

use Illuminate\Database\Eloquent\Builder;

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')

That returns the 124 rows. Conditions that must always hold, like the user_id, stay outside the group. The conditions being OR’d together go inside it.

Scopes group it for you

If you move the same two lines into a query scope, you don’t need the closure:

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')

The SQL is identical to the closure version, and it returns the same 124 rows. When a scope adds an or condition, Eloquent wraps that scope’s conditions in their own group. You can see the check in Eloquent\Builder::groupWhereSliceForScope().

So the same two lines behave differently depending on where they live. Written inline, they leak out of the query. In a scope, they stay inside it. That’s a good reason to keep OR conditions in scopes.

What about whereAny()?

whereAny() and whereNone() also add parentheses, so they often come up as the fix. They compare one value against several columns, like a search box matching a term against status or reference. They can’t express “status is sent or the due date has passed”, because those are two different comparisons. For that you still need a closure or a scope.

Catching it in tests and review

A test that creates one customer with a few invoices will pass, because that customer’s overdue invoices are the only overdue invoices in the table. It can only fail once a second customer has an overdue invoice too, so add one.

In review, toRawSql() shows the finished statement with the bindings filled in, so you can see whether the parentheses are there:

Invoice::query()
    ->where('user_id', $user->id)
    ->where('status', 'sent')
    ->orWhere('due_at', '<', now())
    ->toRawSql();

So whenever a query has an orWhere, group it in a closure or move it into a scope. Check the queries filtered by a user_id, team_id or tenant_id first, because there the extra rows belong to someone else.