Laravel Magazine
Eloquent's chaperone() Method: Fixing N+1 Queries on Inverse Relationships

Eloquent's chaperone() Method: Fixing N+1 Queries on Inverse Relationships

Eric Van Johnson ·

You already know the golden rule: eager load your relationships or Eloquent will quietly fire a query per row and your dashboard will crawl. Most of us learned this lesson the hard way, staring at Telescope wondering why a page with 40 comments made 41 queries.

But there's a sneakier version of the same problem that with() doesn't fully solve, and it hides in the inverse relationship.

The setup

Say you're loading a post with its comments:

$post = Post::with('comments')->first();

That's two queries: one for the post, one for the comments. Clean. But now look at what happens when your Blade view (or API resource) touches the parent relation from inside the loop:

foreach ($post->comments as $comment) {
    echo $comment->post->title;
}

Even though $comment->post is the exact same model instance you already have loaded in $post, Eloquent doesn't know that. Each $comment->post access either triggers a fresh query (if lazy loading is allowed) or, if you've locked things down with Model::preventLazyLoading(), throws a LazyLoadingViolationException. Either way, you're paying for data you already have sitting in memory.

Enter chaperone()

Laravel added chaperone() specifically for this. Call it on the hasMany (or hasManyThrough) definition and Eloquent will automatically hydrate the inverse belongsTo relation with the already-loaded parent, no extra query, no extra memory for a duplicate model.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Post extends Model
{
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class)->chaperone();
    }
}

Now this works with zero additional queries:

$post = Post::with('comments')->first();

foreach ($post->comments as $comment) {
    echo $comment->post->title; // free, already hydrated
}

If you'd rather not touch the relationship definition (maybe it's used in places where you don't want this behavior by default), you can request it per-query instead:

$post = Post::with(['comments' => fn ($query) => $query->chaperone()])->first();

Where this actually bites you

The classic place this shows up is API Resources. It's really common to write a CommentResource that includes a nested, lightweight representation of the parent post, like the post's title or slug for a breadcrumb:

class CommentResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'body' => $this->body,
            'post_title' => $this->post->title,
        ];
    }
}

Return a collection of 50 comments through that resource without chaperone() and you've just added 50 queries to a response that felt fast in local testing with five rows of seed data.

It also matters for polymorphic relationships. If you're building something like a notifications feed with MorphTo, each notifiable lookup is its own query unless the parent's already loaded. chaperone() works there too as long as the eager-loaded side is the hasMany/hasManyThrough definition, not the polymorphic morphTo itself.

A quick way to catch this before it ships

Pair chaperone() with strict mode in your AppServiceProvider, and any inverse access you forgot to chaperone will blow up loudly in local development instead of quietly costing you queries in production:

public function boot(): void
{
    Model::shouldBeStrict(! $this->app->isProduction());
}

shouldBeStrict() throws on lazy loading, silently discarded attributes, and missing attributes, all in one call. Run your test suite with it on and you'll find every spot where you're relying on implicit lazy loading, chaperone() included among the fixes.

The takeaway

with() solves the query-per-row problem going down the relationship tree. chaperone() solves it coming back up. If your models reference their parent from inside a loop over the children, and there's a decent chance yours do, it's a one-word fix that costs nothing and saves a query per row every single time.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.