Laravel Custom Eloquent Relations: Building Polymorphic and Composite-Key Relations
#laravel #eloquent #database #orm #php

Laravel Custom Eloquent Relations: Building Polymorphic and Composite-Key Relations

4 min read Mohamed Said Mohamed Said

Why Eloquent's Built-in Relations Sometimes Fall Short

Eloquent ships with eight relation types that map cleanly onto single-column foreign keys. The moment your legacy schema uses a composite key (tenant_id + external_id) or your polymorphic pivot carries extra join conditions, you hit a wall. The answer is not to abandon Eloquent — it is to extend it properly.

Anatomy of a Relation Class

Every relation extends Illuminate\Database\Eloquent\Relations\Relation. The three methods you must implement are:

  • addConstraints() — applied when the relation is instantiated eagerly or lazily.
  • addEagerConstraints(array $models) — applied when loading a collection.
  • initRelation(array $models, $relation) — seeds the default value on each parent.
  • match(array $models, Collection $results, $relation) — hydrates results back onto parents.
  • getResults() — returns the final result for a single model.
namespace App\Relations;

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

class HasManyByCompositeKey extends Relation
{
    public function __construct(
        protected string $firstKey,
        protected string $secondKey,
        protected string $ownerFirstKey,
        protected string $ownerSecondKey,
        Model $related,
        Model $parent,
    ) {
        parent::__construct($related->newQuery(), $parent);
    }

    public function addConstraints(): void
    {
        if (static::$constraints) {
            $this->query
                ->where($this->firstKey, $this->parent->{$this->ownerFirstKey})
                ->where($this->secondKey, $this->parent->{$this->ownerSecondKey});
        }
    }

    public function addEagerConstraints(array $models): void
    {
        $pairs = collect($models)->map(fn ($m) => [
            $m->{$this->ownerFirstKey},
            $m->{$this->ownerSecondKey},
        ]);

        $this->query->where(function ($q) use ($pairs) {
            foreach ($pairs as [$first, $second]) {
                $q->orWhere(fn ($inner) => $inner
                    ->where($this->firstKey, $first)
                    ->where($this->secondKey, $second)
                );
            }
        });
    }

    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->filter(
                fn ($r) => $r->{$this->firstKey} == $model->{$this->ownerFirstKey}
                    && $r->{$this->secondKey} == $model->{$this->ownerSecondKey}
            );
            $model->setRelation($relation, $matched->values());
        }
        return $models;
    }

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

Wiring It Into a Model

Add a convenience method on your model that mirrors how Eloquent exposes hasMany:

class Tenant extends Model
{
    public function orders(): HasManyByCompositeKey
    {
        return new HasManyByCompositeKey(
            firstKey: 'tenant_id',
            secondKey: 'external_order_id',
            ownerFirstKey: 'id',
            ownerSecondKey: 'external_id',
            related: new Order(),
            parent: $this,
        );
    }
}

Eager loading now works exactly as expected:

$tenants = Tenant::with('orders')->get();

Handling Non-Standard Polymorphic Pivots

When a polymorphic pivot needs an extra discriminator column (e.g., context), override addConstraints on a subclass of MorphToMany and add the extra where clause before calling parent::addConstraints().

class ContextualMorphToMany extends MorphToMany
{
    public function __construct(
        private string $context,
        ...$args,
    ) {
        parent::__construct(...$args);
    }

    public function addConstraints(): void
    {
        parent::addConstraints();
        if (static::$constraints) {
            $this->query->where(
                $this->table . '.context', $this->context
            );
        }
    }
}

Because addEagerConstraints in MorphToMany already scopes by morph type, you only need to inject the extra column in addConstraints and replicate it in addEagerConstraints via a tap on the parent call.

Testing Custom Relations

Use Pest with an in-memory SQLite database to assert eager loading produces no extra queries:

it('eager loads composite orders without N+1', function () {
    $tenants = Tenant::factory(3)->create();
    foreach ($tenants as $t) {
        Order::factory(2)->create([
            'tenant_id' => $t->id,
            'external_order_id' => $t->external_id,
        ]);
    }

    $queryCount = 0;
    DB::listen(fn () => $queryCount++);

    Tenant::with('orders')->get();

    expect($queryCount)->toBe(2); // one for tenants, one for orders
});

Key Takeaways

  • Extend Relation directly when no built-in type fits; implement all five lifecycle methods.
  • addEagerConstraints is the critical method — a naive implementation causes N+1 even with with().
  • Use orWhere grouping for composite-key eager loads to keep it a single query.
  • Subclassing MorphToMany for extra pivot conditions is safer than reimplementing from scratch.
  • Always cover custom relations with a query-count assertion in Pest to catch regressions.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use `withCount` and `withSum` on a custom relation class?
Only if your class also implements `SupportsPartialRelations` and overrides `getRelationExistenceQuery`. The aggregate macros on the query builder delegate to that method, so without it the aggregate scopes silently fall back to incorrect SQL.
Q02 Does Laravel's model serialization handle custom relations automatically?
Yes — as long as you call `setRelation` in `match` and `initRelation`, Eloquent's `toArray` and JSON serialization treat the relation like any other loaded relation. No extra work is needed.
Q03 Is there a performance penalty for the `orWhere` grouping in eager loading?
On indexed columns the query planner handles OR groups efficiently. For very large parent sets, consider batching with `addEagerConstraints` in chunks of 500 pairs, mirroring how Eloquent's `whereIntegerInRaw` batches large IN clauses.

Continue reading

More Articles

View all