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.
Install, pin, and read the source before writing a model.
composer require mpociot/laravel-composite-key
There is nothing to register: no service provider, no config file, no facade, no migration helpers. The whole package is one trait in src/. That makes day-one setup trivial and day-two risk non-trivial, because the last release is May 2016 and nothing will patch a compatibility break for you.
Pin it in composer.json ("mpociot/laravel-composite-key": "1.0.*" or an exact tag) so an unrelated composer update cannot move it under you.
Confirm the trait's real name and namespace. Descriptions call it CompositeKey, but the shipped class is generally imported as CompositeKeyTrait. Open src/ and use whatever is actually there:
use Mpociot\LaravelCompositeKey\CompositeKeyTrait;
Wire one model, minimally.
use Illuminate\Database\Eloquent\Model;
use Mpociot\LaravelCompositeKey\CompositeKeyTrait;
class OrderLine extends Model
{
use CompositeKeyTrait;
protected $primaryKey = ['order_id', 'line_no']; // must be an ARRAY
public $incrementing = false; // must be false
protected $keyType = 'int'; // 'string' for text keys
protected $guarded = [];
}
All three of those properties are load-bearing. $primaryKey as an array alone does nothing; $incrementing = true will make inserts go through insertGetId() on a table with no single auto-increment column.
First use case — the one that justifies the package. A line-item table whose natural identity is a tuple:
$line = OrderLine::find([42, 3]); // WHERE order_id = 42 AND line_no = 3
$line->quantity = 5;
$line->save(); // UPDATE ... WHERE order_id = 42 AND line_no = 3
$line->delete(); // DELETE ... WHERE order_id = 42 AND line_no = 3
Verify before you build on it. The trait's only real contract is that it overrides setKeysForSaveQuery() (and the key-aware find/hydration path) in a way that stays compatible with your installed Illuminate\Database\Eloquent\Model. Prove it on a real table:
DB::enableQueryLog();
OrderLine::find([42, 3]);
OrderLine::find([42, 3])->save();
dump(DB::getQueryLog());
You must see both columns in every WHERE, and never where id is null or where 0 = 1. If you see either, the override is being bypassed — stop and fix that before writing a single feature.
Where to look, in order: src/CompositeKeyTrait.php (~30 lines; that is the entire product), then the matching setKeysForSaveQuery() in vendor/laravel/framework, then SHOW CREATE TABLE order_lines; to confirm PRIMARY KEY (order_id, line_no) is genuinely composite and not just a unique index.
Never let calling code assemble the tuple inline. Positional order in the array must match $primaryKey exactly, and find([3, 42]) looks identical to find([42, 3]) at the call site while returning null. Centralize the shape:
class OrderLineRepository
{
public function find(int $orderId, int $lineNo): ?OrderLine
{
return OrderLine::find([$orderId, $lineNo]);
}
public function put(int $orderId, int $lineNo, array $attrs): OrderLine
{
$line = $this->find($orderId, $lineNo)
?? new OrderLine(['order_id' => $orderId, 'line_no' => $lineNo]);
return tap($line)->fill($attrs)->save();
}
}
Do not reach for updateOrCreate() / firstOrCreate() on these models without testing them first. They route through the same key machinery and their multi-column behavior is not guaranteed by the package's README.
Put the trait on one abstract base model. When you fork, vendor, or swap the package, the change is a single use statement:
abstract class CompositeKeyModel extends Model
{
use CompositeKeyTrait;
public $incrementing = false;
protected $guarded = [];
}
Then class OrderLine extends CompositeKeyModel. This also gives you one obvious place to add getRouteKey(), key-join helpers, or a keyTuple() accessor.
Read paths are safe; write paths are the ones to audit. Anything that never touches getKeyName() behaves normally:
$lines = OrderLine::where('order_id', 42)->orderBy('line_no')->get();
$total = OrderLine::where('order_id', 42)->sum('amount');
OrderLine::where('order_id', 42)->paginate(25);
Chained where, orderBy, aggregates, and pagination are where you should spend your feature budget — reporting and list screens over composite-key tables carry almost no risk.
Route model binding: write an explicit resolver or skip implicit binding. getRouteKey() is contractually a scalar, so a tuple cannot round-trip through it. The lowest-friction version is a closure route:
Route::get('/orders/{orderId}/lines/{lineNo}', function (int $orderId, int $lineNo) {
return OrderLine::find([$orderId, $lineNo]) ?? abort(404);
});
If you want binding, encode and decode deliberately:
public function getRouteKey(): string
{
return "{$this->order_id}-{$this->line_no}";
}
public function resolveRouteBinding($value, $field = null): ?self
{
[$orderId, $lineNo] = explode('-', $value, 2);
return static::find([(int) $orderId, (int) $lineNo]);
}
Serialization and API resources. Never emit ['id' => $model->getKey()] on these models. Emit the columns and build URLs from them:
return [
'order_id' => $this->order_id,
'line_no' => $this->line_no,
'quantity' => $this->quantity,
'url' => route('order-lines.show', [$this->order_id, $this->line_no]),
];
Relationships: keep composite-key models at the leaf end. A hasMany from a normal model to a composite-key model works fine because the foreign key is a single column:
public function lines()
{
return $this->hasMany(OrderLine::class, 'order_id');
}
Do not make a composite-key model the parent of a hasMany, the target of a belongsTo, or a morph target — those resolve foreign keys through a single getKeyName() and will generate wrong or truncated joins. Cross-composite joins are a different problem (awobaz/compoships); don't stack the two packages.
Bulk writes bypass the trait entirely — use that.
OrderLine::where('order_id', 42)->update(['locked' => true]);
OrderLine::where('order_id', 42)->delete();
For anything touching more than a handful of rows, this is both faster and safer than iterating models, because it never exercises the composite-key save path.
Factories and seeding. Define the composite columns explicitly rather than relying on sequences:
OrderLine::factory()->create(['order_id' => 1, 'line_no' => 1]);
Keep one canary test and run it on every PHP/Laravel bump:
public function test_composite_key_roundtrip(): void
{
$line = OrderLine::create(['order_id' => 1, 'line_no' => 1, 'quantity' => 1]);
$found = OrderLine::find([1, 1]);
$this->assertNotNull($found);
$found->update(['quantity' => 9]);
$this->assertSame(9, OrderLine::find([1, 1])->quantity);
$found->delete();
$this->assertNull(OrderLine::find([1, 1]));
$this->assertDatabaseMissing('order_lines', ['order_id' => 1, 'line_no' => 1]);
}
This is the single highest-value thing you can write with this package. It fails loudly the moment signature drift kicks in.
Signature drift is the only real failure mode, and it can fail silently. A 2016 override of setKeysForSaveQuery() against a modern, type-hinted setKeysForSaveQuery(Builder $query) either fatal-errors (loud — fine) or gets bypassed (silent — dangerous). The silent variant produces UPDATE ... WHERE 0 = 1, affecting zero rows while save() returns truthy. Re-run the query-log check after every framework or PHP upgrade.
$incrementing = false is not optional. With it left true, inserts call insertGetId() against a table with no single auto-increment column, giving broken inserts or a bogus lastInsertId. For text keys also set protected $keyType = 'string';.
find() changes meaning. Core Eloquent: Model::find([1,2,3]) returns a collection of three models. Under this trait, the same array is reinterpreted as a composite tuple. Any generic helper doing $class::find($ids) will behave differently for these models. Keep composite-key models out of polymorphic lookup code.
Audit everything that assumes a scalar key. Specifically: cache keys built from $model->getKey(), session keys, getRouteKey(), JSON id fields, findOrFail() messages, ->is() comparisons, and queue payloads keyed on model ID. Replace with explicit columns:
$cacheKey = "order_line:{$line->order_id}:{$line->line_no}";
fresh() / refresh() are unproven. They re-query via the primary key and may emit a malformed or partial where. Test them; if they misbehave, reload explicitly:
OrderLine::where('order_id', $line->order_id)
->where('line_no', $line->line_no)
->firstOrFail();
getQualifiedKeyName() returning an array breaks third parties. Any package that string-interpolates where {$model->getQualifiedKeyName()} = ? — admin panels, auditing/activity-log packages, Nova-style resource tooling, some auth drivers — will build invalid SQL. Before adopting, grep composer.json for packages that touch models generically and smoke-test the admin surface against a composite-key model.
Debug with the query log, not with the return value. save() returning true tells you nothing here. DB::enableQueryLog() / ->toSql() with bindings is the only reliable signal that both key columns reached the WHERE.
Prefer redesigning the schema. If you control the table, a surrogate id plus UNIQUE(order_id, line_no) gives identical integrity with zero friction across binding, relations, caching, and third-party tooling. Reserve this trait for vendor, legacy, or replicated tables where the composite key is a fact of the data rather than a modeling choice.
Keep the blast radius small and named. This package pays off on a handful of read-mostly models. If the list of models inheriting your base class keeps growing, that's the signal to stop and revisit the schema. Write down who owns the fork before you adopt.
Benchmark alternatives before committing. fico7489/laravel-composite-key is the more actively maintained package in this niche; awobaz/compoships solves the adjacent composite-join problem. Pick one, pin it, and isolate it behind a single base model so swapping is a one-file change.
How can I help you explore Laravel packages today?