В одном проекте заказ описывается классом, который не подозревает о существовании базы данных, а в другом тот же заказ - это активная запись, умеющая себя сохранить, удалить и найти своих соседей по таблице. Doctrine и Eloquent решают одну задачу противоположными способами, и спор о том, какой ORM лучше, без контекста лишён смысла: выбор паттерна - это выбор архитектуры на годы вперёд. Разберём, как Data Mapper и Active Record расходятся в деталях, чем это грозит производительности и где проходит граница разумного применения каждого подхода.
Разделение ответственности между классом и базой у Data Mapper и Active Record
Eloquent реализует Active Record: модель одновременно описывает таблицу и хранит одну строку. Статический вызов ищет записи, метод save() пишет изменения, delete() удаляет строку, а связи декларируются методами прямо в классе.
class Order extends Model
{
protected $fillable = ['user_id', 'total', 'status'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
$order = Order::create(['user_id' => 7, 'total' => 1990]);
$order->status = 'paid';
$order->save();
Doctrine работает по схеме Data Mapper: сущность - обычный класс с приватными полями и поведением домена, а перемещение данных между объектами и таблицами выполняет отдельный слой, Entity Manager.
#[ORM\Entity]
class Order
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private int $id;
private function __construct(
#[ORM\ManyToOne(targetEntity: User::class)]
private User $user,
#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
private string $total,
) {}
public function markPaid(): void
{
// изменение состояния домена, без слова "база"
}
}
Разница видна сразу: Doctrine-сущность можно распечатать и повесить на стену рядом с диаграммой домена, а Eloquent-модель всё равно несёт знание о таблице, подключении и способе сохранения. Первая пишется для бизнес-аналитика, вторая для таблицы.
Оба паттерна старше самих библиотек: Мартин Фаулер описал их в книге о шаблонах корпоративных приложений ещё в 2002 году, и с тех пор ничего принципиально нового не случилось. Eloquent принёс Active Record в PHP в удобной форме, Doctrine довёл Data Mapper до индустриального уровня. Спор между ними - это на самом деле спор о том, сколько логики готова хранить строка таблицы.
Механика flush в Doctrine от Unit of Work до карты идентичности
Главный механизм Doctrine - Unit of Work. persist() не пишет в базу, а лишь запоминает сущность; настоящий SQL рождается в flush(), где менеджер сравнивает исходное состояние объектов с текущим и строит минимально необходимые выражения. Если из двадцати полей заказа изменилось одно, UPDATE коснётся одной колонки.
$order = $em->find(Order::class, 42);
$order->markPaid();
$em->flush(); // UPDATE только статуса
Параллельно работает карта идентичности: повторный find() по тому же ключу вернёт тот же самый объект, без второго SELECT и без рассинхронизации. В пределах одного запроса граф объектов согласован - сильная сторона, которую снаружи не видно.
Цена известна: менеджер держит оригиналы данных в памяти, и при импорте десятков тысяч строк процесс упирается в лимиты. Штатный выход - порционный flush каждые пару сотен записей и clear() между порциями, а для чтения больших объёмов - toIterable(), который отдаёт строки потоком, не удерживая их в памяти.
$query = $em->createQuery(
'SELECT o FROM App\\Entity\\Order o WHERE o.status = :status'
);
foreach ($query->toIterable() as $order) {
// строки приходят по одной, память не растёт
}
Для ленивых связей Doctrine генерирует классы-заглушки: связь подтянется отдельным запросом при первом обращении к ней. Заглушки собираются командой генерации при деплое, а не на лету, и в продакшене их отсутствие ловится мгновенно. У связей есть стороны: владелец отвечает за колонку внешнего ключа, обратная сторона только читается. Путаница сторон - классическая ошибка новичков, из-за которой поля не сохраняются, хотя код выглядит правильным.
Отдельный слой занимают коллекции связей. COUNT при первом обращении считается на стороне базы, contains() проверяет через карту идентичности без выборки. У Eloquent коллекции связей - обёртки над массивом, и count() по уже загруженной связи повторно в базу не ходит.
Eloquent и скорость написания кода против накопления логики в моделях
Скорость Eloquent на табличных задачах феноменальна: связи, методы фильтрации по соглашению, касты атрибутов, мягкое удаление и события модели включаются за минуты, и панель администратора из десяти таблиц собирается за день.
Проблема приходит с ростом домена. Самое удобное место для логики - сама модель, и через три года класс Order содержит правила скидок, расчёт доставки, экспорт, уведомления и интеграции. Проверка инвариантов размазывается по мутаторам, обсерверам и событиям, а модульные тесты без базы данных превращаются в фантазию.
class Order extends Model
{
protected static function booted(): void
{
static::creating(function (Order $order) {
$order->number = Str::ulid();
});
}
}
Дисциплинированные команды борются с этим сервисным слоем: Eloquent остаётся транспортным механизмом, а бизнес-правила живут в отдельных классах домена. Работает, но требует конвенции, которую фреймворк не навязывает, - и это главный риск подхода на длинной дистанции.
Современные версии Eloquent поддерживают акцессоры в виде класса Attribute с явными функциями чтения и записи, касты к датам, перечислениям и объектам-значениям, а трейт мягкого удаления добавляет колонку deleted_at и глобальное условие к каждому запросу. Инструменты заметно выросли, но расположение логики они не меняют: правила всё равно живут в самой модели или вплотную к ней.
Запросы DQL и Query Builder против цепочек where в Eloquent
Doctrine говорит на собственном диалекте DQL: запрос описывается через сущности и их связи, а не через таблицы, и опечатка в имени свойства ловится при первом запуске.
$rows = $em->createQueryBuilder()
->select('o, u')
->from(Order::class, 'o')
->join('o.user', 'u')
->where('o.status = :status')
->setParameter('status', 'paid')
->getQuery()
->getResult();
Eloquent отвечает цепочками конструктора запросов: коротко, гибко и читаемо до определённой длины.
$orders = Order::query()
->select('id', 'number', 'total', 'user_id')
->where('status', 'paid')
->with('user:id,name')
->orderBy('paid_at')
->cursorPaginate(100);
У обоих есть режимы экономии: у Doctrine - гидратация в массивы или скаляры вместо объектов, у Eloquent - явный select() колонок и cursor() со стримингом строк генератором. Общая беда одинакова: ленивые обращения к связям в цикле порождают лавину одиночных запросов, и в Doctrine это лечится жадным JOIN прямо в DQL, а в Eloquent - явным with() и счётчиками агрегатов на стороне базы.
Глобальные фильтры есть у обоих: у Doctrine - включаемые фильтры уровня менеджера, у Eloquent - глобальные скоупы, навешивающие условие на каждый запрос модели, и мягкое удаление строится именно на них. Побег в сырой SQL тоже симметричен: Eloquent открывает DB::raw и методы фасада DB, Doctrine - прямой доступ к слою DBAL, и в обоих случаях билдер запроса остаётся под контролем разработчика.
Транзакции и слушатели событий в двух подходах
Eloquent оборачивает транзакцию с числом попыток и повторяет её при взаимоблокировке - приятная мелочь, экономящая шаблонный код при конкурентных записях.
DB::transaction(function () use ($data, $items) {
$order = Order::create($data);
$order->items()->createMany($items);
}, attempts: 3);
Doctrine предлагает wrapInTransaction и события жизненного цикла на самой сущности: prePersist, postUpdate и их соседи закрывают стандартные задачи вроде проставления дат.
#[ORM\HasLifecycleCallbacks]
class Order
{
#[ORM\PreUpdate]
public function touchUpdatedAt(): void
{
$this->updatedAt = new DateTimeImmutable();
}
}
Разница в размещении поведения: у Eloquent события подписываются в моделях и обсерверах, у Doctrine слушатели живут отдельно и получают событие с экземпляром и аргументами. Для больших команд второй вариант удобнее: правила аудита и накопления собираются в одном списке, а не размазаны по классам.
Вложенные транзакции поддерживают оба инструмента: и Laravel, и Doctrine эмулируют их через точки сохранения, при откате внутренней части внешняя продолжает жить. Уровни изоляции задаются на стороне подключения, потому что обе библиотеки честно работают в том режиме, который выбран у базы. Долгие транзакции одинаково вредны обоим: удержание большой единицы работы открытой - плата срезами данных, блокировками и ростом журнала.
Влияние на архитектуру приложения там где домен важнее таблиц
На уровне архитектуры различие фундаментально. Doctrine-сущность не зависит от ORM и от базы: класс домена тестируется без подключения к СУБД, инварианты защищены приватными полями, а конструктор не даёт создать объект в невозможном состоянии. В этом и есть смысл Data Mapper: чуть больше церемоний при создании, зато домен остаётся чистым.
Eloquent-модель честно признаёт, что она - обёртка над строкой таблицы, и на этом отлично работает, пока домен тонкий. Как только появляются сложные правила согласованности между сущностями, Active Record проседает: модель обязана знать слишком много, а подмена модели в юнит-тестах становится отдельным искусством.
Практичный вывод для Laravel-проектов звучит скромнее любых идеологических споров: Eloquent хорош как слой чтения и записи для табличных задач, а домен растёт в сервисах и классах событий. В Symfony-проектах Doctrine берёт ту же черновую работу на себя, оставляя домену быть доменом. Выбор паттерна - это выбор того, что будет дешевле менять через пять лет: запросы или правила.
Хрестоматийный слой поверх ORM - репозитории. Doctrine поставляет их из коробки: у каждой сущности есть класс репозитория с методами по умолчанию, который расширяется под конкретные запросы команды. Вокруг Eloquent обёртки-репозитории пишут вручную, и польза от них зависит от дисциплины: тонкие обёртки превращаются в формальность, толстые дублируют конструктор запросов. Репозиторий осмыслен тогда, когда за ним спрятан домен, а не таблица.
Правила выбора между Doctrine и Eloquent под конкретный проект
Список вопросов, закрывающих спор в конкретной команде:
- задача сводится к табличному вводу и выводу без сложных инвариантов;
- домен содержит правила согласованности между несколькими сущностями;
- большие выборки читаются ради отчётов, а не ради изменения объектов;
- команда уже живёт в Symfony или в Laravel, и переучивание дорого;
- проект долгоживущий, с плановыми сменами мажорных версий ORM;
- тесты домена должны работать без базы данных;
- в кодовой базе много унаследованных моделей, которые не будут переписаны.
Первый и четвёртый пункты толкают к Eloquent, второй, третий и шестой - к Doctrine, остальное решается привычкой команды и составом легаси.
Производительность редко решает спор: на реальных проектах она упирается в SQL, индексы и объёмы данных. Массовая вставка через create() в цикле проигрывает одному составному INSERT с сотнями строк, и порционный flush в Doctrine с тысячами строк тоже не собирается в единственный запрос. Для тяжёлых загрузок оба мира спускаются к слою DB или DBAL и делают то, что видит база: минимальное число выражений с максимальным числом строк в каждом.
ORM выбирают один раз, а расхлёбывают годами. Eloquent экономит время на первых спринтах и выставляет счёт за него в сложных доменах, Doctrine берёт сложность авансом и возвращает её управляемостью. Тяжёлые проблемы обоих миров лечатся одинаково: запросы профилируют, связи загружают явно, большие выборки стримят, транзакции держат короткими. Различие паттернов - это различие того, где живёт сложность: в модели строки или в отдельном слое домена. Первое быстрее на старте, второе предсказуемее на дистанции, и проект чаще страдает не от выбора, а от нежелания этот выбор осознать.