Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Laravel Double Where: Chaining, orWhere, and the Grouping Trap

Sean

Platform Writer

Sep 05, 2026
8 min read

Chaining two where calls in Laravel joins them with AND, which is what almost everyone expects. Adding an orWhere into that chain does not do what almost anyone expects, because SQL evaluates AND before OR and the resulting parentheses are not where you assumed they were.

Laravel Double Where: Chaining, orWhere, and the Grouping Trap

This is one of those bugs that does not throw an exception. The query runs, rows come back, and they are the wrong rows — usually more of them than there should be. It surfaces weeks later when someone notices a listing showing records that should have been filtered out, and by then nobody connects it to the query builder.

Table of contents

Chaining where clauses

Consecutive where calls are joined with AND. Three equivalent ways to express the same thing:

// Chained
$users = User::where('active', true)
              ->where('role', 'admin')
              ->where('votes', '>', 100)
              ->get();

// Array of conditions, implicitly AND
$users = User::where([
    ['active', true],
    ['role', 'admin'],
    ['votes', '>', 100],
])->get();

// Associative array, equality only
$users = User::where([
    'active' => true,
    'role'   => 'admin',
])->get();

All three produce WHERE active = 1 AND role = ‘admin’. The chained form is the most readable and the one to prefer, particularly once conditions become conditional.

The two-argument form assumes equality, so where(‘active’, true) and where(‘active’, ’=’, true) are the same. Any other operator needs the three-argument form.

Worth knowing alongside these: whereIn, whereBetween, whereNull, whereNot, whereDate, and whereColumn all exist and read considerably better than constructing the equivalent with raw operators.

Where orWhere goes wrong

Here is the bug. Consider a query intended to find active users who are either administrators or moderators.

// WRONG
$users = User::where('active', true)
              ->where('role', 'admin')
              ->orWhere('role', 'moderator')
              ->get();

That generates:

SELECT * FROM users
WHERE active = 1 AND role = 'admin' OR role = 'moderator'

SQL binds AND more tightly than OR, so this is read as (active = 1 AND role = ‘admin’) OR (role = ‘moderator’). Every moderator is returned regardless of whether they are active. Inactive moderators appear in a list that was supposed to exclude inactive users.

The query does not fail. It returns a plausible-looking result set that is quietly wrong, which is why this survives code review and testing on a dataset where no inactive moderators happen to exist.

The fix is to group the OR conditions in a closure:

// RIGHT
$users = User::where('active', true)
              ->where(function ($query) {
                  $query->where('role', 'admin')
                        ->orWhere('role', 'moderator');
              })
              ->get();

Which produces the parentheses you meant:

SELECT * FROM users
WHERE active = 1 AND (role = 'admin' OR role = 'moderator')

For this specific shape, whereIn is simpler and clearer than either:

$users = User::where('active', true)
              ->whereIn('role', ['admin', 'moderator'])
              ->get();

Logical grouping, nested both ways

A closure passed to where opens a parenthesised group joined with AND. A closure passed to orWhere opens one joined with OR. Both nest arbitrarily.

$orders = Order::where('status', 'pending')
    ->where(function ($q) {
        $q->where('total', '>', 1000)
          ->orWhere(function ($q2) {
              $q2->where('priority', 'high')
                 ->where('customer_tier', 'gold');
          });
    })
    ->get();
SELECT * FROM orders
WHERE status = 'pending'
  AND (total > 1000 OR (priority = 'high' AND customer_tier = 'gold'))

The closure receives the query builder, and inside it you use the same methods you would outside. The variable name is conventional — $query or $q — and only its scope matters.

Remember that variables from the enclosing scope are not automatically available inside the closure. PHP requires them to be imported:

$minTotal = 1000;

$orders = Order::where('status', 'pending')
    ->where(function ($q) use ($minTotal) {
        $q->where('total', '>', $minTotal)
          ->orWhere('priority', 'high');
    })
    ->get();

Omitting the use clause gives an undefined variable, and depending on your error settings that becomes a null comparison rather than a visible failure — another way this class of bug hides.

Global scopes make ungrouped orWhere worse

This is the reason the Laravel documentation says you should always group orWhere calls, rather than saying you should usually group them.

Global scopes — including SoftDeletes, which is on a large share of real models — add their own conditions to every query. A soft-deletable model quietly appends AND deleted_at IS NULL.

Now consider the ungrouped version again on such a model:

SELECT * FROM users
WHERE users.deleted_at IS NULL
  AND active = 1 AND role = 'admin'
   OR role = 'moderator'

The OR now escapes the soft-delete condition as well. Deleted moderators are returned. Records the application believes no longer exist appear in a listing, and the eventual bug report is about ghost users rather than about a query builder chain.

The same applies to any custom global scope — multi-tenancy scopes being the most alarming case. A tenant scope of AND tenant_id = 7 escaped by an ungrouped OR means one tenant sees another tenant’s rows. That is a data-isolation failure produced by a missing pair of parentheses.

So the rule is unconditional: any time an orWhere appears in a chain that also contains a where, wrap the OR group in a closure. There is no case where doing so is wrong, and several where omitting it is severe.

Conditional clauses without if statements

Building a query from optional filters is the usual reason a chain grows complicated. Laravel’s when method handles it without breaking the chain.

$users = User::query()
    ->when($request->role, function ($q, $role) {
        $q->where('role', $role);
    })
    ->when($request->active !== null, function ($q) use ($request) {
        $q->where('active', (bool) $request->active);
    })
    ->when($request->search, function ($q, $search) {
        $q->where(function ($q2) use ($search) {
            $q2->where('name', 'like', "%{$search}%")
               ->orWhere('email', 'like', "%{$search}%");
        });
    })
    ->paginate(25);

Note the search filter. It has an OR inside it and is therefore wrapped in a closure — exactly the pattern from the previous section, and exactly the place people forget, because the OR is buried inside a when callback rather than sitting visibly in the main chain.

when passes the truthy value as the second argument to the callback, which is why $role and $search are available without a use clause while the boolean case needs one.

There is also unless for the inverse, and both accept a second callback as an else branch.

Check the SQL rather than assuming

Given that the failure mode here is silently wrong rows, the habit worth building is looking at the generated SQL whenever a query contains an OR.

// The SQL with placeholders
$query = User::where('active', true)->orWhere('role', 'admin');
dd($query->toSql());

// The bound values
dd($query->getBindings());

// Both together, in Laravel 10 and later
dd($query->toRawSql());

Read the parentheses in the output. If they are not where you intended, add a closure. That check takes five seconds and catches the entire class of bug described here.

For a broader view, enable query logging in development or use a debug bar that shows every query a request issued. Beyond correctness, that surfaces the other common Eloquent problem — a relationship accessed inside a loop generating one query per row instead of one query total, which the with method fixes by eager loading.

And once the SQL is correct, run EXPLAIN on it. A query returning the right rows can still be doing so by scanning a table that should have an index on the columns you are filtering by.

How this fits the rest of the stack

Chained where calls are AND, and an orWhere mixed into that chain escapes the grouping you intended — including any global scope such as soft deletes or a tenancy filter, which is what turns a formatting nit into a data-isolation bug. Wrap every OR group in a closure, and read toSql when a query contains one. Once the rows are right, the next question is whether the database is doing the work efficiently, which is a matter of indexes and query plans. The RunxBuild hosting calculator shows the managed database alongside the PHP service that queries it.

Useful related references:

FAQ

How do I use multiple where clauses in Laravel?

Chain them — each consecutive where is joined with AND. You can also pass an array of conditions or an associative array for simple equality. The chained form is clearest, especially once conditions become optional, at which point the when method adds them without breaking the chain.

Why does my Laravel orWhere return too many rows?

Because SQL evaluates AND before OR, so where(‘a’,1)->where(‘b’,2)->orWhere(‘c’,3) parses as (a AND b) OR c rather than a AND (b OR c). Wrap the OR conditions in a closure passed to where, which produces the parentheses you intended.

How do I group where clauses in Laravel?

Pass a closure to where or orWhere. The closure receives the query builder and everything inside it is wrapped in parentheses, joined to the outer query with AND or OR respectively. Closures nest, so arbitrarily complex boolean logic is expressible this way.

Why does the Laravel documentation say to always group orWhere calls?

Because global scopes add conditions to every query, and an ungrouped OR escapes them. On a soft-deletable model that means deleted records reappear. On a model with a tenancy scope it means one tenant can see another tenant’s rows, which is a data-isolation failure caused by missing parentheses.

How can I see the SQL a Laravel query generates?

Call toSql() for the statement with placeholders and getBindings() for the values, or toRawSql() in Laravel 10 and later for both combined. Do this whenever a query contains an OR and check that the parentheses match your intent — it takes seconds and catches the whole class of grouping bug.

#laravel#eloquent#query builder#orwhere#php