With Eloquent, how do whereHas(), whereRelation(), doesntHave() and whereBelongsTo() filter photos by related rows, and what SQL do they produce?
answer
- filter parents, load nothing
- WHERE EXISTS correlated subquery
- count operator switches to COUNT subquery
- whereRelation: one where, shorter
- whereBelongsTo guesses relation from class
basics
~20 sThey 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
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
Recall that whereHas() filters parents by related rows and that doesntHave() finds parents with none.
Explain the EXISTS versus COUNT subquery, how the closure is scoped inside it, and how whereRelation() and whereBelongsTo() shorten common cases.
Catch the any-versus-all tag bug, the assumption that filtering loads the relation, and slow count thresholds on large tables.
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