# Compoships lets an Eloquent relationship match on two columns

> Compoships is a Laravel package that adds relationships based on two or more columns to Eloquent, which is what you need when the schema is not yours and the natural key is composite. The relationships are declared with the same methods as before, hasMany, belongsTo and belongsToMany, except that the key arguments become arrays, and the package also handles the many-to-many case with composite pivot keys, including attach and sync input shapes that carry per-row attributes.

**topclaudy/compoships** — Multi-columns relationships for Laravel's Eloquent ORM

- Repository: https://github.com/topclaudy/compoships
- Stars: 1,226 · Forks: 144
- Language: PHP
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/topclaudy-compoships

## Why a where clause on a relationship is not enough

The problem is stated in the README with the failed attempt included. Eloquent does not support composite keys, so there is no way to define a relationship from one model to another by matching more than one column, and the usual workaround, a where clause on the relationship, does not work when eager loading because at the time the relationship is processed $this->team_id is null. The reason is the order of operations in Eloquent, where the parent model is not populated when the constraint is built, so the extra condition is evaluated against nothing. That is a genuinely different failure from a missing index or a wrong column, because the relationship works when you load it lazily and breaks when you eager load it, which is exactly the kind of bug that appears in production and not in a test that fetches one model. The need for this tends to arise when working with a third party or pre-existing schema or database, which is the case the package names first.

## Two ways in, and both sides must use one

There are two ways to adopt the package and the choice is about how much of your model layer you want to touch. The first is to make your model class derive from the Awobaz\Compoships\Database\Eloquent\Model base class, which extends the Eloquent base class without changing its core functionality. The second is the Awobaz\Compoships\Compoships trait, for the case where you cannot change what your models extend. The trait is the pragmatic option in a codebase with a shared base model of your own, since a trait does not fight your inheritance chain. The rule that catches people out is stated as a note, and it is the one thing to check before writing any code: to define a multi-column relationship from a model A to another model B, both models must either extend the Compoships model class or use the Compoships trait. The relationship is built on both ends, so a model on one side that has not opted in produces a query that quietly matches on one column.

## The syntax is the same methods with arrays

Once the models are set up, the syntax is the standard relationship methods with an array of columns instead of a string. A hasMany passes the foreign key columns and then the local key columns:

```php
public function b()
{
    return $this->hasMany('B', ['foreignKey1', 'foreignKey2'], ['localKey1', 'localKey2']);
}
```

And the inverse uses belongsTo with the same shape, the foreign keys and then the owner keys:

```php
public function a()
{
    return $this->belongsTo('A', ['foreignKey1', 'foreignKey2'], ['ownerKey1', 'ownerKey2']);
}
```

The familiarity is the point. Nothing about the call site changes except the type of the key arguments, so a developer who knows Eloquent reads the code without a tutorial, and the existing eager loading, with, whereHas and the rest continue to work, which the README states explicitly for the many-to-many case and which the examples demonstrate for the one-to-many case.

## Many-to-many through a composite pivot

The pivot case is the one that has no equivalent in stock Eloquent, and the syntax carries four arrays because there are two composite keys on each side. The belongsToMany call names the related model, the pivot table, the foreign pivot keys for A, the foreign pivot keys for B, the local keys on A and the local keys on B:

```php
public function b()
{
    return $this->belongsToMany(
        B::class,
        'a_b',                                  // pivot table
        ['a_foreignKey1', 'a_foreignKey2'],     // foreign pivot keys for A
        ['b_foreignKey1', 'b_foreignKey2'],     // foreign pivot keys for B
        ['localKey1', 'localKey2'],             // local keys on A
        ['localKey1', 'localKey2']              // local keys on B
    );
}
```

All the standard belongsToMany operations work with composite keys: attach, detach, sync, toggle, withPivot, withTimestamps, eager loading, and the existence queries has and whereHas. That list matters more than it looks, because withPivot and withTimestamps are where a naive implementation tends to lose the extra pivot columns, and the existence queries are the ones a dashboard depends on.

## Two shapes for attach and sync, and one of them throws

For attach, sync, syncWithoutDetaching and toggle on composite-key relations there are two supported input shapes. The first is a list of composite tuples, each an array aligned with the related-pivot-key columns:

```php
$team->projects()->attach([
    ['EU', 2],
    ['US', 1],
]);
```

The second is a map of json_encode($tuple) to $perRowAttributes, equivalent to Laravel's single-key id to attributes shape, where the key must be the JSON encoding of the composite tuple produced with json_encode. Per-row attributes override any shared bulk attributes on a key conflict, and any per-row attribute key that collides with the foreign-pivot-key columns is silently dropped to prevent overriding the parent linkage:

```php
$team->projects()->attach([
    json_encode(['EU', 2]) => ['role' => 'reviewer'],
    json_encode(['US', 1]) => ['role' => 'lead'],
], ['note' => 'bulk applied to all']);
```

Passing an associative array key that is not a JSON-encoded tuple of the correct arity throws an Awobaz\Compoships\Exceptions\InvalidUsageException, so a malformed pivot write fails loudly rather than writing a wrong row. The silent drop of colliding attribute keys is the one behaviour to be aware of, since a typo in an attribute name that happens to match a pivot key column disappears without a warning.

## Factories need the Compoships trait too

There is a separate concern for factories, and the README addresses it directly, because you will probably need factories for your Compoships models. The advice is to use factory methods to create relationship models, for example by using the has method, and to use the Awobaz\Compoships\Database\Eloquent\Factories\ComposhipsFactory trait in your factory classes to be able to use relationships correctly. That is a small detail in a large project, and it is the sort of thing that only surfaces when somebody writes the factory after the model, sees a strange relationship count, and has to read the package source. The end to end example in the README shows the case the package is built for, a task list with categories managed by several teams of users, where a task belongs to a category, a task is assigned to a team, a team has many users, a user belongs to one team, and a user is responsible for one category of tasks, so the user responsible for a task is the user currently in charge for the category inside the team, and the relationship is declared on the composite of team_id and category_id.

## Where this fits, and what it does not fix

The decision is really a schema decision, and the package exists for the case where the schema is not yours. If you can add a surrogate key to the table, a single column relationship is simpler, faster to reason about and needs no package, and the composite relationship only buys you the ability to express a relationship that the data model already implies. The second limit is that this is a thin layer over Eloquent, so it inherits Eloquent's behaviour and its surprises, and the relationship between a Compoships model and a plain one is exactly where the both-sides rule bites. The maintenance picture is healthy for a package of this size, with 3.0.1 in April 2026, 3.0.2 in June and 3.1.0 on 2026-08-19, the last push on the same day, a Travis configuration and a run-matrix-tests.sh script for testing across versions, a phpunit.xml.dist, a TODO.md and a .codeclimate.yml. The package name is worth noting for anyone searching for it, since it is installed as awobaz/compoships and lives under the Awobaz namespace.

## Conclusion

Compoships is the right dependency when your database has composite keys and you want to keep using Eloquent rather than dropping to query builder, because it extends the relationship methods you already know instead of replacing them, and because it covers the many-to-many case that a simple foreign key workaround cannot. It is the wrong tool if you can add a surrogate key to your schema, since a single column relationship is faster to reason about and needs no package, and it is the wrong tool if you are on a Laravel version where the underlying relationship API has moved on. Before adopting it, read the two-model rule, because both sides of the relationship have to use the base model or the trait, and check the pivot attribute behaviour, since per-row attributes that collide with the foreign pivot key columns are dropped rather than applied.

## FAQ

### Why does my Eloquent relationship with a where clause fail on eager loading?

Because at the time the relationship is processed $this->team_id is null, so the extra condition is evaluated against nothing. The relationship works when loaded lazily and breaks when eager loaded, which is why the package exists to add multi-column relationships that Eloquent can build without a closure over the parent.

### How do I install and set up Compoships?

Install it with composer require awobaz/compoships. Then either make your models extend Awobaz\Compoships\Database\Eloquent\Model or use the Awobaz\Compoships\Compoships trait. Both models on either side of a multi-column relationship must use one of those two options.

### How do I attach records on a composite-key pivot relation?

Two shapes are supported for attach, sync, syncWithoutDetaching and toggle. You can pass a list of composite tuples aligned with the related pivot key columns, or a map of json_encode($tuple) to per-row attributes, where per-row attributes override shared bulk attributes and keys colliding with the foreign pivot key columns are dropped.

### Does Compoships work with factories?

Yes, with an extra step. The README suggests using factory methods such as has to create relationship models, and to use the Awobaz\Compoships\Database\Eloquent\Factories\ComposhipsFactory trait in your factory classes so that relationships behave correctly. The package is MIT licensed and version 3.1.0 was published on 2026-08-19.

## Sources

- [Issues](https://github.com/topclaudy/compoships/issues)
- [License: MIT](https://github.com/topclaudy/compoships/blob/master/LICENSE)
- [README](https://github.com/topclaudy/compoships/blob/master/README.md)
- [Releases](https://github.com/topclaudy/compoships/releases)
- [topclaudy/compoships on GitHub](https://github.com/topclaudy/compoships)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/topclaudy-compoships
