Illuminate\Database\Eloquent\Collection@loadMorph() is now part of the Laravel framework starting in 5.6.13.

Improving database query performance of polymorphic relations is a rarely-documented edge case in Laravel development but I’ve solved some N+1 problems it presents. A new Collection macro can be introduced, inspired by Illuminate\Database\Eloquent\Collection method load() (L38) that calls Illuminate\Database\Eloquent\Builder method eagerLoadRelations() (L466).

UI showing polymorphic relations

Dashboards, activity feeds, inboxes, and notifications are perfect examples of database tables that hold relationships to many object types. Laravel’s polymorphic relations allow an easy active record representation. But problems arise when nested relations of those morphable relations are referenced.

Here is an example component of a Facebook notification feed, proposed model class names on the right for a Notification@subject() polymorphic relationship.


Recent Notifications

EventInvite Dave invited you to his event Dropping Acid

GroupPhotoUpload Loz added a photo in Cats Driving Cars

GroupPost Hans posted in Fixing N+1 queries

PostLike Nobody liked your political post.


The query to fetch data for this UI might be:

$notifications = Auth::user()->notifications()
    ->with('subject') // morphTo() relationship
    ->get();

Now suppose to generate the SEO-friendly URL for each notification, that query also needs these nested relations:

  • EventInvite@event()
  • GroupPhotoUpload@group()
  • GroupPost@group()
  • PostLike@post()

Attempting to eager load

    ->with('subject.event')
    ->with('subject.group')
    ->with('subject.post')

will throw an exception since the event() method doesn’t exist on the GroupPost model class, etc.

A hacky eager load attempt using null relationships

This solution is a fugly one but it does attain the end-result. For those missing methods, I added faux relationships that always return an empty collection. So to support the above feed, the GroupPost model would look like:

class GroupPost extends Model
{
    // ...

    public function event()
    {
        return $this->belongsTo(Event::class);
    }

    public function group()
    {
        return $this->event()->whereNone();
    }

    public function post()
    {
        return $this->event()->whereNone();
    }
}

Then the remaining three model classes also need null relationship methods setup. They’re inexpensive queries but they are being run each page load. It’s a bit bananas and not ideal at all.

A whereNone() scope in the base model class is good for supporting other scopes that accept arguments. When a value isn’t present in the scope’s arguments, sometimes it makes more sense for the query to return nothing rather than whitelisting everything.

protected function isIpv4($ip)
{
    return Validator::make(['ip' => $ip], ['ip' => 'required|ipv4'])->passes();
}

public function scopeOfIp($query, $ip)
{
    return $query->when($this->isIpv4($ip),
        function ($query) use ($ip) {
             return $query->where('ip', $ip);
        },
        function ($query) {
             return $query->whereNone();
        });
}

whereNone() is as simple as:

public function scopeWhereNone($query)
{
    return $query->whereRaw('true = false');
}

A proper solution to polymorphic relation N+1 queries

I was acquainted with Illuminate\Support\Collection as it’s well-documented on laravel.com and I’m often introducing new macros to streamline nasty code chunks. Less well-known is the support-extending Eloquent Collection class that handles models fresh from the database, hashed by keys. Its load() method is exactly what polymorphic relationships need to eager load after the collection is fetched.

Of course a collection pipeline solves all.

Collection::macro('loadMorph', function ($relation, $relations) {
    $this->pluck($relation)
        ->filter()
        ->groupBy(function ($model) {
            return get_class($model);
        })
        ->filter(function ($models, $className) use ($relations) {
            return array_has($relations, $className);
        })
        ->each(function ($models, $className) use ($relations) {
            $className::with($relations[$className])
                ->eagerLoadRelations($models->all());
        });

        return $this;
});

Now that above query can be optimized via this macro.

$notifications = Auth::user()->notifications()
    ->with('subject')
    ->get()
    ->loadMorph('subject', [
        EventInvite::class => 'event',
        GroupPhotoUpload::class => 'group',
        GroupPost::class => 'group',
        PostLike::class => 'post',
    ]);

A paginated query looks like:

$notifications = Auth::user()->notifications()
    ->with('subject')
    ->paginate(50);

$notifications->getCollection()->loadMorph('subject', [
    EventInvite::class => 'event',
    GroupPhotoUpload::class => 'group',
    GroupPost::class => 'group',
    PostLike::class => 'post',
]);