skip to content

With Eloquent, how do whereHas(), whereRelation(), doesntHave() and whereBelongsTo() filter photos by related rows, and what SQL do they produce?

level: middleimportance: must knowfreq 60%

answer

  1. filter parents, load nothing
  2. WHERE EXISTS correlated subquery
  3. count operator switches to COUNT subquery
  4. whereRelation: one where, shorter
  5. whereBelongsTo guesses relation from class

basics

~20 s

They filter parents by related rows without loading them. whereHas() adds a WHERE EXISTS subquery (COUNT when given a count), doesntHave() adds NOT EXISTS, whereRelation() is whereHas() with one where, and whereBelongsTo() adds a foreign-key WHERE IN.

solid answer

~40 s

`Photo::has('tags')` keeps photos with at least one tag; Eloquent compiles it to `WHERE EXISTS (SELECT ... FROM tags JOIN photo_tag ... WHERE photos.id = photo_tag.photo_id)`. `whereHas('tags', fn ($q) => $q->where('slug', 'sunset'))` puts your constraint inside that subquery, and `whereRelation('tags', 'slug', 'sunset')` is the one-condition shorthand. Passing a count, as in `has('comments', '>=', 5)`, switches to a `(SELECT COUNT(*) ...) >= 5` comparison. `doesntHave('tags')` and `whereDoesntHave()` produce `NOT EXISTS`. `whereBelongsTo($user)` needs no subquery: it finds the `belongsTo` relation named after the class (`user`) and adds `WHERE photos.user_id IN (...)`. None of these load the related models — filtering and loading are separate calls.

code

php · 17 lines
php
<?php

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

$sunsets = Photo::whereRelation('tags', 'slug', 'sunset')->get();

$popular = Photo::has('comments', '>=', 5)->get();

$untagged = Photo::doesntHave('tags')
    ->whereBelongsTo($user)          // WHERE photos.user_id IN (?)
    ->latest()
    ->get();

$flagged = Photo::whereHas('comments', function (Builder $query) {
    $query->where('flagged', true)->where('created_at', '>', now()->subDay());
})->get();

go deeper

for a junior

Recall that whereHas() filters parents by related rows and that doesntHave() finds parents with none.

for a middle

Explain the EXISTS versus COUNT subquery, how the closure is scoped inside it, and how whereRelation() and whereBelongsTo() shorten common cases.

for a senior

Catch the any-versus-all tag bug, the assumption that filtering loads the relation, and slow count thresholds on large tables.

for a principal

Decide when relation filters belong in reusable query objects or scopes versus inline, so search endpoints stay consistent and indexable.

## Filtering parents by what they are related to A photo-sharing site constantly asks questions like "photos tagged *sunset*", "photos with no tags yet" or "photos uploaded by this user". The answer is a list of **photos**, but the condition lives in **another table**. Eloquent's relationship-existence methods add that condition to the photo query without writing the join yourself, and **without loading** the related rows. ## The methods and the SQL they generate | Call | Meaning | SQL shape added to the photos query | |---|---|---| | `has('tags')` | at least one tag | `WHERE EXISTS (subquery)` | | `has('comments', '>=', 5)` | five or more comments | `WHERE (SELECT COUNT(*) ...) >= 5` | | `whereHas('tags', $closure)` | at least one tag matching the closure | `WHERE EXISTS (subquery with your wheres)` | | `whereRelation('tags', 'slug', 'sunset')` | shorthand for one `where` inside `whereHas` | same as above | | `doesntHave('tags')` | no tags at all | `WHERE NOT EXISTS (subquery)` | | `whereDoesntHave('tags', $closure)` | no tag matching the closure | `WHERE NOT EXISTS (subquery with your wheres)` | | `whereBelongsTo($user)` | photos whose `belongsTo` points at this user | `WHERE photos.user_id IN (...)` | Each also has an `or` form (`orWhereHas`, `orWhereRelation`, `orWhereBelongsTo`, `orDoesntHave`). ## How the subquery is built Reading `QueriesRelationships::has()` in the framework source: 1. The relation method is called with its constraints to learn the tables and keys. 2. If the check is plain existence (operator `>=` with count 1, or `<` with count 1 for the negative form), it builds an **EXISTS** subquery; any other operator or count builds a **COUNT** subquery compared with your number. 3. Your closure runs against that subquery, so its `where` calls are grouped inside it. 4. Constraints written inside the relation method itself, and global scopes of the related model (such as soft deletes), are merged in. For a `belongsToMany` relation the subquery joins the pivot table, so `whereHas('tags', ...)` correlates on `photo_tag.photo_id = photos.id`. Dot notation nests: `has('album.owner')` checks through two relations. ## A trap: "any of" versus "all of" ```php // photos tagged sunset OR beach Photo::whereHas('tags', fn ($q) => $q->whereIn('slug', ['sunset', 'beach'])); // photos tagged sunset AND beach Photo::whereHas('tags', fn ($q) => $q->where('slug', 'sunset')) ->whereHas('tags', fn ($q) => $q->where('slug', 'beach')); ``` A single `whereHas` with `whereIn` matches photos with **any** of the tags. To require **all** of them, chain one `whereHas` per tag, or count the matches with `whereHas('tags', $closure, '=', 2)` when the list has no duplicates. ## whereBelongsTo: the belongs-to shortcut `Post::where('user_id', $user->id)` works, but `whereBelongsTo($user)` reads the key from the relation definition: - By default it guesses the relation name from the model's class, `User` → `user()`. For a relation called `owner()`, pass it: `whereBelongsTo($user, 'owner')`. - It accepts an Eloquent collection of parents and turns it into `WHERE IN`. - If the method is missing or is not a `belongsTo`, it throws `RelationNotFoundException`; an empty collection throws `InvalidArgumentException`. The many-to-many counterpart, `whereAttachedTo($tag)`, filters through a `belongsToMany` relation the same way. ## What these methods do not do - They **do not load** the related models. `Photo::whereHas('tags', ...)->get()` returns photos whose `tags` are still unloaded; reading `$photo->tags` later queries again and returns **all** of that photo's tags, not only the ones that matched. Filtering and loading the same subset together is an eager-loading concern. - They do not add columns. To read a number such as the count of tags, use the aggregate methods (`withCount`). ## Performance notes - An EXISTS subquery returns each photo **once**, however many tags match. A hand-written `join('photo_tag', ...)` would repeat a photo per matching tag and need `distinct()`, which is one reason interviewers prefer `whereHas` for filtering. - The correlated subquery runs against the pivot for each candidate photo, so the pivot needs an index that starts with the column it is correlated on (`photo_id`) and ideally covers the tag key too. On a large table the difference between an indexed and an unindexed pivot dominates everything else. - Filtering by a value you already hold, such as a user, is cheaper with `whereBelongsTo()`: it is a plain `WHERE IN` on the photos table, with no subquery at all. - For a search page that combines several tag filters, build the query step by step and inspect the result with `toSql()` before tuning; each chained `whereHas` adds its own subquery.

  • Why can has('comments', '>=', 5) be slower than has('comments')?
    Plain existence compiles to `WHERE EXISTS`, which the database can stop evaluating at the first matching row. A count threshold compiles to a correlated `(SELECT COUNT(*) ...) >= 5`, which has to count matching rows for each candidate photo. The framework only uses EXISTS when the operator and count express 'at least one' or 'none'.
  • Does whereHas('tags', ...) respect soft deletes on the Tag model?
    Yes. The subquery is built from a fresh query on the related model with its global scopes registered, so a `SoftDeletes` scope on `Tag` excludes trashed tags from the existence check, just as it does from ordinary queries. Any `where` written inside the `tags()` relation method is merged in too.

saying these in an interview costs you the question

  • Believing whereHas() also loads the matching tags onto each photo
  • Using one whereIn inside whereHas to require all tags
  • Saying whereHas() always compiles to a JOIN that duplicates photos
  • Thinking whereBelongsTo() needs the foreign-key column name as its argument
  • Believing whereRelation() can express filters that whereHas() cannot