Doctrine ORM (3.x, PHP 8.1+; у Symfony підключається через doctrine/doctrine-bundle) побудований на патерні Data Mapper, і звідси ростуть усі інші правила. Сутність - клас із приватними властивостями, конструктором і методами предметної області. Мапінг задається атрибутами #[ORM\Entity], #[ORM\Column], #[ORM\ManyToOne] (в ORM 3.0 підтримку annotations прибрали, лишились атрибути й XML), але сам клас не має жодного посилання на з'єднання. Зберегти чи знайти себе він не вміє, зате його можна створити оператором new просто в тесті, не піднімаючи жодного сервісу. Перекладом об'єкта в рядки таблиці й назад займається окремий шар: EntityManagerInterface для запису, репозиторій для читання, метадані мапінгу як словник між полями й колонками.
В Active Record межа проходить інакше. Модель Eloquent успадковує Illuminate\Database\Eloquent\Model, а модель Yii - yii\db\ActiveRecord, тому вона водночас і рядок таблиці, і точка доступу до з'єднання: Post::find(1), $post->save(), Post::where(...)->get() живуть у тому самому класі, що й бізнес-методи. Поки модель невелика, це коротко й зрозуміло, і саме тому Active Record виграє в прототипах та CRUD-адмінках. Проблеми починаються далі: клас одночасно описує схему й поведінку, статичні виклики зашиваються всередину логіки, тест «просто перевірити правило» вимагає бази чи важкого мока, а перейменування колонки зачіпає домен. Data Mapper платить за незалежність шарів більшою кількістю класів і необхідністю тримати в голові життєвий цикл об'єкта.
Цей життєвий цикл - друге, що питають. Новий об'єкт спершу new (стан new: Doctrine про нього не знає), потім $em->persist($talk) переводить його в managed, тобто бере під управління, але жодного INSERT ще немає. SQL з'являється на $em->flush(): Doctrine порівнює поточні значення керованих об'єктів зі знімками, зробленими під час завантаження, будує INSERT/UPDATE/DELETE, упорядковує їх за залежностями й виконує в одній транзакції. Звідси практичне правило: сутності, яку ви щойно дістали з репозиторію, persist() не потрібен взагалі, достатньо викликати її метод і зробити один flush() у кінці сценарію. Аргумент flush($entity), який у 2.7 був deprecated, в ORM 3.0 видалено, як і merge() з copy(). Дістати $em->find(Talk::class, 42) двічі теж не означає два SELECT: identity map тримає один екземпляр на рядок у межах EntityManager, і другий виклик повертає той самий об'єкт із пам'яті.
Репозиторій у цій схемі виконує рівно одну роль: відповідає на питання «дай мені сутності, які...». Клас вказують у самій сутності через #[ORM\Entity(repositoryClass: TalkRepository::class)], а сам репозиторій успадковує ServiceEntityRepository з DoctrineBundle, щоб працювало autowiring по типу. Базовий EntityRepository уже дає find(), findBy(), findOneBy(), findAll(), count() і matching() для Criteria; писати обгортку findOneByEmail() над findOneBy(['email' => $email]) сенсу немає. Власні методи додають там, де потрібен DQL або createQueryBuilder(): join-и, діапазони дат, агрегації, часткові проєкції. Чого в репозиторії краще не робити, так це flush(). Метод save(Talk $talk, bool $flush = true) виглядає зручно й тому часто трапляється в згенерованому коді, але він розмиває транзакційну межу: замість однієї транзакції на сценарій виходить по одній на кожен збережений об'єкт, а частково виконаний сценарій уже не відкотити.
Межі підходу краще назвати самому: junior-відповідь без них звучить завчено. Data Mapper вимагає уваги до кількості завантажених об'єктів. Масове оновлення десятків тисяч рядків через сутності буде повільним навіть при правильному коді, тут доречні пакетна обробка або DQL-запит UPDATE. Ліниве завантаження асоціацій дає приховані запити при обході графа, і з нього виростає N+1. Кеш першого рівня (identity map) живе лише до кінця запиту, тому в довгих воркерах Messenger його треба свідомо чистити. Плутають ще одне: незалежність сутності від бази не означає, що схему можна не проєктувати. Мапінг - це і є опис схеми, а зміни до неї їдуть у продакшн міграціями, а не через doctrine:schema:update.
// Сутність: звичайний PHP-клас. Жодного save(), жодного EntityManager всередині
#[ORM\Entity(repositoryClass: TalkRepository::class)]
class Talk
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null; // null до flush(): значення дає AUTO_INCREMENT
#[ORM\Column]
private bool $approved = false;
public function __construct(
#[ORM\Column(length: 200)] private string $title,
#[ORM\ManyToOne] private Conference $conference,
) {}
public function approve(): void
{
$this->approved = true; // правило живе тут, SQL - ні
}
}
/** @extends ServiceEntityRepository<Talk> */
class TalkRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Talk::class);
}
/** @return Talk[] */
public function findApprovedForConference(Conference $conference): array
{
return $this->createQueryBuilder('t') // репозиторій = запити
->andWhere('t.approved = true')
->andWhere('t.conference = :conf')
->setParameter('conf', $conference) // параметр, не конкатенація
->orderBy('t.title', 'ASC')
->getQuery()
->getResult();
}
}
// Сервіс: persist для нового об'єкта, для завантаженого достатньо змінити стан
$talk = new Talk('Doctrine без магії', $conference);
$em->persist($talk); // взяти під управління, SQL ще не пішов
$em->find(Talk::class, 42)?->approve();
$em->flush(); // один flush: INSERT + UPDATE в одній транзакції
Скажіть одним рядком, хто за що відповідає: «сутність тримає стан і правила, репозиторій читає, EntityManager пише». Далі додайте, що persist реєструє, а flush виконує, і що для вже завантаженої сутності достатньо змінити властивість. Це рівно та відповідь, якої чекають від junior на Symfony.