Eloquent Custom Relations: Building a HasManyThrough Alternative with Query Builder
#laravel #eloquent #orm #database

Eloquent Custom Relations: Building a HasManyThrough Alternative with Query Builder

1 min read Mohamed Said Mohamed Said

Why Standard Relations Fall Short

Eloquent ships with eight relation types. They cover the common cases well, but real-world schemas often involve multi-column joins, filtered pivot conditions, or cross-schema links that don't map cleanly onto HasManyThrough or BelongsToMany. The usual workaround is a raw join inside a scope, which breaks eager loading and pollutes your model with query logic.

The cleaner path is a custom Relation subclass. It's less magic than it looks.

Anatomy of an Eloquent Relation

Every relation extends Illuminate\Database\Eloquent\Relations\Relation. The contract requires three methods:

  • addConstraints() — applied when loading a single model (lazy load).
  • addEagerConstraints(array $models) — applied when loading a collection (eager load).
  • initRelation(array $models, $relation) — seeds each model with a default value before results arrive.
  • match(array $models, Collection $results, $relation) — maps results back onto their parent models.
  • getResults() — executes the query and returns the final value.

A Concrete Example: HasManyInPeriod

Imagine User has many Booking records, but you always want bookings filtered to an active contract period stored on a contracts table. The join condition is non-trivial.

<?php

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 HasManyInPeriod extends Relation
{
    public function __construct(
        Builder $query,
        Model $parent,
        protected string $foreignKey,
        protected string $localKey,
    ) {
        parent::__construct($query, $parent);
    }

    public function addConstraints(): void
    {
        if (static::$constraints) {
            $this->query
                ->join('contracts', 'contracts.user_id', '=', $this->foreignKey)
                ->whereColumn('bookings.booked_at', '>=', 'contracts.starts_at')
                ->whereColumn('bookings.booked_at', '<=', 'contracts.ends_at')
                ->where($this->foreignKey, $this->parent->{$this->localKey});
        }
    }

    public function addEagerConstraints(array $models): void
    {
        $keys = collect($models)->pluck($this->localKey)->unique()->values();

        $this->query
            ->join('contracts', 'contracts.user_id', '=', $this->foreignKey)
            ->whereColumn('bookings.booked_at', '>=', 'contracts.starts_at')
            ->whereColumn('bookings.booked_at', '<=', 'contracts.ends_at')
            ->whereIn($this->foreignKey, $keys);
    }

    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
    {
        $dictionary = $results->groupBy($this->foreignKey);

        foreach ($models as $model) {
            $key = $model->{$this->localKey};
            $model->setRelation(
                $relation,
                $dictionary->get($key, $this->related->newCollection())
            );
        }
        return $models;
    }

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

Wiring It Into the Model

<?php

namespace App\Models;

use App\Relations\HasManyInPeriod;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    public function contractBookings(): HasManyInPeriod
    {
        $instance = $this->newRelatedInstance(Booking::class);

        return new HasManyInPeriod(
            $instance->newQuery(),
            $this,
            'bookings.user_id',
            'id',
        );
    }
}

Eager loading now works as expected:

$users = User::with('contractBookings')->paginate(50);

Eloquent calls addEagerConstraints with all 50 user models, fires one query, then match distributes results — no N+1.

Handling withCount and Subquery Selects

If you want withCount('contractBookings') to work, override getRelationExistenceCountQuery:

public function getRelationExistenceCountQuery(
    Builder $query, Builder $parentQuery
): Builder {
    return $this->getRelationExistenceQuery($query, $parentQuery, new Expression('count(*)'))
        ->setBindings([], 'select');
}

Without this, withCount falls back to a generic exists subquery that ignores your join.

Key Takeaways

  • addConstraints and addEagerConstraints are the two critical split points — get them wrong and you either miss results or produce a Cartesian product.
  • Always call static::$constraints guard in addConstraints; Eloquent disables it during eager load setup.
  • initRelation must seed a default (empty collection or null) so models without matches don't throw undefined relation errors.
  • Override getRelationExistenceCountQuery if withCount or whereHas semantics matter to your consumers.
  • Custom relations compose with all standard Eloquent features: with, load, withCount, constraint closures.

Found this useful?

Frequently Asked Questions

2 questions
Q01 Does a custom Relation subclass work with `whereHas` and `withCount`?
Yes, but you must override `getRelationExistenceQuery` and `getRelationExistenceCountQuery`. Without those overrides, Eloquent uses a generic exists subquery that ignores your custom join conditions.
Q02 When should I use a custom relation instead of a query scope?
Use a custom relation when you need eager loading, `withCount`, or the ability to pass constraint closures via `with('relation', fn($q) => ...)`. Scopes can't participate in Eloquent's eager load pipeline.

Continue reading

More Articles

View all