Eloquent - это AR, все в одном флаконе - и данные, и средства работы с ними.
Doctrine - это DM, данные отдельно, средства работы с ними - отдельно.
Да, это простой и правильный ответ.
Но без дополнительных размышлений и умозаключений он столь же полезен, как тот ответ математика из анекдота - "вы находитесь на воздушном шаре".
Вон, помню, был подкаст, где Тейлор и Феррара спорили, что лучше, и все это звучало довольно анекдотично, типа "DM лучше, чем AR!" - "Чем лучше?" - "Чем AR!". И, действительно, есть взять две анемичные модели, реализованные на Eloquent (с магией) и Doctrine (с геттерами-сеттерами), получается, что разница исключительно в том, "в одном флаконе" или "отдельно", а по сути-то ничего не меняется. А все это потому, что основная проблема с ORM, которая сформулирована еще в 90-х, а именно
Object-relational impedance mismatch, вообще толком не обсуждалась!
Популярность анемичных моделей, полагаю, обусловлена именно проблемой impedance mismatch: дескать, если автоматически замапить объекты на реляции так сложно, пусть наши объекты будут вырожденными - один класс соответствует одной таблице, а его свойства соответствуют полям в таблицах - то есть, по сути, Persistence Models. Дальше в какой-то момент это уточнение "а какие именно у нас модели" затерялось, и "моделями" стали называть все подряд - вплоть до того, что "M" в "MVC" стало обозначать Persistence Models. Бизнес-логика сначала уехала в толстые контроллеры, потом (по мере осознавания глубины проблемы) в типичном Симфони появились сервисы... Но основная катастрофа так большинством осталась и незамеченной: все свалилось в процедурное программирование, когда вся бизнес-логика стала де-факто описываться процедурами, оперирующими структурами, представляющими собой копии строк в базе. В контроллерах это или в сервисах - не суть важно, сервисы - это просто реюзабельные процедуры (вынесли из контроллеров, чтобы не копипастить). А наличие формальных признаков ООП (ключевые слова class, стрелочки вот эти) создают у неокрепших умов впечатление, что так и надо ("все так делают!", "в документации так написано!").
Вернемся к impedance mismatch. Проблема там, конечно, не в том, что невозможно замапить объекты на реляции - нет, оба "языка" достаточно "полны", и это всегда возможно. Проблема в том, что в общем случае этот маппинг нетривиален, и нет таких правил, по которым в общем случае его можно построить автоматически: объектные зависимости и реляционные зависимости - это две большие разницы. Если начинать проектирование "от базы данных" (как оно часто принято в вебдеве по историческим причинам), и если начинать проектирование "от объектов" (как оно и должно быть в ООП), получатся довольно разные структуры, которые, тем не менее, изоморфны (то есть всегда можно написать функцию, однозначно преобразующую одно в другое и обратно).
Пытаясь создать универсальную функцию для такого взаимного преобразования, мы неизбежно столкнемся со случаями, когда эта абстракция "течет", и мы вынужденно нарушаем либо принципы проектирования ООП, либо принципы проектирования РСУБД (а то и оба два сразу!).
Давайте забудем на минутку про ORM и попробуем запрограммировать это дело вручную, как в старые добрые времена - ручным написанием SQL-запросов. Будем исходить из того, что мы хотим полноценного ООП, и наши модели - это полноценные Domain Models. Пусть нашей предметной областью будет... записная книжка. Ну вон типа как в телефоне. С точки зрения ООП у нас будет пачка классов типа PhoneNumber extends Contact, Address extends Contact, Email extends Contact, TelegramAccount extends Contact и так далее, и Collection<Contacts> у Person (полагаем, что у одного человека может быть сколько угодно контактов любого типа). А с точки зрения РСУБД тут должны быть отдельные таблицы для типов контактов и к каждой еще таблица-связка M:N, ну то есть ${foo}_contact : person. Вот, казалось бы, будет простая анемика и CRUD-ы, а уже все не так просто. Напоминаю - начали мы с ООП, то есть с представления Person в виде объектов в оперативной памяти. Положим, написали. Как теперь мы туда прикрутим персистенцию в базе?
Начнем с ручного ActiveRecord (напоминаю, ручками написанный SQL внутрях модели - это тоже ActiveRecord, Фаулер на странице 160 PoEAA подтверждает). Тут у нас два варианта. Можно написать SQL в каждом классе контактов, и писать в базу при каждом изменении. Но тут сразу вылезает куча изменений: во-первых, мы наверняка вообще не имеем personId в Contact и его наследниках, во-вторых, вместо банального удаления из коллекции придется теперь добавить дергать какой-то там $contact->delete(), то есть делать этакий костыль. Фу. Нет, давайте лучше сделаем единственный метод save прямо в Person - там у нас есть все, что надо, можно даже оптимизировать все это дело и выполнить минимум запросов в одной транзакции. Заодно решается проблема отката частичных изменений ввиду их отсутствия - никаких внешних транзакций не потребуется. А для загрузки из базы сделаем статический метод loadById, где наджойним все, что надо, и воссоздадим из этого нашу структуру объектов. Ну и delete() до кучи (тут с on delete cascade получится совсем просто).
Посмотрим внимательно на то, что получилось. Теперь прочитаем определение
Aggregate Root у Фаулера и еще раз посмотрим внимательно на то, что получилось. Получается, мы сделали DDD-шный Aggregate Root буквально из говна и палок, причем оставаясь в рамках Active Record?
Вроде бы да. Но откуда мы вообще взяли в методах save() и loadById() $db? Фаулер в своих примерах этот вопрос удобно игнорирует - "ну вот откуда-то взялся". Наверное, придется в конструктор и в статические методы его передавать в явном виде? И по цепочке везде таскать $db? Или, допустим, создавать все эти наши типа-aggregate-roots через абстрактную фабрику? Ммм, окей, а что делать со статикой? Создавать неконсистентный почти пустой (только с $db) объект рефлексией без конструктора и транслировать в нестатические вызовы, после чего самому создавать и возвращать из "неполноценного" инстанса себя другой "полноценный" (привет, Eloquent)?
Так или иначе, мы что-то такое сделали, оно неплохо работает, с точки зрения ООП все нормально (за исключением SRP и вопроса с $db), с точки зрения РСУБД все нормально.
Есть второй вариант: сделать отдельный класс PersonMapper и заюзать рефлексию. У мапперов в конструкторе $db, через абстрактную фабрику или DIC все пробрасывается легко, loadById($id): Person, save(Person $person) - обычные такие себе методы. Вроде теперь вообще все выглядит красиво (хотя внутри этих методов, конечно, будет адок).
Теперь посмотрим, что нам предлагают типичные "универсальные" ORM-ки, и ужаснемся:
1) Предлагают они в основном странное: либо испортить базу данных, сделав единственную табличку связей с полем type (до свидания, foreign keys), либо испортить ООП, раскидав общую коллекцию на пачку коллекций по типам: Collection<PhoneNumbers> $phoneNumbers, Collection<Email> $emails... А скорее всего и то, и другое. Вот он и impedance mismatch.
2) Плюс к тому, большинство ActiveRecord ORM-ок потребуют от нас ручками писать $phoneNumber->save(), $email->delete()... Одним методом $person->save() не обойдешься. (Нет, push() из Eloquent тут не поможет.)
Что же теперь, писать вот такие портянки SQL-я в save() и load() - как в нашем мысленном эксперименте - ручками на каждый Aggregate Root, присоединившись к лагерю противников ORM? Да в принципе-то нет. В условных 90% случаев стандартные правила из универсальных ORM прекрасно сработают. А для 10% надо лишь дать механизм, позволяющий определить собственные схемы маппинга там, где стандартных правил недостаточно.
Получается, что качество ORM в смысле решения проблемы impedance mismatch определяется двумя факторами:
1) поддержка концепции Aggregate Root,
2) возможность (и удобство) создания кастомных мапперов.
В CycleORM с этим почти все хорошо, с доктриной надо местами побороться и покостылить, а с типичными реализациями Active Record-ов это вообще не решаемо. Но это не значит, что Active Record принципиально не годится. Концептуально Active Record или Data Mapper - вообще не так уж и важно (единственное отличие - это проблема передачи $db в инстанс ActiveRecord Entity, ну и нарушение принципа SRP). Дело тут в том, что существующие реализации Active Record а-ля Rails по сути сводят все к Table Gateway со связями, даже не пытаясь дать решение проблемы impedance mismatch.