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/01M22N44A70A5MC2S599JP0MPH.webp)  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) 

 [ ![Laravel Macro-Free Extensibility: Custom Query Builder Classes and Fluent Scopes](https://cdn.msaied.com/658/b45c3cc06b92a332e526bed9bb1f826d.png) laravel eloquent architecture 

### Laravel Macro-Free Extensibility: Custom Query Builder Classes and Fluent Scopes

Skip global macros and reach for typed, testable custom query builder classes in Laravel. Learn how to bind a...

  ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)  Mohamed Said 

 11 Sep 2026     1 min read  

  Read    

 ](https://msaied.com/articles/laravel-macro-free-extensibility-custom-query-builder-classes-and-fluent-scopes) [ ![Testing Filament Resources, Actions, and Form Assertions with Pest](https://cdn.msaied.com/655/efd33245cffa553c1dffba29721e0139.png) filament pest testing 

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

A practical guide to writing reliable Pest tests for Filament v3 resources — covering table actions, form subm...

  ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)  Mohamed Said 

 11 Sep 2026     4 min read  

  Read    

 ](https://msaied.com/articles/testing-filament-resources-actions-and-form-assertions-with-pest-4) [ ![PayZephyr: One Payment API for Stripe, Paystack, and PayPal in Laravel](https://cdn.msaied.com/657/2cc520b20de249b38bc52120605ff447.png) Laravel Payments Stripe 

### PayZephyr: One Payment API for Stripe, Paystack, and PayPal in Laravel

PayZephyr is a Laravel package that wraps eight payment providers—Stripe, Paystack, PayPal, Flutterwave, and m...

  ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)  Mohamed Said 

 11 Sep 2026     3 min read  

  Read    

 ](https://msaied.com/articles/payzephyr-one-payment-api-for-stripe-paystack-and-paypal-in-laravel) 

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