Some Laravel applications run against a schema nobody on the team designed, where the link between two tables is a pair of columns rather than one foreign key. Eloquent matches a single column, and the usual workaround of chaining a where() onto hasMany() returns the wrong rows under eager loading. Laravel builds an eager-loaded relationship from a new empty model instance, so the parent attribute you reference in the where() is null. Compoships, by Claudin J. Daniel, lets you pass an array of columns where Eloquent expects a key name.
Defining a Relationship on Two Columns
Both models in the relationship need the Awobaz\Compoships\Compoships trait, or they can extend Awobaz\Compoships\Database\Eloquent\Model, which subclasses Eloquent's base model. After that the relationship methods take arrays instead of strings.
Take an order table imported from an accounting system, where an order number is only unique within a company code. Reaching the order lines means matching both columns:
namespace App\Models; use Awobaz\Compoships\Compoships;use Illuminate\Database\Eloquent\Model; class Order extends Model{ use Compoships; public function lines() { return $this->hasMany( OrderLine::class, ['company_code', 'order_no'], ['company_code', 'order_no'] ); }}
The inverse uses the same shape:
class OrderLine extends Model{ use Compoships; public function order() { return $this->belongsTo( Order::class, ['company_code', 'order_no'], ['company_code', 'order_no'] ); }}
hasOne, hasMany, belongsTo, and belongsToMany accept column arrays. Nullable columns are handled, though a relationship whose key columns are all null returns nothing.
Many-to-Many Through a Pivot Table
belongsToMany takes the pivot table name, then four arrays: the pivot columns pointing at each side, and the local key columns on each model. Here a warehouse and a carrier are both identified by a region code plus a short code:
class Warehouse extends Model{ use Compoships; public function carriers() { return $this->belongsToMany( Carrier::class, 'carrier_warehouse', ['warehouse_region_code', 'warehouse_code'], ['carrier_region_code', 'carrier_code'], ['region_code', 'code'], ['region_code', 'code'] ); }}
attach(), detach(), sync(), toggle(), withPivot(), withTimestamps(), has(), and whereHas() all work on that relationship. Where Laravel takes a list of ids, Compoships takes a list of tuples with one value per related pivot key column:
$warehouse->carriers()->attach([ ['EU', 'DHL'], ['EU', 'UPS'],]);
For per-row pivot attributes, the array key is the tuple run through json_encode(), which stands in for the [id => attributes] form:
$warehouse->carriers()->attach([ json_encode(['EU', 'DHL']) => ['priority' => 1], json_encode(['EU', 'UPS']) => ['priority' => 2],], ['contract_year' => 2026]);
An associative key that is not a JSON tuple of the right length raises Awobaz\Compoships\Exceptions\InvalidUsageException. Custom pivot models are supported through using(), as long as the pivot class extends Awobaz\Compoships\Database\Eloquent\Relations\Pivot.
Composite Primary Keys on the Write Path
On a table keyed by (invoice_no, company_code), saving a hydrated model produces UPDATE ... WHERE invoice_no = ?. The same invoice number can exist under a different company code, so that statement can update the wrong row. Compoships scopes the write path by every key column once you declare $compositeKey, while $primaryKey stays a scalar column name:
class Invoice extends Model{ use Compoships; protected $primaryKey = 'invoice_no'; public $incrementing = false; protected $keyType = 'string'; protected $compositeKey = ['invoice_no', 'company_code'];}
save(), update(), delete() including soft deletes, refresh(), and fresh() then build their WHERE clause from both columns:
$invoice = Invoice::where('invoice_no', 'INV-4471') ->where('company_code', 'DE01') ->first(); $invoice->status = 'paid';$invoice->save(); // UPDATE invoices SET status = ?// WHERE invoice_no = ? AND company_code = ?
Everything keyed off the scalar column keeps stock Eloquent behavior: Model::find($id), route model binding, and helpers like firstOrCreate() and updateOrCreate() that build their own conditions from what you pass them.
When the stored value of a key column is null, the trait writes WHERE column IS NULL instead of binding null into an equality check, so $compositeKey also covers tables that use a unique index with a nullable discriminator. If you change a key column in memory before calling save(), the WHERE clause uses the original value from storage while the SET clause writes the new one.
Queued Models
A single composite-keyed model on a job property survives a round trip through the queue. SerializesModels calls getQueueableId(), which returns the JSON-encoded key columns, and the worker decodes it into a query scoped by all of them. Payloads queued before the feature existed still restore through Laravel's own path, so you can upgrade with jobs already on the queue.
Collections need a wrapper. Laravel's restoreCollection re-keys the loaded models by their scalar key and looks them up by the queued ids, which for these models are JSON strings, so the collection comes back empty. QueueableCompositeCollection captures the key tuples at dispatch time and reloads the rows in one query:
use Awobaz\Compoships\Queue\QueueableCompositeCollection; class ExportInvoices{ use SerializesModels; public QueueableCompositeCollection $invoices; public function __construct(Collection $invoices) { $this->invoices = QueueableCompositeCollection::for($invoices); } public function handle(): void { $invoices = $this->invoices->restore(); }}
The wrapper keeps the original order, the eager-loaded relations, and the connection. Mixed-class collections raise a LogicException and a bad $compositeKey raises InvalidUsageException, both at the point you wrap.
Installation
Compoships requires PHP 8.2 and Laravel 12 or 13. Install it with Composer:
composer require awobaz/compoships
The package covers two gaps and leaves the rest of Eloquent alone. A single scalar primary key is still the better default for a schema you control, and Compoships is for the cases where the database comes from somewhere else, or where a relationship needs more than one column to match. The source, along with a Docker script that runs the full Laravel and PHP test matrix locally, is on GitHub.