В одном проекте заказ описывается классом, который не подозревает о существовании базы данных, а в другом тот же заказ - это активная запись, умеющая себя сохранить, удалить и найти своих соседей по таблице. 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 под конкретный проект

Список вопросов, закрывающих спор в конкретной команде:

  1. задача сводится к табличному вводу и выводу без сложных инвариантов;
  2. домен содержит правила согласованности между несколькими сущностями;
  3. большие выборки читаются ради отчётов, а не ради изменения объектов;
  4. команда уже живёт в Symfony или в Laravel, и переучивание дорого;
  5. проект долгоживущий, с плановыми сменами мажорных версий ORM;
  6. тесты домена должны работать без базы данных;
  7. в кодовой базе много унаследованных моделей, которые не будут переписаны.

Первый и четвёртый пункты толкают к Eloquent, второй, третий и шестой - к Doctrine, остальное решается привычкой команды и составом легаси.

Производительность редко решает спор: на реальных проектах она упирается в SQL, индексы и объёмы данных. Массовая вставка через create() в цикле проигрывает одному составному INSERT с сотнями строк, и порционный flush в Doctrine с тысячами строк тоже не собирается в единственный запрос. Для тяжёлых загрузок оба мира спускаются к слою DB или DBAL и делают то, что видит база: минимальное число выражений с максимальным числом строк в каждом.

ORM выбирают один раз, а расхлёбывают годами. Eloquent экономит время на первых спринтах и выставляет счёт за него в сложных доменах, Doctrine берёт сложность авансом и возвращает её управляемостью. Тяжёлые проблемы обоих миров лечатся одинаково: запросы профилируют, связи загружают явно, большие выборки стримят, транзакции держат короткими. Различие паттернов - это различие того, где живёт сложность: в модели строки или в отдельном слое домена. Первое быстрее на старте, второе предсказуемее на дистанции, и проект чаще страдает не от выбора, а от нежелания этот выбор осознать.