Model::whereHas

Filters models by a constrained relationship, compiling the check into an EXISTS subquery.

Signature
public function whereHas(\Illuminate\Database\Eloquent\Relations\Relation|string $relation, ?\Closure $callback = null, string $operator = '>=', \Illuminate\Contracts\Database\Query\Expression|int $count = 1): $this

Parameters

ParameterTypeDescription
$relation\Illuminate\Database\Eloquent\Relations\Relation|stringThe relationship name, optionally using dot syntax for nested relationships, or a Relation instance.
$callbackoptional?\Closure= nullReceives the related model's builder so you can constrain the subquery. Null applies no extra constraint.
$operatoroptionalstring= '>='Comparison operator applied to the related row count.
$countoptional\Illuminate\Contracts\Database\Query\Expression|int= 1The number of related rows the comparison is made against.

Return value

$thisThe same builder instance, so calls can be chained. Delegates to has() with the 'and' boolean.

Errors

Throws Illuminate\Database\Eloquent\RelationNotFoundException when the named relationship does not exist on the model.

whereHas() filters the parent query by whether a relationship has matching rows. With the default operator and count it compiles to a correlated EXISTS subquery; supply an operator and count and it becomes a (select count(*) ...) >= n comparison instead.

Filtering, not loading

This is the point people miss. whereHas() decides which parents come back. It does not load the relationship, and it does not filter the relationship once loaded. Combining it with with() requires stating the constraint twice — once to select the parents, once to constrain what gets eager loaded — because the two operate on different queries. Repeating the closure is normal and correct, not a bug.

What trips people up

has() is the version without a closure, and whereRelation() is the compact form for a single condition: whereRelation('comments', 'is_approved', false) reads better than a one-line closure. Dot syntax nests — whereHas('comments.images', ...) constrains the deepest relationship.

The subquery is correlated, so it re-evaluates per candidate row. Without an index on the child's foreign key, and on whatever the closure filters, this is the query that turns a fast page slow. Check the plan before adding a third nested level.

Chaining several whereHas() calls means AND across separate subqueries — each relationship must independently match.

Example

Example · php
<?php

use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;

// Posts with at least one comment whose content is like 'code%'.
$posts = Post::whereHas('comments', function (Builder $query) {
    $query->where('content', 'like', 'code%');
})->get();

echo $posts->count(), "\n";

// Posts with at least ten such comments.
$posts = Post::whereHas('comments', function (Builder $query) {
    $query->where('content', 'like', 'code%');
}, '>=', 10)->get();

echo $posts->count(), "\n";

// No constraint needed? has() is shorter.
echo Post::has('comments')->count(), "\n";

When to use it

  • List posts that have at least one approved comment.
  • Find users belonging to a role with a specific permission through a nested relationship.
  • Restrict an admin listing to orders containing a product from a given category.

Frequently asked questions

What is the difference between with() and whereHas()?

with() loads a relationship for the models you retrieve; whereHas() decides which models you retrieve in the first place. They run separate queries, so a constraint given to whereHas() has no effect on what with() loads. When you want both a filtered parent list and a filtered relation, pass the same closure to each.

Why are my relationship constraints ignored when I use with()?

Because whereHas() constrains only the EXISTS subquery that selects parents, while with() issues an independent query for the related rows. Repeat the constraint inside the eager-load closure — with(['comments' => fn ($q) => $q->where('is_approved', true)]) — to filter what actually gets attached to each model.

Is whereHas() slow on large tables?

It can be, because it compiles to a correlated subquery that is evaluated against candidate parent rows, and nesting with dot syntax adds another level. Index the child table's foreign key and every column the closure filters on. When the subquery still dominates, a join with a distinct or a whereIn() against a subquery selecting parent ids is often the faster rewrite.

More examples

Filtering parents versus filtering the loaded relation

The two comment counts differ even though the same set of posts is returned. The first query's with() runs an unconstrained second query, so unapproved comments come along; the second constrains both queries and the loaded collection matches the filter.

Example · php
<?php

use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;

$approved = fn (Builder $query) => $query->where('is_approved', true);

// Selects the right posts, but loads EVERY comment on them.
$posts = Post::whereHas('comments', $approved)
    ->with('comments')
    ->get();

echo $posts->first()->comments->count(), "\n";   // includes unapproved

// State it twice: once to filter posts, once to filter the eager load.
$posts = Post::whereHas('comments', $approved)
    ->with(['comments' => $approved])
    ->get();

echo $posts->first()->comments->count(), "\n";   // approved only

Nesting, counts, and the compact form

The first count covers users who own at least one post carrying at least one flagged comment. The third shows two independent EXISTS subqueries joined with AND — a post needs both some comment and a php tag, not one row satisfying both.

Example · php
<?php

use App\Models\User;
use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;

// Dot syntax constrains the deepest relationship.
$users = User::whereHas('posts.comments', function (Builder $query) {
    $query->where('flagged', true);
})->get();
echo $users->count(), "\n";

// A single condition reads better with whereRelation().
$posts = Post::whereRelation('comments', 'is_approved', false)->get();
echo $posts->count(), "\n";

// Chained calls are AND - each relationship must match independently.
$posts = Post::whereHas('comments')
    ->whereHas('tags', fn (Builder $q) => $q->where('slug', 'php'))
    ->get();
echo $posts->count(), "\n";

Absence, and the index that makes it fast

Both counts come from NOT EXISTS subqueries, which is far cheaper than loading every customer and rejecting those with orders in PHP. The closing comment is the performance rule: the subquery filters on the foreign key plus whatever the closure touches, and those are the columns that need indexes.

Example · php
<?php

use App\Models\Customer;
use Illuminate\Database\Eloquent\Builder;

// The inverse: customers with no orders at all.
$dormant = Customer::doesntHave('orders')->get();
// where not exists (select * from orders where ...)

echo $dormant->count(), "\n";

// Customers with no *paid* orders.
$unpaid = Customer::whereDoesntHave('orders', function (Builder $query) {
    $query->where('status', 'paid');
})->get();

echo $unpaid->count(), "\n";

// The correlated subquery re-runs per candidate row.
// Index orders.customer_id and orders.status, or this table-scans.

Discussion

  • Be the first to comment on this lesson.