Laravel chaperone(): stop child models re-querying their parent

Hidden gem: add chaperone() to a hasMany and every child gets its parent for free. 22 queries became 2 in our Laravel 13.38 lab.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-eloquent-chaperone.md
Hidden gem: add chaperone() to a hasMany and every child gets its parent for free. 22 queries became 2 in our Laravel 13.38 lab.
Show the diff. Do not commit.
On this page · 10 sections
  1. What chaperone() is and why people miss it
  2. The usual way vs chaperone()
  3. Real-world example: an Inertia comment list with permissions
  4. New comments get their parent too
  5. Gotchas
  6. Try it yourself
  7. How useful is this?
  8. Recap
  9. FAQ
  10. Next steps
  11. Sources

You eager loaded the comments. You checked Debugbar. Then the comment partial calls $comment->post->slug to build a permalink, and every comment quietly runs a query to fetch a post you already had in memory. One method on the relationship hands each child its parent, with zero SQL.

TL;DR

  • Add ->chaperone() to a hasMany, hasOne, morphMany or morphOne relation. Each child loaded through it gets the parent set on its belongsTo relation, so $comment->post runs no query.
  • In our lab, 5 posts with 20 comments went from 22 queries to 2. The usual fix, with('comments.post'), gets you to 3, and gives every comment a separate copy of the post.
  • It also fires on create(), make(), get() and paginate() through the relation, and you can turn it on per query with with(['comments' => fn ($q) => $q->chaperone()]).
  • Watch your Inertia props: each comment’s JSON now carries its parent, which doubled our payload until we hid it.

What you'll learn

  • What chaperone() does and why with() does not cover it
  • The usual fix (comments.post) vs chaperone(), with real query counts
  • An Inertia comment list that checks permissions without N+1
  • Gotchas: JSON payload size, direct queries, guessing the inverse name, pivot models

Who it's for: Laravel 11.22+ developers, including Inertia + Vue apps

Tested with: Laravel 13.38 · PHP 8.4 · SQLite · Pest

What chaperone() is and why people miss it#

chaperone() landed in Laravel 11.22 (September 2024, PR #51582, where it was first called inverse(), which still works as an alias). It tells a parent-to-children relation to walk back and set the parent on each child after the query runs:

app/Models/Post.phpphp
public function comments(): HasMany
{
    return $this->hasMany(Comment::class)->chaperone(); 
}

Why most people miss it: with('comments') feels like it should already know the parent. It doesn’t. Eloquent fills $post->comments but leaves each comment’s post relation empty, so the first $comment->post goes back to the database. The docs section sits under “One to Many”, between two code samples, and the method name gives nothing away.

Under the hood it is small. The trait registers an afterQuery callback that calls setRelation('post', $post) on every child. No join, no extra query, just a pointer to the model you already hold.

Post::with('comments') runs one query for posts and one for comments. If the comments relation has no chaperone(), reading $comment->post lazy loads the post again, one query per comment: 5 posts and 20 comments cost 22 queries. With chaperone(), Eloquent calls setRelation('post', $post) on every comment with the same instance and no SQL: 5 posts and 20 comments cost 2 queries.
Same data, same loop. The only difference is who tells the comment where it came from.

The usual way vs chaperone()#

Here is the loop from the intro, run against 5 posts with 4 comments each in our Laravel 13.38 lab. The url() method on Comment reads $this->post->slug.

nested eager load, then chaperone():

app/Http/Controllers/PostController.phpphp
$posts = Post::with('comments.post')->get();

foreach ($posts as $post) {
    foreach ($post->comments as $comment) {
        $comment->url(); // reads $this->post->slug
    }
}
// 3 queries: posts, comments, posts again
// $comment->post is a second copy of $post
app/Http/Controllers/PostController.phpphp
$posts = Post::with('comments')->get(); // comments() has ->chaperone()

foreach ($posts as $post) {
    foreach ($post->comments as $comment) {
        $comment->url(); // reads $this->post->slug
    }
}
// 2 queries: posts, comments
// $comment->post === $post

What we measured, counting with DB::getQueryLog():

Scenario (5 posts × 4 comments)Queries
with('comments'), no chaperone22 (1 + 1 + one per comment)
with('comments.post')3
comments() defined with ->chaperone()2
with(['comments' => fn ($q) => $q->chaperone()])2
Single post page, Post::find(1)->comments, no chaperone6
Same page with chaperone2

The nested eager load is not wrong, it is just one query heavier and it hands you a different object. In the lab, changing $post->title in memory showed up on $comment->post->title with chaperone, and did not with comments.post. When a policy or accessor reads the parent, you want the one you already have.

Tip: Opt in per query

Not ready to change the relation for the whole app? Turn it on only where you need it:

php
Post::with(['comments' => fn ($comments) => $comments->chaperone()])->get();

And the reverse: a relation defined with chaperone() can skip it with $post->comments()->withoutChaperone()->get().

Real-world example: an Inertia comment list with permissions#

A post page in an Inertia app. Each comment needs a permalink and a “can delete” flag: the comment’s author can delete it, and so can the post’s author. Both read the parent post.

app/Http/Resources/CommentResource.phpphp
class CommentResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'body' => $this->body,
            'url' => "/posts/{$this->post->slug}#comment-{$this->id}",
            'can_delete' => $request->user()?->id === $this->user_id
                || $request->user()?->id === $this->post->user_id,
        ];
    }
}
app/Http/Controllers/PostController.phpphp
public function show(Post $post): Response
{
    return Inertia::render('Posts/Show', [
        'post' => $post->only('id', 'title', 'slug'),
        'comments' => CommentResource::collection($post->comments),
    ]);
}

Without chaperone, that resource runs one select * from posts per comment, even though the route already bound the post. That is one extra query per comment, all fetching the same row. In the lab, the same logic on a 4-comment post was 6 queries without and 2 with.

With ->chaperone() on comments(), $this->post inside the resource is the route-bound $post. The fix lives in the model, so every controller, resource and Blade partial that loops comments gets it for free.

New comments get their parent too#

The same hook runs when you create through the relation. In the lab, this ran exactly one query, the INSERT:

app/Http/Controllers/CommentController.phpphp
$comment = $post->comments()->create($request->validated());

$comment->relationLoaded('post'); // true
$comment->post === $post;         // true, no select

Handy for the CommentPosted event or notification that wants $comment->post->title right after the insert.

Gotchas#

Inertia props get heavier. Pass chaperoned models straight to Inertia and each comment’s JSON now includes a post object. Laravel’s circular-reference guard (PR #52461, also 11.22) stops the recursion, so you get the post’s columns but not its comments again. Still, our 5-post, 20-comment list grew from 1,427 to 2,702 bytes of JSON. Use an API resource that picks fields (as above), or hide it: $post->comments->each->makeHidden('post') brought it back to 1,482 bytes (the extra 55 are a longer relation name in that test).

Gotcha: Only children loaded through the relation get a chaperone

Comment::where('post_id', 1)->get() never touches the comments() relation, so nothing sets post. In the lab, that loop ran 5 queries (1 + 4). Load children from the parent ($post->comments, $post->comments()->latest()->get()) when you need the parent on each one.

It needs a matching belongsTo on the child. Eloquent guesses the name from the foreign key (post_id gives post), then the parent’s class name, then owner. If none exists, the relation throws RelationNotFoundException the first time you call it. Pass the name when it is unusual: ->chaperone('article').

belongsTo can’t have one. A user has many posts, so setting “the” posts on a user from one post makes no sense. Put chaperone() on the hasMany side.

Pivot models are different. Since Laravel 13.31 (PR #61152), belongsToMany(...)->using(RoleUser::class)->chaperone() sets $user->pivot->user and $user->pivot->role. In the lab, reading both on 5 users took 12 queries without and 2 with. Unlike hasMany, it does nothing without using(), and if it can’t guess the pivot relation names it skips silently instead of throwing.

It plays fine with strict mode. With Model::preventLazyLoading() on, the plain version threw LazyLoadingViolationException for [post] on Comment. The chaperoned version passed, because nothing is lazy loaded.

Try it yourself#

Count the parent queries you didn't know about

routes/console.phpphp
use App\Models\Post;
use Illuminate\Support\Facades\DB;

Artisan::command('chaperone:check', function () {
    DB::enableQueryLog();
    Post::with('comments')->get()->each(fn ($post) => $post->comments->each(fn ($c) => $c->post->id));
    $this->info(count(DB::getQueryLog()).' queries');
});
bash
php artisan chaperone:check

Run it, add ->chaperone() to comments(), run it again. The second number should be 2.

How useful is this?#

Score
Time saved per use★★★★☆ One method call removes a whole class of N+1
How often you’ll reach for it★★★★☆ Any parent with children that link back: posts, orders, invoices, threads
Risk if misused★★☆☆☆ Bigger JSON if you dump models straight into Inertia props
Overall4 / 5: the cheapest N+1 fix you will find, once you know it exists

If you already use automatic eager loading, it fixes the same loop with one batched query (3 total in our lab). chaperone() gets you to 2 and hands back the same instance, and the two stack fine.

Recap#

  • chaperone() on a hasMany, hasOne, morphMany or morphOne sets the parent on every child it loads, with no query.
  • Lab result on Laravel 13.38: 22 queries down to 2 on a list, 6 down to 2 on a single post.
  • It also covers create(), make(), get() and paginate() through the relation; opt in per query with a with() closure, opt out with withoutChaperone().
  • Mind JSON size in Inertia props, load children through the parent, and add the belongsTo it needs.
  • Tested with 14 Pest tests in a scratch Laravel 13.38 app.

FAQ#

Which Laravel version do I need for chaperone()?

Laravel 11.22 or newer for hasMany, hasOne, morphMany and morphOne. Pivot support on belongsToMany with a custom pivot model arrived in Laravel 13.31. We tested on 13.38.

What is the difference between chaperone() and with('comments.post')?

The nested eager load runs one more query and gives each comment a separate copy of the post. chaperone() runs no extra query and sets the exact parent instance you already have.

Is inverse() the same thing?

Yes. inverse() is an alias of chaperone(), and withoutInverse() is an alias of withoutChaperone().

Does chaperone() cause infinite recursion when serializing to JSON?

No. Since Laravel 11.22 model serialization detects circular references. Each child includes the parent columns but not the parent relations again. It does make the JSON larger, so use resources or makeHidden('post') for Inertia props.

Should I add chaperone() to every hasMany?

Add it where children read their parent: permalinks, policies, accessors, notifications. If nothing ever calls $child->parent, it costs a tiny loop and adds the parent to serialized output, so skip it there.

Next steps#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.