Eloquent Custom Relations: Polymorphic Pivots, HasManyThrough Tricks, and Raw Join Relations
#laravel #eloquent #database #orm

Eloquent Custom Relations: Polymorphic Pivots, HasManyThrough Tricks, and Raw Join Relations

3 min read Mohamed Said Mohamed Said

Why Eloquent's Built-in Relations Are Not Always Enough

Eloquent ships with nine relation types, but real-world schemas rarely fit neatly into any of them. You end up writing DB::select calls, appending attributes in getXAttribute, or firing N+1 queries because the "right" relation doesn't exist. The fix is almost always a custom relation class — and it's less work than it looks.


Case 1: A Raw-Join Relation for Denormalised Reporting Tables

Imagine a metrics table that stores pre-aggregated rows keyed by entity_type and entity_id. You want $post->metrics to behave like a first-class relation — eager-loadable, constrainable, and serialisable.

Extend Illuminate\Database\Eloquent\Relations\Relation directly:

namespace App\Relations;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;

class HasMetrics extends Relation
{
    public function addConstraints(): void
    {
        if (static::$constraints) {
            $this->query
                ->where('entity_type', $this->parent->getMorphClass())
                ->where('entity_id', $this->parent->getKey());
        }
    }

    public function addEagerConstraints(array $models): void
    {
        $this->query
            ->where('entity_type', $this->parent->getMorphClass())
            ->whereIn('entity_id', $this->getKeys($models));
    }

    public function initRelation(array $models, $relation): array
    {
        foreach ($models as $model) {
            $model->setRelation($relation, $this->related->newCollection());
        }
        return $models;
    }

    public function match(array $models, Collection $results, $relation): array
    {
        foreach ($models as $model) {
            $matched = $results->where('entity_id', $model->getKey());
            $model->setRelation($relation, $matched->values());
        }
        return $models;
    }

    public function getResults(): Collection
    {
        return $this->query->get();
    }
}

Register it on the model:

public function metrics(): HasMetrics
{
    return new HasMetrics(
        (new Metric)->newQuery(),
        $this
    );
}

Now Post::with('metrics')->get() works without a single extra query.


Case 2: HasManyThrough Across a Polymorphic Pivot

HasManyThrough requires concrete foreign keys on the intermediate table. When that table is a polymorphic pivot (taggables, mediables), the standard relation breaks because it can't scope the intermediate join to a single *_type.

The cleanest fix is a scoped hasManyThrough using a custom intermediate model:

// Intermediate model scoped to one morph type
class PostTaggable extends Model
{
    protected $table = 'taggables';

    protected static function booted(): void
    {
        static::addGlobalScope('type', fn (Builder $q) =>
            $q->where('taggable_type', Post::class)
        );
    }
}

// On the Post model
public function tags(): HasManyThrough
{
    return $this->hasManyThrough(
        Tag::class,
        PostTaggable::class,
        'taggable_id',   // FK on taggables -> posts
        'id',            // FK on tags
        'id',            // local key on posts
        'tag_id'         // local key on taggables
    );
}

The global scope on PostTaggable ensures the join is always filtered by taggable_type, making eager loading safe.


Case 3: Polymorphic Pivot with Extra Columns

When a polymorphic pivot carries extra data (e.g., order, metadata), MorphToMany supports withPivot() just like BelongsToMany:

public function media(): MorphToMany
{
    return $this->morphToMany(Medium::class, 'mediable')
        ->withPivot(['order', 'caption'])
        ->orderByPivot('order');
}

Access pivot data via $medium->pivot->caption. For typed pivot models, swap to using(MediablePivot::class) and cast columns there — keeping the domain logic off the parent model.


Eager Loading Gotchas

  • addEagerConstraints must not call addConstraints logic — the parent key set replaces the single-model constraint entirely.
  • getKeys() strips nulls — guard against models without a primary key before calling it.
  • Custom relations are not automatically recognised by Model::$with; declare them explicitly.

Takeaways

  • Extending Relation directly gives you full control over eager loading without raw DB calls.
  • Scoped intermediate models solve the polymorphic HasManyThrough problem cleanly.
  • withPivot + using keeps extra pivot data typed and testable.
  • Custom relations serialise, cache, and constrain exactly like built-in ones.
  • The four methods to implement are addConstraints, addEagerConstraints, initRelation, and match.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can custom Eloquent relations be used inside `with()` for eager loading?
Yes. As long as you implement `addEagerConstraints`, `initRelation`, and `match` correctly, Laravel's eager loader treats your custom relation identically to built-in ones.
Q02 Why does HasManyThrough fail with polymorphic pivot tables?
HasManyThrough joins the intermediate table without a `*_type` constraint, so it returns rows for all morph types. Scoping the intermediate model with a global scope that filters by the correct morph type fixes this without changing the relation signature.
Q03 Is there a performance cost to custom relation classes?
No measurable overhead beyond what any relation incurs. The query count is identical to a built-in relation when eager loading is used correctly.

Continue reading

More Articles

View all