<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
SYMFONY · JUNIOR ЧАСТО ПИТАЮТЬ

Як влаштовані сутності й репозиторії в Doctrine і чим це відрізняється від Active Record?

Doctrine це Data Mapper: сутність - звичайний PHP-клас без save() і find(), а стан у базу переносить EntityManager (persist реєструє новий об'єкт, flush виконує SQL), тоді як репозиторій відповідає лише за запити. В Active Record модель сама знає про з'єднання, тому домен і таблиця злиті в один клас.

Чому в Doctrine треба кликати flush(), а в Eloquent достатньо $post->save()?
Ми зробили persist(), а в базі рядка немає. Що пропустили?
Де має жити метод findApprovedForConference(): у сутності, репозиторії чи сервісі?
Якщо сутність нічого не знає про базу, хто тоді формує UPDATE і звідки він знає, що поле змінилось?
Doctrine Data Mapper EntityManager репозиторій Active Record

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 в одній транзакції
Що Doctrine ORM реалізує Data Mapper: у сутності немає save(), delete() чи find(), запис і читання виконують окремі об'єкти.
Що persist() лише бере новий об'єкт під управління, а SQL виконується під час flush(), і для вже завантаженої сутності persist() не потрібен.
Що репозиторій це клас запитів: findBy, DQL, QueryBuilder; він повертає сутності й не має відповідати за транзакції.
Що в межах одного EntityManager identity map гарантує один екземпляр на рядок, тому повторний find(42) не породжує другий SELECT.
Що Active Record (Eloquent, Yii) дешевший на старті й коротший у коді, а Data Mapper дає сутність, яку можна тестувати без бази й міняти незалежно від схеми.
Викликати persist() на кожній завантаженій сутності «щоб зберегти»: для керованого об'єкта це нічого не робить, зміну все одно запише flush().
Очікувати рядок у базі відразу після persist() і дивуватися, що id досі null.
Передавати сутність у flush() ($em->flush($talk)): цей аргумент прибрали в ORM 3.0.
Ховати flush() у методі репозиторію save(), через що один HTTP-запит робить п'ять транзакцій замість одної.
Тягнути EntityManager або репозиторій у конструктор сутності, щоб вона «сама себе зберігала»: виходить Active Record поверх Data Mapper, з рекурсивними flush і невідтворюваними тестами.
Робити фільтрацію в геттері сутності (foreach по колекції замість WHERE у запиті) і витягувати всю таблицю в пам'ять.
ПОРАДА

Скажіть одним рядком, хто за що відповідає: «сутність тримає стан і правила, репозиторій читає, EntityManager пише». Далі додайте, що persist реєструє, а flush виконує, і що для вже завантаженої сутності достатньо змінити властивість. Це рівно та відповідь, якої чекають від junior на Symfony.

оновлено 20 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

persist() тільки переводить новий об'єкт у стан managed. Запити формує flush(): Unit of Work порівнює поточні значення зі знімком, зробленим при завантаженні, і пише UPDATE для вже керованих сутностей без жодного persist().