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
addEagerConstraintsmust not calladdConstraintslogic — 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
Relationdirectly gives you full control over eager loading without raw DB calls. - Scoped intermediate models solve the polymorphic
HasManyThroughproblem cleanly. withPivot+usingkeeps 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, andmatch.