awobaz/compoships
Compoships adds composite-key relationship support to Laravel's Eloquent ORM, letting you define hasMany, belongsTo and other relations that match two or more columns. Ideal for third-party or legacy schemas; use the Model base class or the Compoships trait.
Compoships is a narrow, purpose-built package: it exists for the case where you inherit a schema you cannot change (third-party DB, legacy ERP export, partitioned multi-tenant tables) and that schema uses composite foreign keys. If you are designing a greenfield schema, the honest answer is that you do not need this package — use surrogate auto-increment keys and let Eloquent do what it does. Reach for it at the boundary layer of an existing database, not as a general relationship upgrade.
Install and wire up in three steps:
composer require awobaz/composhipsuse Awobaz\Compoships\Compoships; to the models on both sides of the relationship. The alternative is extending Awobaz\Compoships\Database\Eloquent\Model, but in most apps you already have an App\Models\Model or other packages that swap the base class — the trait composes cleanly and is the safer default. Adding the trait to your application's base model is a cheap way to make it uniformly available.First use case — the canonical one from the README: a task list where a user is responsible for a category within a team.
// User.php
public function tasks()
{
return $this->hasMany(Task::class, ['team_id', 'category_id'], ['team_id', 'category_id']);
}
// Task.php
public function user()
{
return $this->belongsTo(User::class, ['team_id', 'category_id'], ['team_id', 'category_id']);
}
Where to look first: before writing any relationship, run SHOW CREATE TABLE (or your migration files) across the tables you care about and map every composite FK explicitly. Then verify the relationship in tinker with ->dd() or ->toSql() rather than assuming. The failure mode of this package is silent wrong results, not exceptions, so the first ten minutes of verification pay for themselves.
Both sides, always. A composite hasMany on User is useless if Task does not also use the trait — the eager load on the inverse direction will fall back to broken single-key behavior. Treat "trait on both models" as a lint rule.
Positional array alignment. The foreign-key array and local-key array are matched by index, not by name. ['team_id', 'category_id'] against ['category_id', 'team_id'] will produce a perfectly valid query that joins the wrong columns. Define the column lists once as a class constant and reference it from both models:
class Task extends Model
{
use Compoships;
public const SCOPE_KEYS = ['team_id', 'category_id'];
}
Eager loading is the whole point. The ->where('team_id', $this->team_id) workaround breaks precisely because $this->team_id is null when Laravel builds the eager-load query. With Compoships, User::with('tasks')->get() produces a single WHERE (team_id, category_id) IN ((?,?),(?,?))-style query. Assert this in a test — lazy and eager results must be identical, and count(DB::getQueryLog()) should stay constant as you add rows.
Pivot tables with composite keys require the full five-argument form, and argument order is the classic source of bugs:
return $this->belongsToMany(
Project::class,
'project_user',
['user_team_id', 'user_department_id'], // parent's FK on pivot
['project_team_id', 'project_department_id'], // related's FK on pivot
['team_id', 'department_id'], // parent local keys
['team_id', 'department_id'] // related local keys
);
attach(), detach(), sync(), toggle(), withPivot(), withTimestamps(), has(), and whereHas() all behave normally. For seeding and tests, prefer the list-of-tuples shape:
$team->projects()->attach([['EU', 2], ['US', 1]]);
Use the map shape when you need per-row pivot attributes, keyed by json_encode($tuple):
$team->projects()->attach([
json_encode(['EU', 2]) => ['role' => 'reviewer'],
], ['note' => 'bulk applied to all']);
Custom pivot models must extend Awobaz\Compoships\Database\Eloquent\Relations\Pivot, not Laravel's base Pivot, or using() relations will misbehave on save, delete, and queued operations.
Factories. Use Awobaz\Compoships\Database\Eloquent\Factories\ComposhipsFactory in the factory class if you want ->has(), ->for(), and similar relationship helpers to work. Without it, factory-driven relationship creation resolves keys incorrectly.
Composite primary keys are an opt-in second feature. On a table keyed by (id, tenant_id), declare:
protected $primaryKey = 'id';
protected $compositeKey = ['id', 'tenant_id'];
Keep $primaryKey scalar so getKey(), find($id), and route binding keep working. With $compositeKey set, save(), update(), delete() (including soft deletes), refresh(), and fresh() scope their WHERE by every listed column. A single-model queue property round-trips correctly. A collection on a job property does not — wrap it:
public QueueableCompositeCollection $users;
public function __construct(Collection $users)
{
$this->users = QueueableCompositeCollection::for($users);
}
public function handle(): void
{
$users = $this->users->restore();
}
Silent wrong-row writes are the real risk. With $compositeKey, Model::find($id) still queries the scalar primary key only, and route model binding is unchanged. In a multi-tenant table where id repeats across tenant_id, User::find('u1') and /users/u1 will return whichever row the database gives you first. Pair $compositeKey with a global tenant scope or explicit scoped bindings; do not assume the package protects you on the read path.
Everything except the write path is untouched. firstOrCreate, updateOrCreate, and friends build their own WHERE from user input and ignore $compositeKey. Enumerate composite keys in the attributes array yourself.
$compositeKey must contain $primaryKey. If it does not, the trait throws InvalidUsageException on the first save/delete/refresh — not at boot, so it surfaces late in a test run. Write a unit test that saves a hydrated model, not just one that boots it.
Nulls behave differently in 2.x. Nullable columns are supported, and a null in the WHERE becomes IS NULL rather than a = null predicate that would never match. This is a behavioral break from 1.x — if you are upgrading, run your relationship test suite against fixtures containing nulls. Note the hard limit: a relationship where all key columns are null simply cannot resolve.
Mutating a discriminator before save targets the original row. If you set $user->tenant_id = $newTenant and then save(), the WHERE uses the pre-mutation value from $model->original and the SET writes the new one. That is usually correct, but it surprises people who expect a "move to tenant" semantic.
Never reintroduce where() closures on a relationship. If a relation needs extra filtering, put it in a separate scoped relationship method. Column-value closures evaluated at relation-definition time are exactly the bug this package exists to fix.
Override hooks need parent::. If your model already overrides setKeysForSaveQuery() or setKeysForSelectQuery(), call parent::setKeysForSaveQuery($query) first. Skipping it silently discards the composite WHERE — no warning, just a wrong update.
Indexes are your job. The package adds no schema. A composite relationship eager load generates row-value IN predicates that need a matching composite index on the foreign side (and the pivot's FK columns). Check with EXPLAIN on your production engine; row-value IN support and its index usage vary by database and version.
Malformed arguments are loud, at least. A non-JSON associative key passed to attach()/sync() throws InvalidUsageException, as does a tuple with the wrong arity. Per-row pivot attributes that collide with the foreign-pivot-key columns are dropped silently, though — so do not try to set the linkage through per-row attributes.
Debugging recipe. ->toSql() plus ->getBindings() on the relationship query, or DB::listen() in a test, is faster than reading generated SQL by eye. And remember hasManyThrough is out of scope: for a composite hasManyThrough, either materialize an intermediate query with whereIn on composite tuples or accept two queries.
Adoption check. With roughly 1.2k stars and MIT licensing, this is a well-understood niche tool, but it is a dependency you inherit maintenance risk on. Isolate it: keep composite-key models in one namespace, wrap the reads behind repositories where practical, and write integration tests against the real database engine. If the legacy schema ever goes away, the blast radius stays contained.
How can I help you explore Laravel packages today?