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
Relationdirectly when no built-in type fits; implement all five lifecycle methods. addEagerConstraintsis the critical method — a naive implementation causes N+1 even withwith().- Use
orWheregrouping for composite-key eager loads to keep it a single query. - Subclassing
MorphToManyfor extra pivot conditions is safer than reimplementing from scratch. - Always cover custom relations with a query-count assertion in Pest to catch regressions.