mpociot/laravel-composite-key
Adds support for composite primary keys in Laravel Eloquent models. This package lets models with multi-column keys work with common Eloquent operations like saving, updating, deleting, and finding records, without hacking around the framework's single-key assumptions.
Architecture fit. The package is a single Eloquent trait (~30 lines) that overrides setKeysForSaveQuery() and key-aware find(). There is no service provider, config, migration, or facade — it sits entirely at the model layer and has no footprint anywhere else in the stack. That makes it architecturally cheap to install and architecturally narrow in where it can safely be used: composite-key models must stay at the leaf of the relationship graph (the "many" side of a hasMany with a single-column FK). Making one a belongsTo target, a hasMany parent, or a polymorphic target will produce wrong joins because FK resolution goes through a scalar getKeyName(). The same scalar assumption breaks routing, cache keys, JSON id fields, queue payloads, and any third-party package that builds SQL from getQualifiedKeyName().
Integration feasibility. Low effort, high verification burden. One composer require, one use statement, $primaryKey set to an array, $incrementing = false. No infrastructure, no data migration, no config surface. The cost is not adoption, it is the audit: confirming trait/namespace naming, confirming signature compatibility against your specific Laravel version, confirming the table actually has a composite PRIMARY KEY rather than just a unique index, and proving the override is live via query log before any application code depends on it.
Technical risk. This is the dominant concern and it is not subtle.
setKeysForSaveQuery(Builder $query). A 2016 override without the type hint either fatals loudly on declaration compatibility (acceptable) or is silently bypassed by the parent (dangerous). The silent variant yields where 0 = 1 updates that affect zero rows while save() returns truthy — a data-integrity bug, not a crash.Model::find([1,2]) means "the tuple (1,2)", whereas core means "records 1 and 2". Any generic helper that does $class::find($ids) behaves differently for these models. This is a footgun for shared abstractions.id plus UNIQUE(order_id, line_no) delivers identical integrity guarantees with zero friction across binding, relations, caching, and tooling. The package is justified only for vendor, legacy, or replicated tables whose composite key is a fact of the data.Key questions to resolve before committing.
composer.json traverse models generically (admin panels, activity logs, authorization drivers, resource tooling)? Each is a potential invalid-SQL site.Stack fit. Laravel/Eloquent only, which is where our application lives — so there is no new runtime, no new infrastructure, no new operational surface. The right pattern is to isolate the trait on one shared abstract base model (CompositeKeyModel) rather than importing it into individual models. That makes the eventual swap — to a fork, to fico7489/laravel-composite-key, or to in-house code — a single-line change in one file.
Migration path. There is no data migration; the package is stateless. The relevant migration is the exit path: pin the version, vendor it into packages/ or our own namespace so a composer update cannot silently change or drop it, and keep the use statement in exactly one place. Treat it as owned source, not as a dependency.
Compatibility. Compatibility is gated entirely on the setKeysForSaveQuery signature in the Laravel version we run. The canary is a single integration test covering create → find → update → delete on a real table. That test must be part of the upgrade checklist and must run on every Laravel and PHP bump. Also set $keyType = 'string' for string keys so casting and strict comparisons behave. Do not assume updateOrCreate(), fresh(), or refresh() work — implement find-then-fill explicitly, and test refresh if it appears in a code path.
Sequencing.
WHERE contains both columns and never id or 0 = 1. Stop here if it fails.use, one owner named in the ADR.where chains, aggregates, pagination, orderBy — all unaffected by the trait). Write paths second, behind a repository that owns the key tuple and never lets a caller pass a hand-built array.getRouteKey() is scalar by contract. Ship the explicit route closure form (/orders/{orderId}/lines/{lineNo}) — greppable, no contract gymnastics.Maintenance. Unmaintained, and that is a planning assumption rather than a risk to monitor. The operational cost is a per-upgrade canary test plus an owner who reads the trait's ~30 lines against the current framework method whenever Laravel moves. If nobody owns that, the package should not be adopted. Budget the fork/vendor work as the realistic steady state, not the exception.
Support. No upstream support, no community, no issue triage, zero dependents to learn from. Escalation is internal by definition. Any security or dependency-review gate that requires a maintained upstream will fail this package — surface that early so the decision is made on engineering merit rather than reversed at release time.
Scaling. Runtime performance is a non-issue: queries filter on two indexed columns, and the trait adds no overhead beyond the WHERE clause shape. The scaling risk is organizational. Every additional model using the trait multiplies the audit surface — routing, caching, serialization, generic helpers, third-party admin tooling. If the count of composite-key models is growing, that is a signal to stop and return to the schema conversation rather than to keep extending the pattern. Prefer query-builder bulk operations (where(...)->delete(), where(...)->update()) over per-model loops; they sidestep the trait entirely and are usually a better fit anyway.
Failure modes, ordered by severity.
save() returns truthy, zero rows updated. Highest severity because it is invisible and corrupts user trust in the data. Detection: query log in tests, not in production.where {$model->getQualifiedKeyName()} = ? receives an array and emits malformed SQL. This is the most likely surprise — it appears in the admin surface, not in the code we wrote.id fields. Predictable if audited, embarrassing if not.find() semantic collision. Generic helpers passing ID arrays silently misinterpret them as tuples.$incrementing is left true — insertGetId() against a table with no single auto-increment column.fresh() / refresh() unproven — reload explicitly via a where chain if needed.Ramp-up. Learning cost is roughly an hour for a mid-level Laravel engineer — the package is small and reads like standard Eloquent. The real ramp-up is the audit: enumerating generic model consumers, smoke-testing the admin surface, and writing the canary test. Estimate adoption as small and verification and blast-radius control as the bulk of the effort. Frame it to stakeholders as "one engineer-week of risk work to save one engineer-week of build," and be explicit that the trade is only worth it for a small, named, read-mostly set of externally-keyed tables. For everything else, the correct TPM recommendation is the schema redesign.
How can I help you explore Laravel packages today?