With Eloquent, what problem does calling chaperone() on a hasMany relation solve, and what should you watch for when using it?
answer
- children do not know their parent
- $comment->post lazy loads again
- sets the inverse relation, no query
- name guessed from the foreign key
- RelationNotFoundException when the guess fails
basics
~20 sEager-loaded children do not get their parent set, so $comment->post inside a loop over $post->comments lazy loads the post again. chaperone() sets that inverse relation on each child after the query, with no extra query.
solid answer
~40 s`Post::with('comments')` loads comments for every post, but each `Comment` does not have its `post` relation set, so code in the loop that reads `$comment->post` (a policy check, an accessor, a URL helper) lazy loads the post it already came from, once per comment. Declaring `$this->hasMany(Comment::class)->chaperone()` makes Eloquent call `setRelation()` on each child with the parent after the query, eager or lazy, so the inverse is free. You can also opt in per query: `with(['comments' => fn ($c) => $c->chaperone()])`. It guesses the inverse name from the foreign key or the parent class, and throws `RelationNotFoundException` when no such relation exists; pass the name, `chaperone('post')`, when it cannot guess. It works on `hasOne`, `hasMany`, `morphOne` and `morphMany`, and since 13.31 on `belongsToMany` custom pivot models.
code
php · 23 lines<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Post extends Model
{
public function comments(): HasMany
{
return $this->hasMany(Comment::class)->chaperone();
}
}
class Comment extends Model
{
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
}go deeper
Recall that an eager load fills the relation one way only, and that chaperone() makes each child point back to its already-loaded parent.
Explain why $comment->post lazy loads inside a loop over eager-loaded comments, how chaperone() sets the inverse without SQL, and how the inverse name is guessed.
Show where hidden inverse reads come from in production code, such as policies, accessors and partials, and the serialization and recursion effects of the circular graph.
Decide whether inverse hydration belongs on relation definitions by default or per query, balancing hidden coupling against repeated query bugs across teams.
## The problem: children that forget their parent An eager load fills the relation in one direction only. After `Post::with('comments')->get()`, each post holds its comments, but each `Comment` has no `post` relation loaded. Any code in the loop that walks back up (a `Gate` check on the comment, an accessor that builds a URL from `$this->post->slug`, a Blade partial that prints the post title) reads `$comment->post` and triggers a lazy load: one query per comment for a post that is already in memory. ```php $posts = Post::with('comments')->get(); foreach ($posts as $post) { foreach ($post->comments as $comment) { echo $comment->post->title; // one query per comment without chaperone() } } ``` With `Model::preventLazyLoading()` on, the same line throws `LazyLoadingViolationException` instead. ## What `chaperone()` does `chaperone()` tells the relation to link each related model back to its parent **after the relationship query has run**. It adds an after-query step that calls `setRelation($inverse, $parent)` on every child. No SQL is involved; the child simply receives a reference to the parent object that is already loaded. ```php public function comments(): HasMany { return $this->hasMany(Comment::class)->chaperone(); } ``` It applies to every way the relation is filled: - **Eager loads.** When matching children to parents, each child gets its own parent set. - **Lazy loads and direct queries.** `$post->comments` and `$post->comments()->get()` set the post on each result. - **Creation through the relation.** `$post->comments()->create([...])` returns a comment with `post` already set. ## Declaring it or opting in per query You can put `chaperone()` on the relation definition, which applies everywhere, or opt in for one query: ```php Post::with(['comments' => fn ($comments) => $comments->chaperone()])->get(); ``` `inverse()` is an alias, and `withoutChaperone()` (alias `withoutInverse()`) turns it off for a query when the relation declares it. ## How the inverse name is found Without an argument, Eloquent guesses the inverse relation from, in order: 1. the relation's foreign key without the key suffix (`post_id` gives `post`); 2. the parent model's own foreign-key name, handled the same way; 3. the parent class name in camel case (`Post` gives `post`); 4. `owner`, and `parent` when the relation points at the same model class. It keeps the first candidate that is a real relation method on the child. If none is, it throws `Illuminate\Database\Eloquent\RelationNotFoundException`. For names it cannot guess, such as a `writer` relation back to an `Author`, pass it explicitly: `chaperone('writer')`. ## Where hidden inverse reads come from The walk back to the parent is rarely written as plainly as `$comment->post->title`. More often it hides in code that receives only the child: - a policy method such as `update(User $user, Comment $comment)` that checks `$comment->post->author_id`; - an accessor on `Comment` that builds a permalink from `$this->post->slug`; - a Blade partial or component that is handed one comment and prints its post title; - a notification or mail class that formats a comment together with its post. Each of these is correct in isolation and costs one query per comment inside a loop. `chaperone()` fixes all of them at once, without changing their code. ## Where it is available | Relation | `chaperone()` | |---|---| | `hasOne`, `hasMany` | yes | | `morphOne`, `morphMany` | yes | | `belongsToMany` with a custom `using()` pivot | yes, since Laravel 13.31; sets the pivot's own `belongsTo` relations, names passed as `declaring:` and `related:` | | `belongsTo`, `morphTo`, `hasManyThrough` | no | ## What to watch for - **The object graph becomes circular.** Parent holds children; each child holds the parent. Eloquent's own `toArray()`, JSON serialization and queue serialization guard against recursion, but a hand-written recursive walker over relations must stop itself. - **Serialized output changes.** Children now carry a loaded `post` relation, so converting a child to an array can include its parent's attributes; hide it where that matters. - **It fixes only the inverse.** It does not load anything new. Loading the comments still needs `with()`, `load()` or automatic eager loading. - **The guess can fail at runtime.** A typo or an unusual foreign key surfaces as `RelationNotFoundException` when the relation is built, so test the pages that use it.
- Does chaperone() run an extra query to load the parent onto each child?No. The parent is already in memory, because the children were loaded from it. `chaperone()` adds an after-query step that calls `setRelation()` on each child with that existing parent object. The result is zero extra statements, and every child shares the same parent instance rather than a copy.
- What happens if the Comment model's relation back to Post is named article instead of post?The guesses (`post` from `post_id`, `post` from the class name, `owner`) all miss, so building the relation throws `RelationNotFoundException`. Pass the name explicitly with `chaperone('article')`, which Eloquent checks is a real relation method on the child before using it.
saying these in an interview costs you the question
- Eager loading comments also sets each comment's post relation automatically
- chaperone() runs one query to fetch the parent for all children
- chaperone() works on belongsTo relations to set the children
- An unguessable inverse name is silently ignored
- chaperone() replaces the need to eager load the children