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
Relationdirectly; implementaddConstraints,addEagerConstraints,match, andgetResultsas a minimum. initRelationpreventsnullrelation errors on models that have no matching rows.- Override
getRelationExistenceQueryto unlockwhereHasandwithCount. - Group results by the foreign key in
match—groupByon a Collection is O(n) and avoids nested loops. - Custom relations are first-class citizens: they work with
load(),loadMissing(), and API resources.