The Problem: Eloquent Matches Only One Foreign Key
When your database schema links two tables through a pair of columns instead of a single foreign key, stock Eloquent falls short. The common workaround — chaining a where() onto hasMany() — silently returns wrong results under eager loading because Laravel builds the relationship from a fresh empty model instance, so the parent attribute you reference is null.
Compoships, by Claudin J. Daniel, solves this by letting you pass an array of column names wherever Eloquent normally expects a single key string.
Installation
Compoships requires PHP 8.2 and Laravel 12 or 13:
composer require awobaz/compoships
Defining a Multi-Column Relationship
Add the Awobaz\Compoships\Compoships trait to both models (or extend Awobaz\Compoships\Database\Eloquent\Model). Then pass arrays to the relationship methods:
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 belongsTo uses the same array shape. hasOne, hasMany, belongsTo, and belongsToMany all accept column arrays.
Many-to-Many With a Pivot Table
belongsToMany accepts four arrays: pivot columns for each side, plus the local key columns on each model:
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(), and whereHas() all work. Where Laravel normally takes a list of IDs, Compoships takes a list of tuples:
$warehouse->carriers()->attach([
['EU', 'DHL'],
['EU', 'UPS'],
]);
To pass per-row pivot attributes, use json_encode() on the tuple as the array key:
$warehouse->carriers()->attach([
json_encode(['EU', 'DHL']) => ['priority' => 1],
json_encode(['EU', 'UPS']) => ['priority' => 2],
]);
Composite Primary Keys on the Write Path
Declare $compositeKey to scope save(), update(), delete(), refresh(), and fresh() by every key column, preventing updates from hitting the wrong row:
class Invoice extends Model
{
use Compoships;
protected $primaryKey = 'invoice_no';
public $incrementing = false;
protected $keyType = 'string';
protected $compositeKey = ['invoice_no', 'company_code'];
}
When a key column is null, the trait writes WHERE column IS NULL rather than binding null into an equality check. If you change a key column before calling save(), the WHERE clause uses the original stored value while the SET clause writes the new one.
Queue Support
A single composite-keyed model serializes safely via SerializesModels — getQueueableId() returns the JSON-encoded key tuple and the worker decodes it back into a scoped query.
For collections, use QueueableCompositeCollection to avoid the empty-collection bug that occurs when Laravel re-keys models by their scalar key:
use Awobaz\Compoships\Queue\QueueableCompositeCollection;
public function __construct(Collection $invoices)
{
$this->invoices = QueueableCompositeCollection::for($invoices);
}
public function handle(): void
{
$invoices = $this->invoices->restore();
}
The wrapper preserves original order, eager-loaded relations, and the connection. Mixed-class collections raise a LogicException at wrap time.
Key Takeaways
- Compoships extends
hasOne,hasMany,belongsTo, andbelongsToManyto accept column arrays, fixing eager-loading bugs caused by null parent attributes. $compositeKeyscopes all write operations (save, update, delete) by every declared key column.belongsToManypivot operations (attach,sync, etc.) accept tuples instead of scalar IDs.QueueableCompositeCollectionis required when dispatching collections of composite-keyed models to a queue.- A single scalar primary key remains the better default for schemas you control; Compoships targets legacy or external schemas.
- Requires PHP 8.2 and Laravel 12 or 13.
Source: Compoships: Eloquent Relationships on Multiple Columns — Laravel News