Compoships: Eloquent Relationships on Multiple Columns in Laravel
Laravel Composer Pacakge #eloquent #laravel #composer-package #database #relationships

Compoships: Eloquent Relationships on Multiple Columns in Laravel

3 min read Mohamed Said Mohamed Said

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 SerializesModelsgetQueueableId() 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, and belongsToMany to accept column arrays, fixing eager-loading bugs caused by null parent attributes.
  • $compositeKey scopes all write operations (save, update, delete) by every declared key column.
  • belongsToMany pivot operations (attach, sync, etc.) accept tuples instead of scalar IDs.
  • QueueableCompositeCollection is 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

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why does chaining where() onto hasMany() break eager loading in Laravel?
During eager loading, Laravel builds the relationship query from a new empty model instance, so any parent attribute you reference in the where() clause is null. This means the constraint is never applied correctly and the wrong rows are returned. Compoships fixes this by building the multi-column constraint directly into the relationship definition.
Q02 How do you attach pivot records when using Compoships belongsToMany?
Instead of passing a list of scalar IDs, you pass a list of tuples — one value per pivot key column. For example: $warehouse->carriers()->attach([['EU', 'DHL'], ['EU', 'UPS']]). To include per-row pivot attributes, use json_encode() on the tuple as the array key.
Q03 Does Compoships work with Laravel's queue system for composite-keyed models?
Yes. A single composite-keyed model serializes safely via SerializesModels using a JSON-encoded key tuple. For collections, you must use QueueableCompositeCollection::for($collection) at dispatch time and call ->restore() in the job handler, otherwise Laravel's default collection restoration returns an empty result.

Continue reading

More Articles

View all