Eloquent Custom Relations: Building a HasManyThrough Alternative for Complex Joins
#laravel #eloquent #database #orm

Eloquent Custom Relations: Building a HasManyThrough Alternative for Complex Joins

3 min read Mohamed Said Mohamed Said

Why Built-In Relations Sometimes Fall Short

Laravel ships with HasMany, BelongsToMany, HasManyThrough, and friends. They handle 95% of real-world schemas. But occasionally you hit a domain shape that none of them model cleanly — for example, a polymorphic pivot with an extra filtering dimension, or a relation that spans a non-FK column like a code string shared across two tables.

The answer is not a raw query shoved into an accessor. The answer is a proper Eloquent relation class that participates in eager loading, with(), whereHas(), and withCount().

Anatomy of an Eloquent Relation

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

public function addConstraints(): void;
public function addEagerConstraints(array $models): void;
public function match(array $models, Collection $results, string $relation): array;
public function getResults(): mixed;

addConstraints applies the WHERE clause for a single-model load. addEagerConstraints receives the full parent batch and applies an IN clause. match stitches results back onto each parent model.

A Concrete Example: HasManyByCode

Imagine Campaign has a tracking_code column, and Conversion also has tracking_code — but there is no FK. A HasManyByCode relation solves this cleanly.

namespace App\Relations;

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

class HasManyByCode extends Relation
{
    public function __construct(
        protected string $foreignKey,
        protected string $localKey,
        Model $related,
        Model $parent,
    ) {
        parent::__construct($related->newQuery(), $parent);
    }

    public function addConstraints(): void
    {
        if (static::$constraints) {
            $this->query->where(
                $this->foreignKey,
                $this->parent->{$this->localKey}
            );
        }
    }

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

        $this->query->whereIn($this->foreignKey, $keys);
    }

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

    public function match(array $models, Collection $results, string $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(): mixed
    {
        return $this->query->get();
    }
}

Wiring It Into the Model

Add a convenience method on Campaign that mirrors how built-in relations are declared:

use App\Relations\HasManyByCode;
use App\Models\Conversion;

class Campaign extends Model
{
    public function conversions(): HasManyByCode
    {
        return new HasManyByCode(
            foreignKey: 'tracking_code',
            localKey: 'tracking_code',
            related: new Conversion(),
            parent: $this,
        );
    }
}

Now standard Eloquent APIs work without modification:

// Eager load — single query with whereIn
$campaigns = Campaign::with('conversions')->get();

// Existence check
$active = Campaign::whereHas('conversions', fn ($q) =>
    $q->where('converted_at', '>=', now()->subDays(7))
)->get();

// Count
$campaigns = Campaign::withCount('conversions')->get();

Handling withCount and whereHas

These features rely on getRelationExistenceQuery(). The default implementation works for simple cases, but if your foreign key lives on the related table you may need to override it:

public function getRelationExistenceQuery(
    Builder $relatedQuery,
    Builder $parentQuery,
    mixed $columns = ['*']
): Builder {
    return $relatedQuery
        ->select($columns)
        ->whereColumn(
            $this->foreignKey,
            $parentQuery->qualifyColumn($this->localKey)
        );
}

This single override unlocks whereHas, doesntHave, and withCount for your custom relation.

Key Takeaways

  • Extend Relation directly; implement addConstraints, addEagerConstraints, match, and getResults as a minimum.
  • initRelation prevents null relation errors on models that have no matching rows.
  • Override getRelationExistenceQuery to unlock whereHas and withCount.
  • Group results by the foreign key in matchgroupBy on a Collection is O(n) and avoids nested loops.
  • Custom relations are first-class citizens: they work with load(), loadMissing(), and API resources.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can a custom relation class be used with Laravel's API resources and `whenLoaded()`?
Yes. As long as `setRelation()` is called in `match()` and `initRelation()` seeds an empty collection, `$model->relationLoaded('conversions')` returns true after eager loading, and `whenLoaded()` in a resource works identically to built-in relations.
Q02 Does this approach work with `loadMissing()` on already-retrieved models?
It does. `loadMissing()` calls `addEagerConstraints` on the batch of models that lack the relation, then calls `match()` to hydrate them — the same code path as `with()` during the initial query.
Q03 How do I add default ordering to the custom relation?
Apply `$this->query->orderBy(...)` inside `addConstraints()`. For eager loads, ordering is applied per-model after `match()` groups results, so you may also sort the collection inside `match()` before calling `setRelation()`.

Continue reading

More Articles

View all