Custom Eloquent Relations: Composite Keys &amp; Polymorphic | 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)    Laravel Custom Eloquent Relations: Building Polymorphic and Composite-Key Relations        On this page       1. [  Why Eloquent's Built-in Relations Sometimes Fall Short ](#why-eloquents-built-in-relations-sometimes-fall-short)
2. [  Anatomy of a Relation Class ](#anatomy-of-a-relation-class)
3. [  Wiring It Into a Model ](#wiring-it-into-a-model)
4. [  Handling Non-Standard Polymorphic Pivots ](#handling-non-standard-polymorphic-pivots)
5. [  Testing Custom Relations ](#testing-custom-relations)
6. [  Key Takeaways ](#key-takeaways)

  ![Laravel Custom Eloquent Relations: Building Polymorphic and Composite-Key Relations](https://cdn.msaied.com/480/c7d2c0d0fd1823460fbdb138f77b573f.png)

  #laravel   #eloquent   #database   #orm   #php  

 Laravel Custom Eloquent Relations: Building Polymorphic and Composite-Key Relations 
=====================================================================================

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

       Table of contents

1. [  01   Why Eloquent's Built-in Relations Sometimes Fall Short  ](#why-eloquents-built-in-relations-sometimes-fall-short)
2. [  02   Anatomy of a Relation Class  ](#anatomy-of-a-relation-class)
3. [  03   Wiring It Into a Model  ](#wiring-it-into-a-model)
4. [  04   Handling Non-Standard Polymorphic Pivots  ](#handling-non-standard-polymorphic-pivots)
5. [  05   Testing Custom Relations  ](#testing-custom-relations)
6. [  06   Key Takeaways  ](#key-takeaways)

 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.

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

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

```php
$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()`.

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

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

 Found this useful?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fmsaied.com%2Farticles%2Flaravel-custom-eloquent-relations-building-polymorphic-and-composite-key-relations&text=Laravel+Custom+Eloquent+Relations%3A+Building+Polymorphic+and+Composite-Key+Relations) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fmsaied.com%2Farticles%2Flaravel-custom-eloquent-relations-building-polymorphic-and-composite-key-relations) 

 Frequently Asked Questions 
----------------------------

  3 questions  

     Q01  Can I use `withCount` and `withSum` on a custom relation class?        Only if your class also implements `SupportsPartialRelations` and overrides `getRelationExistenceQuery`. The aggregate macros on the query builder delegate to that method, so without it the aggregate scopes silently fall back to incorrect SQL. 

      Q02  Does Laravel's model serialization handle custom relations automatically?        Yes — as long as you call `setRelation` in `match` and `initRelation`, Eloquent's `toArray` and JSON serialization treat the relation like any other loaded relation. No extra work is needed. 

      Q03  Is there a performance penalty for the `orWhere` grouping in eager loading?        On indexed columns the query planner handles OR groups efficiently. For very large parent sets, consider batching with `addEagerConstraints` in chunks of 500 pairs, mirroring how Eloquent's `whereIntegerInRaw` batches large IN clauses. 

  Continue reading

 More Articles 
---------------

 [ View all    ](https://msaied.com/articles) 

 [ ![MySQL Query Profiling in Laravel: EXPLAIN ANALYZE, Slow Query Log, and Index Hints](https://cdn.msaied.com/481/8bfc417cb94b3e2868428e065fe4b166.png) laravel mysql performance 

### MySQL Query Profiling in Laravel: EXPLAIN ANALYZE, Slow Query Log, and Index Hints

Stop guessing why a query is slow. Learn how to use EXPLAIN ANALYZE, Laravel's slow query log listener, and in...

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

 28 Jul 2026     4 min read  

  Read    

 ](https://msaied.com/articles/mysql-query-profiling-in-laravel-explain-analyze-slow-query-log-and-index-hints) [ ![PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Overhead](https://cdn.msaied.com/479/d7c54bd7a42ee57610a29f19a2c5e0fa.png) laravel postgresql jsonb 

### PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Overhead

JSONB columns unlock flexible schemas, but misused they become performance sinkholes. This guide covers GIN in...

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

 28 Jul 2026     4 min read  

  Read    

 ](https://msaied.com/articles/postgresql-jsonb-in-laravel-indexing-querying-and-casting-without-the-overhead) [ ![Laravel 12: New Features, Helpers, and Practical Upgrade Notes](https://cdn.msaied.com/478/117fb4792b77da2d5ae106048c5e70a7.png) laravel laravel-12 upgrade 

### Laravel 12: New Features, Helpers, and Practical Upgrade Notes

Laravel 12 ships with streamlined application scaffolding, first-party starter kits built on React/Vue/Livewir...

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

 28 Jul 2026     4 min read  

  Read    

 ](https://msaied.com/articles/laravel-12-new-features-helpers-and-practical-upgrade-notes-1) 

   [  ![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)
