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 (User → orders → items) in one query, without manually joining or looping through each level.