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

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

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

  [ home ](https://www.msaied.com)    [ articles ](https://www.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%2Fwww.msaied.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%2Fwww.msaied.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://www.msaied.com/articles) 

 [ ![New in Laravel 13: First-Party Image Manipulation](https://cdn.msaied.com/427/504aed7f9f1c4a1cd411c7c4a4b6bc7c.png) Laravel 13 Image Manipulation PHP 

### New in Laravel 13: First-Party Image Manipulation

Laravel 13 introduces a first-party image manipulation feature, removing the need for third-party packages for...

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

 15 Jul 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/new-in-laravel-13-first-party-image-manipulation) [ ![Filament v4 Testing with Pest: Resources, Actions, and Form Assertions](https://cdn.msaied.com/426/a81430a378b2849b6fbecb8235b9119f.png) filament pest testing 

### Filament v4 Testing with Pest: Resources, Actions, and Form Assertions

A practical guide to testing Filament v4 panels using Pest — covering resource page assertions, table action d...

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

 15 Jul 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/filament-v4-testing-with-pest-resources-actions-and-form-assertions) [ ![First-Party Image Processing in Laravel 13.20](https://cdn.msaied.com/428/f426d416be5b32c85a6eb69b1dbb5d74.png) Laravel Image Processing Laravel 13 

### First-Party Image Processing in Laravel 13.20

Laravel 13.20 ships a built-in Image facade with an immutable, driver-based API for resizing, cropping, and fo...

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

 15 Jul 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/first-party-image-processing-in-laravel-1320) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://www.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://www.msaied.com)
- [Projects](https://www.msaied.com/projects)
- [Articles](https://www.msaied.com/articles)
- [Certificates](https://www.msaied.com/certificates)
- [Contact](https://www.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)
