Делегированные типы в Laravel
При разработке приложений мы регулярно сталкиваемся с необходимостью реализовать наследование на уровне базы данных.
Например, у нас есть базовая модель Payment, от которой наследуются CreditCardPayment и PayPalPayment. В мире объектно-ориентированного программирования это решается элементарно, но в реляционных базах данных всё не так тривиально.
Существует два классических подхода к этой проблеме, и у обоих есть существенные недостатки.
Наследование одной таблицей (STI)
При подходе Single Table Inheritance мы создаем одну огромную таблицу, которая содержит все возможные поля для всех типов сущностей. Добавляется колонка type, а специфичные для дочерних моделей поля просто остаются пустыми (NULL), если они не относятся к текущему объекту.
// Миграция для STI
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->string('type'); // 'credit_card', 'paypal'
// Общие поля
$table->decimal('amount');
// Специфичные для кредитной карты
$table->string('card_number')->nullable();
$table->string('cvv')->nullable();
// Специфичные для PayPal
$table->string('paypal_email')->nullable();
});
На практике это быстро превращается в кошмар. Таблица разрастается, нарушается целостность данных (нельзя на уровне БД заставить заполнить card_number только для типа credit_card), а запросы становятся неоправданно тяжелыми.
Наследование таблицами классов (CTI)
При подходе Class Table Inheritance мы создаем отдельную таблицу для каждого класса. Базовая таблица хранит общие поля, а дочерние — специфичные. Связь осуществляется через внешний ключ.
// Базовая таблица
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->string('type');
$table->decimal('amount');
});
// Дочерняя таблица
Schema::create('credit_card_payments', function (Blueprint $table) {
$table->foreignId('payment_id')->constrained()->primary();
$table->string('card_number');
$table->string('cvv');
});
Здесь нет лишних NULL-полей, и целостность данных в порядке. Но появляется новая проблема: каждый раз, когда вам нужно получить данные платежа вместе с его специфичными полями, вам придется делать JOIN. При росте количества типов сущностей количество JOIN-ов будет расти пропорционально, что убьет производительность.
Делегированные типы (Delegated Types)
Делегированные типы предлагают элегантный компромисс. Идея проста: дочерняя таблица использует тот же первичный ключ, что и базовая. По сути, это связь «один к одному», где дочерняя таблица делегирует себе часть данных, разделяя с родительской один и тот же ID.
STI делает таблицы широкими. CTI делает запросы глубокими (за счет JOIN-ов). Делегированные типы берут лучшее из обоих миров.
// Базовая таблица
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->decimal('amount');
});
// Дочерняя таблица с общим ID
Schema::create('credit_card_payments', function (Blueprint $table) {
$table->id(); // Тот же самый ID, что и в payments
$table->string('card_number');
$table->string('cvv');
});
Обратите внимание: мы не используем foreignId. Строка в дочерней таблице имеет свой собственный id, но на практике это то же самое значение, что и у родителя. Это значит, что мы можем получить специфичные данные без JOIN-ов, просто зная ID базовой записи.
Как это работает в Laravel
В чистом Eloquent реализация этого паттерна требует небольшой настройки связей. Однако в экосистеме Laravel есть готовое решение — пакет spatie/laravel-delegated-types от команды Spatie.
С его помощью настройка моделей сводится к минимуму. Базовая модель использует трейт, указывающий, какие дочерние типы она может делегировать:
use Spatie\DelegatedTypes\Models\HasDelegatedTypes;
class Payment extends Model
{
use HasDelegatedTypes;
protected static array $delegatedTypes = [
'credit_card' => CreditCardPayment::class,
'paypal' => PayPalPayment::class,
];
}
Дочерняя модель указывает, от какого базового типа она наследуется:
use Spatie\DelegatedTypes\Models\DelegatedType;
class CreditCardPayment extends DelegatedType
{
protected static string $baseType = Payment::class;
protected $fillable = ['card_number', 'cvv'];
}
Использование на практике
Теперь создание и работа с объектами выглядят максимально прозрачно:
// Создание записи
$payment = CreditCardPayment::create([
'amount' => 1000,
'card_number' => '4242...',
'cvv' => '123',
]);
// Под капотом создаются две записи:
// 1. Payment с id=1, amount=1000
// 2. CreditCardPayment с id=1, card_number='4242...', cvv='123'
// Получение специфичных данных без JOIN-ов
$specificData = CreditCardPayment::find(1);
Под капотом
Когда вы обращаетесь к делегированному типу, Laravel делает запрос только к дочерней таблице, так как знает, что искомый ID там совпадает с ID базовой записи. Если вам нужны общие поля из родительской модели, они подгружаются отдельным легким запросом (или через relationship) только по необходимости.
Итог
Делегированные типы — это отличный инструмент, который стоит держать в арсенале. Они избавляют от раздутых таблиц с кучей NULL-полей при STI и от тяжелых JOIN-запросов при CTI. Используйте этот паттерн, когда у вас есть четкая иерархия сущностей с уникальными полями на каждом уровне, и вы хотите сохранить базу данных чистой и быстрой.

