Custom Eloquent Relations: Composite Keys &amp; Polymorphic | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://www.msaied.com/public) - [Home](https://www.msaied.com/public)
- [Projects](https://www.msaied.com/public/projects)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [About](https://www.msaied.com/public#about)

           [  Contact](https://www.msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://www.msaied.com/public)
- [ProjectsCase studies](https://www.msaied.com/public/projects)
- [ArticlesEngineering notes](https://www.msaied.com/public/articles)
- [CertificatesCredentials](https://www.msaied.com/public/certificates)
- [AboutHow I work](https://www.msaied.com/public#about)
- [ContactGet in touch](https://www.msaied.com/public#contact)

  [Start a conversation](https://www.msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. Laravel Custom Eloquent Relations: Building Polymorphic and Composite-Key Relations

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

 Eloquent's built-in relations cover 90% of cases, but composite-key joins and non-standard polymorphic pivots demand custom relation classes. Learn how to build them correctly.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://www.msaied.com/public#person) Published 28 Jul 2026 · Updated 28 Jul 2026 · 4 min read

ShareCopy linkCopied

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

  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)

 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.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [eloquent](https://www.msaied.com/public/articles?search=eloquent)
- [database](https://www.msaied.com/public/articles?search=database)
- [orm](https://www.msaied.com/public/articles?search=orm)
- [php](https://www.msaied.com/public/articles?search=php)

 Frequently asked questions 
---------------------------

  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.

   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.

   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.

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

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

[About](https://www.msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articlePostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Overhead](https://www.msaied.com/public/articles/postgresql-jsonb-in-laravel-indexing-querying-and-casting-without-the-overhead) [Next articleDeploy Next.js and Nuxt Apps on Laravel Cloud](https://www.msaied.com/public/articles/deploy-nextjs-and-nuxt-apps-on-laravel-cloud)  

   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)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://www.msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/743/8998fac3a41451ab3fe1588194e17a43.png) Filament · 3 min read### Securing Filament Plugins with Plumb: Automated Security Scoring for PHP Packages

5 Oct 2026 ](https://www.msaied.com/public/articles/securing-filament-plugins-with-plumb-automated-security-scoring-for-php-packages) [ ![](https://cdn.msaied.com/742/2d02018669cdeedccb5de2efb898f0ee.png) Filament · 3 min read### Filament v3.3.56 Released: File Hash Names and Livewire Upload Fix

5 Oct 2026 ](https://www.msaied.com/public/articles/filament-v3356-released-file-hash-names-and-livewire-upload-fix) [ ![](https://cdn.msaied.com/741/5b55c123ad08e4d34e1f4b99ad6a428b.png)  · 3 min read### Filament v4 Schema-Based Forms, Infolists, and the Unified Schema API

5 Oct 2026 ](https://www.msaied.com/public/articles/filament-v4-schema-based-forms-infolists-and-the-unified-schema-api-5) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://www.msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://www.msaied.com/public/sitemap.xml)
