laraveleloquentpolymorphic-associationseager-loadingpolymorphism

Laravel - Eager Loading Polymorphic Relation's Related Models


I can eager load polymorphic relations/models without any n+1 issues. However, if I try to access a model related to the polymorphic model, the n+1 problem appears and I can't seem to find a fix. Here is the exact setup to see it locally:

1) DB table name/data

history

history table

companies

enter image description here

products

enter image description here

services

enter image description here

2) Models

// History
class History extends Eloquent {
    protected $table = 'history';

    public function historable(){
        return $this->morphTo();
    }
}

// Company
class Company extends Eloquent {
    protected $table = 'companies';

    // each company has many products
    public function products() {
        return $this->hasMany('Product');
    }

    // each company has many services
    public function services() {
        return $this->hasMany('Service');
    }
}

// Product
class Product extends Eloquent {
    // each product belongs to a company
    public function company() {
        return $this->belongsTo('Company');
    }

    public function history() {
        return $this->morphMany('History', 'historable');
    }
}

// Service
class Service extends Eloquent {
    // each service belongs to a company
    public function company() {
        return $this->belongsTo('Company');
    }

    public function history() {
        return $this->morphMany('History', 'historable');
    }
}

3) Routing

Route::get('/history', function(){
    $histories = History::with('historable')->get();
    return View::make('historyTemplate', compact('histories'));
});

4) Template with n+1 logged only becacuse of $history->historable->company->name, comment it out, n+1 goes away.. but we need that distant related company name:

@foreach($histories as $history)
    <p>
        <u>{{ $history->historable->company->name }}</u>
        {{ $history->historable->name }}: {{ $history->historable->status }}
    </p>
@endforeach
{{ dd(DB::getQueryLog()); }}

I need to be able to load the company names eagerly (in a single query) as it's a related model of the polymorphic relation models Product and Service. I’ve been working on this for days but can't find a solution. History::with('historable.company')->get() just ignores the company in historable.company. What would an efficient solution to this problem be?


Solution

  • Solution:

    It is possible, if you add:

    protected $with = ['company']; 
    

    to both the Service and Product models. That way, the company relation is eager-loaded every time a Service or a Product is loaded, including when loaded via the polymorphic relation with History.


    Explanation:

    This will result in an additional 2 queries, one for Service and one for Product, i.e. one query for each historable_type. So your total number of queries—regardless of the number of results n—goes from m+1 (without eager-loading the distant company relation) to (m*2)+1, where m is the number of models linked by your polymorphic relation.


    Optional:

    The downside of this approach is that you will always eager-load the company relation on the Service and Product models. This may or may not be an issue, depending on the nature of your data. If this is a problem, you could use this trick to automatically eager-load company only when calling the polymorphic relation.

    Add this to your History model:

    public function getHistorableTypeAttribute($value)
    {
        if (is_null($value)) return ($value); 
        return ($value.'WithCompany');
    }
    

    Now, when you load the historable polymorphic relation, Eloquent will look for the classes ServiceWithCompany and ProductWithCompany, rather than Service or Product. Then, create those classes, and set with inside them:

    ProductWithCompany.php

    class ProductWithCompany extends Product {
        protected $table = 'products';
        protected $with = ['company'];
    }
    

    ServiceWithCompany.php

    class ServiceWithCompany extends Service {
        protected $table = 'services';
        protected $with = ['company'];
    }
    

    ...and finally, you can remove protected $with = ['company']; from the base Service and Product classes.

    A bit hacky, but it should work.