Reader Stacks

Filtering by Relationship Existence in Laravel (whereHas)

"Users who have at least one order over $100" can't be expressed with a normal where() clause — whereHas() is specifically for filtering a query based on a related model's data.

A query like "users who have at least one order over $100" filters the base model (User) based on a condition that actually lives on a related model (Order) — a plain where() clause on User has no way to express that, since total isn't a column on the users table at all. whereHas() exists specifically for this shape of query.

1. Basic usage

$users = User::whereHas('orders', function ($query) {
    $query->where('total', '>', 100);
})->get();

This returns every User that has at least one related Order matching the closure's condition — under the hood, it's implemented as a correlated subquery checking for the existence of at least one matching related row, without actually loading those related orders into the result.

2. whereHas() without a condition — just checking the relationship isn't empty

$usersWithOrders = User::whereHas('orders')->get();

Without a closure, this simply filters to users who have at least one related order at all, regardless of its contents — the relationship-existence check alone, with no further filtering on the related model's own columns.

3. The inverse: whereDoesntHave()

$usersWithNoOrders = User::whereDoesntHave('orders')->get();

$usersWithNoRecentOrders = User::whereDoesntHave('orders', function ($query) {
    $query->where('created_at', '>=', now()->subMonths(6));
})->get();

whereDoesntHave() is the direct opposite — users with zero matching related records. The second example (customers with no order in the last six months) is a common, genuinely useful "at-risk" or re-engagement query that's awkward to express any other way.

4. whereHas() doesn't load the relationship — it only filters

// This filters users but doesn't give you their orders — orders is NOT loaded
$users = User::whereHas('orders', fn ($q) => $q->where('total', '>', 100))->get();
$users->first()->orders; // triggers a separate query per user (N+1) if accessed like this
// Correct: filter AND eager-load in one combined call
$users = User::whereHas('orders', fn ($q) => $q->where('total', '>', 100))
    ->with(['orders' => fn ($q) => $q->where('total', '>', 100)])
    ->get();

This is a common point of confusion — whereHas() only affects which parent models are returned, it has no effect on what's eager-loaded. If the matching related records are also needed afterward, a separate with() call (ideally with the same filtering condition, to keep only the relevant related rows) is still required.

5. Aggregating instead of just checking existence: whereHas with a count

$loyalCustomers = User::has('orders', '>=', 5)->get(); // 5+ orders, any total

has() is the simpler sibling of whereHas() for pure count-based conditions — no closure needed when the check is just "at least N related records," rather than filtering on the related model's own column values.

6. Nested relationship checks

$users = User::whereHas('orders.items', function ($query) {
    $query->where('product_id', 42);
})->get();

Dot notation reaches through a chain of relationships — this finds users who have an order containing a specific product, checking two relationship hops deep (Userordersitems) in one query, without manually joining or looping through each level.

Topics: Database Queries & Eloquent