Custom Eloquent Relations in Laravel Explained | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com) [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles  ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

  [ home ](https://msaied.com)    [ articles ](https://msaied.com/articles)    Eloquent Custom Relations: Building a HasManyThrough Alternative for Complex Joins        On this page       1. [  Why Built-In Relations Sometimes Fall Short ](#why-built-in-relations-sometimes-fall-short)
2. [  Anatomy of an Eloquent Relation ](#anatomy-of-an-eloquent-relation)
3. [  A Concrete Example: HasManyByCode ](#a-concrete-example-hasmanybycode)
4. [  Wiring It Into the Model ](#wiring-it-into-the-model)
5. [  Handling withCount and whereHas ](#handling-codewithcountcode-and-codewherehascode)
6. [  Key Takeaways ](#key-takeaways)

  ![Eloquent Custom Relations: Building a HasManyThrough Alternative for Complex Joins](https://cdn.msaied.com/405/9567c07e9cdd10de853dc989784f6c5c.png)

  #laravel   #eloquent   #database   #orm  

 Eloquent Custom Relations: Building a HasManyThrough Alternative for Complex Joins 
====================================================================================

     10 Jul 2026      3 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

1. [  01   Why Built-In Relations Sometimes Fall Short  ](#why-built-in-relations-sometimes-fall-short)
2. [  02   Anatomy of an Eloquent Relation  ](#anatomy-of-an-eloquent-relation)
3. [  03   A Concrete Example: HasManyByCode  ](#a-concrete-example-hasmanybycode)
4. [  04   Wiring It Into the Model  ](#wiring-it-into-the-model)
5. [  05   Handling withCount and whereHas  ](#handling-codewithcountcode-and-codewherehascode)
6. [  06   Key Takeaways  ](#key-takeaways)

 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:

```php
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.

```php
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:

```php
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:

```php
// 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:

```php
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 `match` — `groupBy` 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?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fmsaied.com%2Farticles%2Feloquent-custom-relations-building-a-hasmanythrough-alternative-for-complex-joins&text=Eloquent+Custom+Relations%3A+Building+a+HasManyThrough+Alternative+for+Complex+Joins) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fmsaied.com%2Farticles%2Feloquent-custom-relations-building-a-hasmanythrough-alternative-for-complex-joins) 

 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-&gt;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-&gt;query-&gt;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    ](https://msaied.com/articles) 

 [ ![Laravel Enum Casts, Backed Enums, and Value Semantics in PHP 8.3](https://cdn.msaied.com/408/3534df365ffb2c8f6c430180bfaba8f1.png) laravel php8.3 enums 

### Laravel Enum Casts, Backed Enums, and Value Semantics in PHP 8.3

PHP 8.3 backed enums combined with Laravel's native enum casting unlock type-safe domain models. Learn how to...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 10 Jul 2026     1 min read  

  Read    

 ](https://msaied.com/articles/laravel-enum-casts-backed-enums-and-value-semantics-in-php-83) [ ![Laravel Queues: Reliable Job Retry Strategies with Exponential Backoff and Dead-Letter Handling](https://cdn.msaied.com/407/704b143d40e1a6ac5a2faa4efb900174.png) laravel queues reliability 

### Laravel Queues: Reliable Job Retry Strategies with Exponential Backoff and Dead-Letter Handling

Beyond basic retries: how to implement exponential backoff, custom retry delays, and dead-letter queue pattern...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 10 Jul 2026     4 min read  

  Read    

 ](https://msaied.com/articles/laravel-queues-reliable-job-retry-strategies-with-exponential-backoff-and-dead-letter-handling) [ ![Filament v3 Custom Table Columns: Rendering, State, and Performance at Scale](https://cdn.msaied.com/406/145404572d600dbdab49a13226b0537d.png) filament laravel tables 

### Filament v3 Custom Table Columns: Rendering, State, and Performance at Scale

Go beyond built-in columns in Filament v3. Learn how to build custom table columns with precise state resoluti...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 10 Jul 2026     4 min read  

  Read    

 ](https://msaied.com/articles/filament-v3-custom-table-columns-rendering-state-and-performance-at-scale) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

Explore

- [Home](https://msaied.com)
- [Projects](https://msaied.com/projects)
- [Articles](https://msaied.com/articles)
- [Certificates](https://msaied.com/certificates)
- [Contact](https://msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
