---
title: "Compoships: Eloquent-зв'язки на основі кількох колонок"
url: https://laravelukraine.com/blog/compoships-eloquent-zviazki-na-osnovi-kilkox-kolonok
date: 2026-09-02
source: https://laravel-news.com/compoships?utm_medium=feed&utm_source=feedpress.me&utm_campaign=Feed%3A+laravelnews
---

# Compoships: Eloquent-зв'язки на основі кількох колонок

Деякі Laravel-застосунки працюють зі схемами баз даних, які ніхто з команди не проєктував, де зв'язок між двома таблицями здійснюється через пару колонок, а не один зовнішній ключ. Eloquent за замовчуванням порівнює лише одну колонку, а звичайний обхідний шлях - додавання `where()` до `hasMany()` - повертає неправильні рядки при eager loading. Laravel будує eager-loaded зв'язок з нового порожнього екземпляра моделі, тому батьківський атрибут, на який ви посилаєтесь у `where()`, є null. Compoships від Claudin J. Daniel дозволяє передавати масив колонок там, де Eloquent очікує назву ключа.

## Визначення зв'язку на основі двох колонок

Обидві моделі у зв'язку потребують трейту `Awobaz\Compoships\Compoships`, або можуть успадковувати `Awobaz\Compoships\Database\Eloquent\Model`, який є підкласом базової моделі Eloquent. Після цього методи зв'язків приймають масиви замість рядків.

Розглянемо таблицю замовлень, імпортовану з системи бухгалтерського обліку, де номер замовлення є унікальним лише в межах коду компанії. Для доступу до рядків замовлення потрібно порівнювати обидві колонки:

```php
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']
        );
    }
}
```

Зворотний зв'язок має таку саму структуру:

```php
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` та `belongsToMany` приймають масиви колонок. Nullable-колонки також підтримуються, хоча зв'язок, у якому всі ключові колонки є null, не повертає нічого.

## Зв'язкиMany-to-Many через проміжну таблицю

Метод `belongsToMany` приймає назву pivot-таблиці, а потім чотири масиви: колонки pivot, що вказують на кожну сторону, та локальні ключові колонки на кожній моделі. У прикладі склад і перевізник ідентифікуються кодом регіону плюс коротким кодом:

```php
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()` та `whereHas()` працюють з таким зв'язком. Там, де Laravel приймає список ідентифікаторів, Compoships приймає список кортежів з одним значенням для кожної колонки пов'язаного pivot-ключа:

```php
$warehouse->carriers()->attach([
    ['EU', 'DHL'],
    ['EU', 'UPS'],
]);
```

Для індивідуальних атрибутів pivot ключ масиву - це кортеж, перетворений через `json_encode()`, що замінює форму `[id => attributes]`:

```php
$warehouse->carriers()->attach([
    json_encode(['EU', 'DHL']) => ['priority' => 1],
    json_encode(['EU', 'UPS']) => ['priority' => 2],
], ['contract_year' => 2026]);
```

Асоціативний ключ, який не є JSON-кортежем правильної довжини, викликає виняток `Awobaz\Compoships\Exceptions\InvalidUsageException`. Користувацькі pivot-моделі підтримуються через `using()`, якщо pivot-клас успадковує `Awobaz\Compoships\Database\Eloquent\Relations\Pivot`.

## Складені первинні ключі на шляху запису

Для таблиці з ключем `(invoice_no, company_code)` збереження гідратованої моделі створює запит `UPDATE ... WHERE invoice_no = ?`. Той самий номер рахунку може існувати під іншим кодом компанії, тому такий запит може оновити неправильний рядок. Compoships обмежує шлях запису всіма ключовими колонками після оголошення `$compositeKey`, тоді як `$primaryKey` залишається скалярною назвою колонки:

```php
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()` (включно з м'яким видаленням), `refresh()` та `fresh()` тоді будують WHERE-умову з обох колонок:

```php
$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 = ?
```

Усе, що прив'язане до скалярної колонки, зберігає стандартну поведінку Eloquent: `Model::find($id)`, route model binding та хелпери на кшталт `firstOrCreate()` і `updateOrCreate()`, які будують свої власні умови з переданих параметрів.

Коли збережене значення ключової колонки є null, трейт пише `WHERE column IS NULL` замість прив'язки null до перевірки на рівність, тому `$compositeKey` також охоплює таблиці з унікальним індексом з nullable-дискримінатором. Якщо ви змінюєте ключову колонку в пам'яті перед викликом `save()`, WHERE-умова використовує оригінальне значення зі сховища, тоді як SET-умова записує нове.

## Моделі в чергах

Одна модель зі складеним ключем у властивості завдання переживає повний цикл через чергу. `SerializesModels` викликає `getQueueableId()`, який повертає JSON-закодовані ключові колонки, а worker декодує це в запит, обмежений усіма ними. Payload-и, поставлені в чергу до появи цієї функції, все ще відновлюються через власний шлях Laravel, тому можна оновлюватись, маючи завдання в черзі.

Колекції потребують обгортки. Метод `restoreCollection` Laravel переіндексує завантажені моделі за їхнім скалярним ключем і шукає їх за поставленими в чергу ідентифікаторами, які для цих моделей є JSON-рядками, тому колекція повертається порожньою. `QueueableCompositeCollection` захоплює кортежі ключів під час відправки і перезавантажує рядки одним запитом:

```php
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();
    }
}
```

Обгортка зберігає оригінальний порядок, eager-loaded зв'язки та з'єднання. Колекції зі змішаними класами викликають `LogicException`, а неправильний `$compositeKey` викликає `InvalidUsageException` - обидва у момент обгортання.

## Встановлення

Compoships вимагає PHP 8.2 та Laravel 12 або 13. Встановлення через Composer:

```bash
composer require awobaz/compoships
```

Пакет закриває два прогалини і залишає решту Eloquent без змін. Один скалярний первинний ключ залишається кращим за замовчуванням для схеми, яку ви контролюєте, а Compoships призначений для випадків, коли база даних походить з іншого джерела, або коли зв'язку потрібно більше однієї колонки для порівняння. Вихідний код разом із Docker-скриптом, який локально запускає повну матрицю тестів Laravel і PHP, доступний на GitHub.
