With Eloquent, how do nested, constrained and column-limited with() calls work, and when do you need withWhereHas() instead?
answer
- dot syntax, one query per level
- closure filters children, not parents
- relation:id,foreign_key column list
- limit inside the closure is per parent
- whereHas plus with, one closure
basics
~20 sDot syntax loads a chain level by level; a closure constrains the related rows only; 'relation:col,...' trims columns but must keep keys. withWhereHas() also drops parents without a matching child, applying one closure to both.
solid answer
~40 s`with('posts.comments')` loads posts, then comments for those posts, one query per level. A keyed closure, `with(['posts' => fn ($q) => $q->where('published', true)])`, constrains only the **related** query: every author still comes back, some with an empty `posts` collection. A column list like `with('author:id,name')` trims the related select, but it must keep the primary key and any foreign key used for matching, or the relation cannot be matched. When you want only the parents that have a matching child **and** only those children loaded, `withWhereHas('posts', fn ($q) => ...)` applies the same closure to a `whereHas` filter and to the eager load, so the two conditions cannot drift apart.
code
php · 10 lines<?php
use App\Models\Author;
$authors = Author::query()
->withWhereHas('posts', fn ($query) => $query
->where('published', true)
->with('comments:id,post_id,body'))
->orderBy('name')
->get();go deeper
Recall the three shapes: dot syntax for a chain, a keyed closure for conditions, and a colon column list. Remember that the closure never removes parents.
Explain which query each closure touches, why the key columns must stay in a column list, and how withWhereHas combines an existence filter with the eager load using one closure.
Show judgment on real pages: spotting a filter that drifted between whereHas and with, trimming columns on wide tables safely, and checking that per-parent limits do what the page promises.
Weigh how much shaping belongs in ad hoc query closures versus named scopes or dedicated query classes, so filters stay consistent across the pages that reuse them.
## Three ways to shape an eager load Plain `with('author')` loads a whole relation for every parent. Real pages need more control: a second level, a filter on the children, or fewer columns. Eloquent covers each with a variation of the same `with()` call on the query builder. The scenario below is an authors page on a blog: each author with their published posts and the comments on them. ## Nested eager loading **Dot syntax** walks a chain of relations. `Author::with('posts.comments')->get()` runs: 1. the authors query; 2. one `posts` query for all loaded authors; 3. one `comments` query for all loaded posts. Every level adds exactly one query. Eloquent registers each prefix of the path on its own, so `posts` is loaded even though you only wrote `posts.comments`. The **nested array** form expresses several branches under one parent: ```php Author::with([ 'posts' => ['comments', 'tags'], ])->get(); ``` ## Constrained eager loading A closure keyed by the relation name adds conditions to the **related** query: ```php $authors = Author::with(['posts' => fn ($query) => $query ->where('published', true) ->latest() ->limit(3), ])->get(); ``` Key facts about the closure: - It **filters children, not parents.** Every author is still returned; authors with no published posts get an empty collection. - For a nested path such as `'posts.comments' => fn ($q) => ...`, the closure applies to the **last** segment (comments). The `posts` level is loaded unconstrained. - In Laravel 13, a `limit()` inside an eager-load closure on a `hasMany` is applied **per parent** through a group limit, so each author gets up to three posts rather than three posts in total. - Ordering and extra `where` clauses behave like any query-builder chain. ## Column-limited eager loading `with('posts:id,author_id,title')` selects only the listed columns of the related table. The rule that trips people up: **keep every key Eloquent matches on**, meaning the related model's primary key and the foreign key that points back. For a `belongsTo` such as `with('author:id,name')`, the author's `id` must be in the list; drop it and no author can be matched to its post, so every post's `author` comes back `null`. Under `Model::shouldBeStrict()`, reading an attribute you did not select throws `MissingAttributeException` instead of quietly returning `null`. ## When the parents must be filtered too: `withWhereHas` Suppose the page should list **only** authors who have a featured post, and show only those featured posts. Two separate calls work, but the condition is written twice: ```php Author::whereHas('posts', fn ($q) => $q->where('featured', true)) ->with(['posts' => fn ($q) => $q->where('featured', true)]) ->get(); ``` If someone later edits one closure and not the other, the page lists authors whose loaded posts do not match the filter, or hides authors it should show. `withWhereHas()` removes the duplication: ```php Author::withWhereHas('posts', fn ($q) => $q->where('featured', true))->get(); ``` Internally it calls `whereHas()` with the relation name (anything after a `:` column list is ignored for the existence check) and `with()` with the same closure. It accepts the same `relation:columns` syntax as `with()`. ## Choosing between them | Need | Call | Parents returned | Children loaded | |---|---|---|---| | All authors, all posts | `with('posts')` | all | all | | All authors, only published posts | `with(['posts' => fn ...])` | all | matching only | | Only authors with a featured post, no posts loaded | `whereHas('posts', fn ...)` | matching only | none | | Only authors with a featured post, only those posts | `withWhereHas('posts', fn ...)` | matching only | matching only | ## Typical mistakes - Expecting a constrained `with()` to hide authors without matching posts. - Writing `with('author:name')` and wondering why every author is `null`. - Putting a `where` on the parent query that was meant for the children, or the reverse. - Duplicating the same closure in `whereHas` and `with` and letting them drift. - Adding `with('posts.comments')` after a constrained `posts` load: the dot path re-registers `posts` with an empty constraint and replaces the filtered one. Nest the second level inside the closure instead.
- What goes wrong with Post::with('author:name')->get()?The author's `id` is missing from the column list, so Eloquent has no key to match each author to its post. Every post's `author` relation ends up `null`, even though the authors query ran. Always include the related primary key and the foreign key used for matching, for example `with('author:id,name')`. With `shouldBeStrict()` on, reading an unselected attribute throws `MissingAttributeException` instead.
- Does limit(3) inside a with() closure give three posts in total or three per author?In Laravel 13 it gives up to three per author. When the relation is being eager loaded, `HasOneOrMany::limit()` applies a group limit keyed on the foreign key, which the grammar compiles into a per-parent window, instead of a plain `limit` on the whole query. On a single loaded parent, such as `$author->posts()->limit(3)`, it is an ordinary limit.
- Why can Author::withWhereHas('posts', $filter)->with('posts.comments') load unfiltered posts?Dot syntax registers every prefix of the path, so `with('posts.comments')` also registers `posts` with an empty constraint, and the later call replaces the earlier `posts` entry, closure and all. The authors are still filtered by the `whereHas` half, but their posts load unconstrained. Nest the second level inside the same closure, `fn ($q) => $q->where(...)->with('comments')`, so there is only one `posts` entry.
saying these in an interview costs you the question
- A constrained with() closure also removes parents that have no matching children
- with('author:name') works because Eloquent adds the key columns for you
- In 'posts.comments' => closure, the closure constrains the posts level
- withWhereHas() loads every child and only filters the parents
- Each nested level of dot syntax adds one query per parent row