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
addConstraintsandaddEagerConstraintsare the two critical split points — get them wrong and you either miss results or produce a Cartesian product.- Always call
static::$constraintsguard inaddConstraints; Eloquent disables it during eager load setup. initRelationmust seed a default (empty collection ornull) so models without matches don't throw undefined relation errors.- Override
getRelationExistenceCountQueryifwithCountorwhereHassemantics matter to your consumers. - Custom relations compose with all standard Eloquent features:
with,load,withCount, constraint closures.