Деякі 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. Після цього методи зв'язків приймають масиви замість рядків.
Розглянемо таблицю замовлень, імпортовану з системи бухгалтерського обліку, де номер замовлення є унікальним лише в межах коду компанії. Для доступу до рядків замовлення потрібно порівнювати обидві колонки:
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']
);
}
}
Зворотний зв'язок має таку саму структуру:
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, що вказують на кожну сторону, та локальні ключові колонки на кожній моделі. У прикладі склад і перевізник ідентифікуються кодом регіону плюс коротким кодом:
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-ключа:
$warehouse->carriers()->attach([
['EU', 'DHL'],
['EU', 'UPS'],
]);
Для індивідуальних атрибутів pivot ключ масиву - це кортеж, перетворений через json_encode(), що замінює форму [id => attributes]:
$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 залишається скалярною назвою колонки:
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-умову з обох колонок:
$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 захоплює кортежі ключів під час відправки і перезавантажує рядки одним запитом:
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:
composer require awobaz/compoships
Пакет закриває два прогалини і залишає решту Eloquent без змін. Один скалярний первинний ключ залишається кращим за замовчуванням для схеми, яку ви контролюєте, а Compoships призначений для випадків, коли база даних походить з іншого джерела, або коли зв'язку потрібно більше однієї колонки для порівняння. Вихідний код разом із Docker-скриптом, який локально запускає повну матрицю тестів Laravel і PHP, доступний на GitHub.