Reader Stacks

Laravel Eloquent Where Clauses: A Practical Reference

whereIn, whereNull, orWhere, and a handful of siblings cover almost every filtering need — the one that actually causes bugs is orWhere's grouping behavior when mixed with other conditions.

Beyond a plain where('column', 'value'), Eloquent's query builder has a specific method for most common filtering patterns — reaching for the dedicated method is both more readable and, in a couple of cases below, avoids a genuine correctness bug that the naive approach falls into.

1. whereIn and whereNotIn

Product::whereIn('category_id', [1, 4, 7])->get();
Product::whereNotIn('status', ['archived', 'deleted'])->get();

Equivalent to chaining several orWhere('category_id', $id) calls, but far more concise for a list of values, and it maps to a single SQL IN (...) clause rather than a chain of ORs.

2. whereNull and whereNotNull

User::whereNull('email_verified_at')->get();     // never verified
User::whereNotNull('deleted_at')->withTrashed()->get(); // soft-deleted

A plain where('column', null) doesn't work reliably for this — SQL's NULL comparison semantics mean = NULL isn't equivalent to IS NULL. whereNull() generates the correct SQL for a null check specifically.

3. Multiple where() conditions — implicit AND

Order::where('status', 'completed')
    ->where('total', '>', 100)
    ->get();

Chained where() calls combine with AND by default — this is the most common pattern and needs no special syntax.

4. orWhere — and the grouping trap

// BUG: this doesn't do what it looks like it does
Order::where('status', 'completed')
    ->orWhere('status', 'processing')
    ->where('total', '>', 100)
    ->get();

This reads as "completed or processing, both over $100" — but SQL's operator precedence means it actually evaluates as status = 'completed' OR (status = 'processing' AND total > 100), which returns every completed order regardless of total. This is a genuine, easy-to-miss bug, not just a style issue.

// Correct: group the OR conditions explicitly
Order::where(function ($query) {
    $query->where('status', 'completed')
        ->orWhere('status', 'processing');
})
->where('total', '>', 100)
->get();

Passing a closure to where() wraps its contents in parentheses in the generated SQL — this is the correct way to group a set of OR conditions so they're evaluated together, before being combined with an outer AND.

5. whereBetween and whereNotBetween

Order::whereBetween('total', [50, 200])->get();
Order::whereBetween('created_at', [$startDate, $endDate])->get();

6. whereColumn — comparing two columns on the same row

Order::whereColumn('shipped_at', '>', 'promised_at')->get(); // shipped late

whereColumn() is specifically for comparing two columns against each other, rather than a column against a fixed value — a plain where('shipped_at', '>', 'promised_at') would incorrectly treat 'promised_at' as a literal string value to compare against, not as a reference to another column.

7. Combining several of these together

Order::whereIn('status', ['completed', 'processing'])
    ->whereNotNull('shipped_at')
    ->whereBetween('created_at', [now()->subMonth(), now()])
    ->get();

All of these compose naturally in one chain — each adds its own clause to the same underlying query, combined with AND unless explicitly grouped otherwise as shown above.

Topics: Database Queries & Eloquent