skip to content

With Eloquent, what problem does calling chaperone() on a hasMany relation solve, and what should you watch for when using it?

level: middleimportance: nice to knowfreq 22%

answer

  1. children do not know their parent
  2. $comment->post lazy loads again
  3. sets the inverse relation, no query
  4. name guessed from the foreign key
  5. RelationNotFoundException when the guess fails

basics

~20 s

Eager-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
<?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

for a junior

Recall that an eager load fills the relation one way only, and that chaperone() makes each child point back to its already-loaded parent.

for a middle

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.

for a senior

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.

for a principal

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