reedware/laravel-composite-relations
Adds composite foreign key support to Eloquent relationships. Define Belongs To, Has One, and Has Many relations using multiple columns, with full eager loading and "where has" query support through a simple trait on your base model.
Install once, wire once, at the base model.
composer require reedware/laravel-composite-relations
There is no service provider, no facade, no config file, no publishable assets. The entire integration surface is a single trait applied to your application's base model:
use Illuminate\Database\Eloquent\Model as Eloquent;
use Reedware\LaravelCompositeRelations\HasCompositeRelations;
abstract class Model extends Eloquent
{
use HasCompositeRelations;
}
Because it lands on the base class, every model in the app immediately gains compositeBelongsTo, compositeHasOne, and compositeHasMany. That's the point — and also the risk: the dependency now sits under every model, so treat the first install and every major bump as a reviewed change with one named owner, not something that rides along in a routine composer update. Check the version matrix first: v6.x targets Laravel 12–13 / PHP 8.2–8.5, v5.x targets Laravel 11–12. Pin to a major.
Read in this order before writing a relation:
README.md §1 (Defining Relations) then §4 (composite and glue). §4 is not optional — the default glue is 'or', and that is the single biggest correctness trap in the package.vendor/reedware/laravel-composite-relations/src/. It's small. Read the three method signatures verbatim so the parameter order is in your head before you type.First use case — the one that justifies the dependency. A summary table keyed by (vendor_id, vendor_name):
class Task extends Model
{
protected $primaryKeys = ['vendor_id', 'vendor_name'];
public function importSummary()
{
return $this->compositeHasOne(
TaskImportSummary::class,
['task_vendor_id', 'task_vendor_name'], // on the related table
['vendor_id', 'vendor_name'], // on this table
'and' // glue — never omit
);
}
}
Immediately confirm the generated SQL before building anything on top:
$q = (new Task)->importSummary()->toSql();
// expect: ... where ("task_vendor_id" = ? and "task_vendor_name" = ?)
Then exercise the two paths that matter:
Task::with('importSummary')->first();
Task::whereHas('importSummary', fn ($q) => $q->where('status', 'ok'))->get();
If you see or between the key pair, stop. Fix the glue before writing a second relation.
Always pass 'and' explicitly, on every relation, forever. Make it a review rule. The default 'or' glue emits ("fk1" = ? or "fk2" = ?), which matches rows sharing either column — a silent over-match that returns plausible, wrong data and never throws. Encode the safe default once on your base model so nobody has to recall argument order:
abstract class Model extends Eloquent
{
use HasCompositeRelations;
protected function compositeBelongsToAnd($related, array $foreign, array $local)
{
// NOTE: 4th arg is the relation name, 5th is the glue
return $this->compositeBelongsTo($related, $foreign, $local, null, 'and');
}
protected function compositeHasOneAnd($related, array $foreign, array $local)
{
return $this->compositeHasOne($related, $foreign, $local, 'and');
}
protected function compositeHasManyAnd($related, array $foreign, array $local)
{
return $this->compositeHasMany($related, $foreign, $local, 'and');
}
}
Mind the signature asymmetry — it will bite you in hour one.
| Method | Signature |
|---|---|
compositeBelongsTo |
($related, $foreign, $local, $relation = null, $glue = 'or') |
compositeHasOne |
($related, $foreign, $local, $glue = 'or') |
compositeHasMany |
($related, $foreign, $local, $glue = 'or') |
Passing 'and' as the fourth argument to compositeBelongsTo doesn't set the glue — it names your relation and, and the glue silently stays 'or'. It fails quietly and produces wrong results. The wrapper methods above exist precisely to make that unreachable.
Keep key-array direction and ordering straight.
compositeBelongsTo: first array = columns on this model, second = columns on the owner/related model.compositeHasOne / compositeHasMany: first array = columns on the related model, second = columns on this model.vendor_id must occupy the same slot as task_vendor_id in both arrays. Swapping slots compiles, runs, and quietly matches nothing (or the wrong thing).Make eager loading the default access pattern. This is where the package pays for itself — replace hand-rolled multi-column joins in controllers with with() and the existence-query family:
return Task::query()
->with(['importSummary' => fn ($q) => $q->select(
'task_vendor_id', 'task_vendor_name', 'status' // key columns are mandatory
)])
->whereHas('importSummary', fn ($q) => $q->where('status', 'ok'))
->whereDoesntHave('importSummary', fn ($q) => $q->whereNotNull('blocked_at'))
->paginate();
Because these return real Relation subclasses, with(), whereHas, doesntHave, whereRelation, has(), and withCount() all flow through the standard machinery. Verify withCount() and has('rel', '>=', 2) once each against a seeded fixture before trusting them in a dashboard — those are the two aggregates most likely to expose a glue mistake.
Nested dot-notation works, and is worth using. Task::with('vendor.importSummary') behaves like ordinary Eloquent, which makes it easy to replace chains of manual lookups deep in a presenter or API resource.
Adopt the $primaryKeys convention only after the explicit form works. Declaring protected $primaryKeys = ['vendor_id', 'vendor_name'] on the parent lets you drop the key arrays:
return $this->compositeHasOne(TaskImportSummary::class, 'and' /* ...positionally careful... */);
Get one relation working with fully explicit arrays first, then delete the arrays and diff the SQL. Derived column names follow Laravel's conventional naming (related_table_singular + key, etc.), so unexpected names are a naming-convention problem, not a package bug. Also note: $primaryKeys is a package property, not a Laravel one — find(), update(), route-model binding and $model->getKey() still use $primaryKey. This package resolves relations; it does not make models composite-key aware for persistence.
Define both sides and keep them in sync. There is no automatic inverse. When you add Task::importSummary() as a compositeHasOne, add the matching TaskImportSummary::task() as a compositeBelongsTo with mirrored key arrays and the same 'and' glue. A mismatch between the two sides is invisible until someone calls $summary->task.
Reach for relation joins only when you need flat rows. With reedware/laravel-relation-joins installed alongside:
Task::query()->joinRelation('importSummary', function ($join) {
$join->where('task_import_summaries.status', 'ok');
})->get();
Genuinely useful for admin/report surfaces that need parent and child columns in one result set — but it's a second dependency with its own version matrix. Install it because you have that need, not preemptively.
Test every composite relation with a fixture that would catch 'or'. Seed at least two parents that share one key column and differ on the other, and assert the relation returns exactly one row — not two. One test per relation, written immediately, is the cheapest possible insurance:
// parent A: (vendor_id=1, vendor_name='acme')
// parent B: (vendor_id=1, vendor_name='globex') <- shares vendor_id
$this->assertCount(1, Task::find(1)->importSummary()->get());
Add a second assertion for the eager-loaded path (Task::with('importSummary')->get()), since eager and lazy loading go through different code paths and only one of them may be exercised by your feature tests.
The 'or' default is the headline hazard. Every symptom of it looks like a data bug rather than a code bug: duplicated children in hasMany, withCount() values that are too high, whereHas returning rows it shouldn't. If a composite relation behaves oddly, dump the SQL first:
$rel = (new Task)->importSummary();
dump($rel->toSql(), $rel->getBindings());
Or watch everything during a request:
DB::listen(fn ($q) => logger()->debug($q->sql, $q->bindings));
NULL keys never match. Composite foreign keys with nullable components are a trap even when the query is built correctly, because NULL = NULL is unknown in SQL. Rows with a missing key part silently disappear from eager loads and existence queries. Enforce NOT NULL on composite key columns in the schema, or normalise with a sentinel.
Index the key pairs in the right order. The generated predicate is an OR of AND groups, so a composite index on (task_vendor_id, task_vendor_name) — in that order — is what makes it sargable. Without it these relations degrade quickly on any table of size. Add it in the same migration that introduces the relation.
Eager-load select() closures must include every key column. Trimming a with(['importSummary' => fn ($q) => $q->select('status')]) drops task_vendor_id/task_vendor_name, so Eloquent can't match children back to parents and the relation silently returns null. Same failure mode as ordinary Eloquent, but more likely here because there are two columns to forget instead of one.
Eager loading here means an OR-of-AND WHERE, not a tuple IN. That's fine for the intended table sizes but is worth knowing before you point a composite relation at a million-row table. Measure with EXPLAIN if the child table is large; consider a materialised single-column key (concatenated hash) if the relation is hot.
No BelongsToMany, no MorphTo, no HasManyThrough. The maintainer has stated there's no intent to add them. If your design needs composite keys on a pivot or a polymorphic relation, this package won't help — restructure to a surrogate key at that boundary rather than fighting it.
Verify writes before relying on them. $task->importSummary()->create([...]) and $summary->task()->associate($task) should populate/set both key columns via the relation's key dictionary, but this is the least-documented area. Write one test asserting both columns land correctly; if a column is missed, set it explicitly in the calling code rather than patching around it later.
save()/saveMany() on hasMany will stamp the parent's key values onto the children. With 'or' glue in play, an over-matching parent read can cascade into writing children against the wrong parent row. Another reason the glue rule is non-negotiable.
Static analysis gives you real protection here. The package ships PHPStan level 9 with generics support for the relation return types. Run PHPStan over your models directory — it will flag array-shape and type mismatches in key arrays that would otherwise surface as runtime nothing-matches. Cheap win for a 22-star dependency.
Version bumps are breaking by policy, not by accident. v3→v4 dropped L8/L9 and PHP 7.x; v4→v5 dropped L10 and PHP 8.1; v5→v6 dropped L11. The maintainer follows Laravel's support policy closely and cuts majors accordingly. Budget a review per Laravel upgrade rather than expecting a patch-level ride.
Extension points are thin, and that's the design. There's no manager class, no macro registration, no driver to swap. If you need behaviour the package doesn't offer (a custom Relation subclass, tuple-IN eager loading, a composite BelongsToMany), you're subclassing Illuminate\Database\Eloquent\Relations\* yourself — or, more realistically, adding the concatenated-hash column and going back to native single-key Eloquent. Choose that fork deliberately and early; retrofitting it after ten composite relations exist is painful.
How can I help you explore Laravel packages today?