<? phpukraine СПІВБЕСІДИ
Пошук по платформі

Symfony для Middle: питання на співбесіду

5 питань рівня Middle з теми Symfony з розгорнутими відповідями, порадами та перевіркою.

Тема
Рівень
5 питань
SF
Symfony·Middle ·Security ·Voter ·IsGranted

Будь-яка перевірка проходить через `isGranted($attribute, $subject)`: AccessDecisionManager опитує всі voter'и й зводить їхні голоси стратегією (за замовчуванням affirmative). Ролі перевіряє RoleHierarchyVoter, який розгортає `role_hierarchy`, а права на конкретний обʼєкт — власний Voter, підключений атрибутом `#[IsGranted('EDIT', subject: 'post')]`.

Чому `#[IsGranted('ROLE_ADMIN')]` пропускає користувача, у якого в базі записано лише `ROLE_SUPER_ADMIN`, а перевірка `in_array('ROLE_ADMIN', $token->getRoleNames())` у власному voter'і — ні?
Де перевіряти право «редагувати цей пост»: у контролері, в entity чи у voter'і?
Що відбувається, коли всі voter'и повернули ABSTAIN?
Чим `access_control` у security.yaml відрізняється від `#[IsGranted]` на контролері?

Уся авторизація в Symfony зводиться до одного виклику: isGranted($attribute, $subject). Ні контролер, ні Twig не знають, що таке «роль» чи «власник поста» — вони лише передають рядок-атрибут і необовʼязковий обʼєкт у AccessDecisionManager, а той опитує кожен сервіс, тегований security.voter (autoconfigure навішує тег автоматично за VoterInterface). Кожен voter повертає GRANTED, DENIED або ABSTAIN, і менеджер зводить голоси стратегією: за замовчуванням affirmative — достатньо одного GRANTED. Якщо всі утрималися, працює allow_if_all_abstain: false, тобто доступу немає. Через це друкарка в назві атрибута ніколи не відкриває доступ, але й не кидає помилки — саме тому атрибути тримають константами voter'а.

Ролі — не окремий механізм, а такі самі атрибути. Рядок, що починається з ROLE_, підбирає RoleHierarchyVoter: він бере ролі токена, розгортає їх через RoleHierarchy::getReachableRoleNames() за конфігом role_hierarchy і порівнює з потрібною. Ключова деталь, на якій валяться: розгортання живе всередині цього voter'а, а не в токені. $token->getRoleNames() і $user->getRoles() повертають сирі ролі, тому перевірка in_array('ROLE_ADMIN', $token->getRoleNames(), true) у власному voter'і не побачить ROLE_SUPER_ADMIN, який успадковує ROLE_ADMIN. Правильно — інʼєктувати Symfony\Bundle\SecurityBundle\Security і викликати isGranted('ROLE_ADMIN'). Поруч із ролями є службові атрибути: IS_AUTHENTICATED_FULLY, IS_AUTHENTICATED_REMEMBERED, IS_IMPERSONATOR, PUBLIC_ACCESS (замість прибраного в Symfony 6 IS_AUTHENTICATED_ANONYMOUSLY).

Точок входу в перевірку дві, і вони працюють на різних етапах запиту. access_control у security.yaml — це AccessListener на firewall: він бачить лише URL, IP, метод і хост, спрацьовує до контролера і добре закриває цілі розділи (/admin). Атрибут #[IsGranted] (у ядрі з Symfony 6.2) обробляється слухачем на події kernel.controller_arguments, тобто вже після того, як ParamConverter або MapEntity завантажили Post — тому subject: 'post' посилається на аргумент контролера й дає перевірку на конкретному обʼєкті. Той самий voter викликається з Twig через is_granted('POST_EDIT', post), коли треба сховати кнопку, і з сервісу через denyAccessUnlessGranted(). Це і є головна цінність voter'а: правило описане один раз, а не тричі.

Продуктивність упирається в те, що voter — звичайний сервіс, який викликається на кожен isGranted() і нічого не memoize. Абстрактний Voter у Symfony 6/7 реалізує CacheableVoterInterface, і його supportsAttribute()/supportsType() дозволяють менеджеру раз і назавжди зрозуміти, що цей voter не цікавиться атрибутом ROLE_USER чи типом Comment, і більше його не смикати. Але всередині свого voter'а за кеш відповідаєте ви: якщо voteOnAttribute() ходить у базу по правах, то список із 200 постів дасть 200 запитів. Лікується це або завантаженням прав одним запитом у приватну властивість voter'а, або перевіркою на рівні запиту (фільтрувати вибірку по власнику), а не поштучним isGranted() у шаблоні.

Межі підходу варто назвати самому. #[IsGranted] захищає дію контролера, а не сутність: виклик того самого сервісу з консольної команди чи з Messenger-хендлера пройде повз перевірку, тому критичні правила дублюють у домені. Voter'и погано підходять для «показати лише свої записи» — це завдання запиту, а не перевірки постфактум. І якщо прав стає багато й вони налаштовуються адміністратором у рантаймі, конфіг role_hierarchy перестає бути відповіддю: тоді ролі перетворюють на permissions у базі, а voter стає тонким шаром, який їх читає, лишаючи isGranted() єдиним публічним API авторизації.

// src/Security/Voter/PostVoter.php
final class PostVoter extends Voter   // абстрактний Voter реалізує CacheableVoterInterface
{
    public const EDIT = 'POST_EDIT';
    public const PUBLISH = 'POST_PUBLISH';

    public function __construct(private Security $security) {}

    protected function supports(string $attribute, mixed $subject): bool
    {
        // false тут = ABSTAIN, а не DENY: інші voter'и голосують далі
        return in_array($attribute, [self::EDIT, self::PUBLISH], true)
            && $subject instanceof Post;
    }

    /** @param Post $subject */
    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return false;                     // не залогінений
        }

        // ROLE_ADMIN перевіряємо через Security, щоб спрацювала role_hierarchy;
        // $token->getRoleNames() поверне сирі ролі без успадкування
        if ($this->security->isGranted('ROLE_ADMIN')) {
            return true;
        }

        return match ($attribute) {
            self::EDIT => $subject->getAuthor() === $user && !$subject->isLocked(),
            self::PUBLISH => $subject->getAuthor() === $user
                && $this->security->isGranted('ROLE_EDITOR'),
        };
    }
}

// src/Controller/PostController.php — subject бере значення аргументу $post
#[Route('/posts/{id}/edit', name: 'post_edit')]
#[IsGranted(PostVoter::EDIT, subject: 'post')]
public function edit(Post $post): Response { /* ... */ }

// config/packages/security.yaml
// role_hierarchy:
//     ROLE_EDITOR: [ROLE_USER]
//     ROLE_ADMIN:  [ROLE_EDITOR]
//     ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
Що `isGranted()` не знає нічого про ролі: він передає атрибут і subject у AccessDecisionManager, а той опитує всі теговані `security.voter` сервіси.
Що стратегія за замовчуванням affirmative — достатньо одного GRANTED; при повному ABSTAIN доступ забороняється, бо `allow_if_all_abstain: false`.
Що `role_hierarchy` розгортає RoleHierarchyVoter, тому в кастомному voter'і не можна порівнювати `$token->getRoleNames()` напряму — треба `Security::isGranted('ROLE_X')`.
Що абстрактний `Voter` у Symfony 6/7 реалізує `CacheableVoterInterface`: `supportsAttribute()` і `supportsType()` дозволяють менеджеру взагалі не викликати voter для чужих атрибутів.
Що `access_control` спрацьовує на firewall до контролера і працює з URL, а `#[IsGranted]` — на `kernel.controller_arguments`, тому має доступ до вже завантаженого обʼєкта.
Перевіряти роль усередині voter'а через `in_array('ROLE_ADMIN', $token->getRoleNames(), true)`: токен містить сирі ролі, ієрархія там не розгорнута, і `ROLE_SUPER_ADMIN` не спрацює.
Писати в voter'і `if (!$user instanceof User) { return false; }` замість `return false` у `supports()` — voter повертає DENY замість ABSTAIN і глушить решту voter'ів при стратегії unanimous.
Ставити `IS_AUTHENTICATED_ANONYMOUSLY` у security.yaml: атрибут прибрано в Symfony 6, для відкритих маршрутів є `PUBLIC_ACCESS`.
Класти бізнес-логіку доступу в контролер (`if ($post->getAuthor() === $this->getUser())`) і дублювати ту саму умову в Twig — правило розповзається у трьох місцях замість одного voter'а.
Викликати `isGranted()` у циклі по 200 сутностях: voter не кешує результат між викликами, і кожен його запит до БД перетворюється на N+1.
Вважати, що `#[IsGranted]` захищає entity: він захищає лише дію контролера, прямий виклик сервісу з іншого місця пройде без перевірки.
ПОРАДА

Порада: скажіть, що voter — це єдине місце правди для правила доступу, бо той самий `isGranted('EDIT', $post)` викликається з контролера, з Twig (`is_granted`) і з сервісу. І одразу назвіть пастку з `role_hierarchy`: у voter'і ролі перевіряють через `Security`, а не через `$token->getRoleNames()`.

Сторінка питання →
SF
Symfony·Middle ·DI ·compiler passes ·autowiring

Контейнер компілюється в PHP-клас на етапі cache warmup: autowiring читає типи в конструкторах, compiler passes змінюють визначення сервісів, а результат — статичний код без рефлексії в рантаймі.

Чим контейнер Symfony відрізняється від контейнера Laravel?
Навіщо потрібні compiler passes?
Як зібрати всі сервіси з певним тегом?

Контейнер Symfony живе у двох фазах. Перша — побудова визначень: services.yaml, атрибути на класах, автоматична реєстрація всього з src/, autowiring по типах конструкторів і autoconfigure, який навішує теги за інтерфейсами. Друга — компіляція: всі визначення перетворюються на один згенерований PHP-клас у var/cache, де кожен сервіс створюється звичайним new з уже відомими аргументами. У рантаймі немає ні YAML, ні рефлексії, а помилки залежностей видно ще на cache:warmup.

Між фазами працюють compiler passes. Це код, який бачить усі визначення й може їх змінювати: знайти сервіси з тегом і передати їх у реєстр, підмінити реалізацію, додати декоратор. Теги — головний механізм розширення: бандл оголошує тег, ваш код додає сервіс із цим тегом, і нічого чужого правити не треба. У типових випадках compiler pass не потрібен, достатньо #[AutowireIterator] або !tagged_iterator.

Autowiring розвʼязує залежності по типу й не вгадує. Якщо інтерфейс має дві реалізації, потрібен alias за замовчуванням або явний вибір через #[Target] чи #[Autowire]. Сервіси приватні: їх не дістати через $container->get(), і це навмисно, бо залежності мають бути оголошені в конструкторі, а не витягнуті з контейнера в довільному місці.

// config/services.yaml
// services:
//   _defaults: { autowire: true, autoconfigure: true }
//   App\: { resource: '../src/' }
//   App\Export\Exporter: '@App\Export\CsvExporter'   # alias для інтерфейсу з кількома реалізаціями

interface Exporter { public function supports(string $format): bool; }

#[AutoconfigureTag('app.exporter')]           // кожна реалізація отримує тег автоматично
final class CsvExporter implements Exporter { /* ... */ }
final class XlsxExporter implements Exporter { /* ... */ }

final class ExporterRegistry
{
    /** @param iterable<Exporter> $exporters */
    public function __construct(
        #[AutowireIterator('app.exporter')] private iterable $exporters,
    ) {}

    public function for(string $format): Exporter
    {
        foreach ($this->exporters as $exporter) {
            if ($exporter->supports($format)) {
                return $exporter;
            }
        }
        throw new UnsupportedFormat($format);
    }
}

// Той самий результат через compiler pass, коли потрібна складніша логіка
final class ExporterPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        $refs = array_map(fn ($id) => new Reference($id), array_keys($container->findTaggedServiceIds('app.exporter')));
        $container->getDefinition(ExporterRegistry::class)->setArgument('$exporters', $refs);
    }
}
Що контейнер має дві фази: побудова визначень (services.yaml, атрибути, autoconfigure) і компіляція в один PHP-клас, який у рантаймі просто викликає new.
Що autowiring працює по типу параметра, а для кількох реалізацій одного інтерфейсу потрібен alias, атрибут #[Autowire] або #[Target].
Що compiler pass це хук у момент компіляції: він бачить усі визначення й може їх змінювати, наприклад зібрати теговані сервіси в один реєстр.
Що autoconfigure автоматично навішує теги за інтерфейсом чи атрибутом, тому EventSubscriber або Command реєструються без конфігурації.
Що приватні сервіси не дістати через $container->get(), і це навмисно: залежності оголошуються в конструкторі, а не витягуються з контейнера.
Казати, що контейнер парсить YAML на кожен запит: у prod він скомпільований у var/cache і YAML не читається взагалі.
Плутати autowiring і autoconfigure: перший про підстановку залежностей, другий про автоматичні теги за інтерфейсами.
Не розуміти, що бінарний вибір між двома реалізаціями інтерфейсу autowiring не робить і кидає помилку, поки не додано alias.
Робити сервіси публічними, щоб діставати їх через контейнер у контролері: це ховає залежності й ламає ідею DI.
Реалізовувати логіку збирання плагінів через рефлексію в рантаймі замість тегів і compiler pass чи tagged_iterator.
ПОРАДА

Плюс до відповіді — згадка про compiler passes і теговані сервіси як механізм розширення без правки чужого коду. Наведіть приклад: набір експортерів, які підключаються тегом.

Сторінка питання →
SF
Symfony·Middle ·Doctrine ·Unit of Work ·flush

Doctrine відслідковує всі керовані обʼєкти й накопичує зміни в памʼяті; на flush() вона обчислює change set і виконує запити в правильному порядку.

Чому Doctrine не пише в базу після persist?
Чому flush у циклі це погано?
Що таке identity map і чим вона небезпечна при масовій обробці?

Unit of Work — це реєстр усього, що Doctrine завантажила або отримала через persist у межах одного EntityManager. Для кожної managed-сутності він зберігає копію початкових значень, а на flush() порівнює її з поточним станом, обчислює change set і генерує INSERT, UPDATE, DELETE у порядку, який враховує звʼязки між сутностями. Усе це виконується в одній транзакції.

Звідси два наслідки, які часто дивують. По-перше, persist не робить запиту, а update не існує: зміна властивості через сеттер достатня. По-друге, identity map гарантує один PHP-обʼєкт на один рядок бази, тому повторний find того самого id не йде в базу, а звʼязки завжди вказують на той самий екземпляр.

Ціна цієї моделі — памʼять і вартість flush. Кожен flush обходить усі managed-обʼєкти, тому виклик у циклі дає квадратичну складність і по транзакції на ітерацію. Identity map тримає обʼєкти до clear(), тому імпорт на мільйон рядків без очищення закінчується OOM. Стандартний рецепт — батчі: flush і clear через кожні кількасот записів, toIterable() замість getResult(), а для простих масових оновлень без доменної логіки — DQL або SQL UPDATE одним запитом.

// persist лише реєструє обʼєкт; SQL немає до flush()
$user = new User('[email protected]');
$em->persist($user);        // стан: managed, INSERT ще не виконано

$existing = $em->find(User::class, 42);
$existing->rename('Олена'); // change set порахується на flush, update() не потрібен

$em->flush();               // одна транзакція: INSERT + UPDATE у правильному порядку

// Погано: N обходів identity map і N транзакцій
foreach ($users as $u) {
    $u->activate();
    $em->flush();
}

// Добре для масової обробки: батчі з flush + clear
$batch = 500;
foreach ($query->toIterable() as $i => $u) {
    $u->activate();
    if (($i + 1) % $batch === 0) {
        $em->flush();
        $em->clear();       // identity map звільняється, памʼять не росте
    }
}
$em->flush();

// Для сотень тисяч рядків без логіки в сутностях краще один DQL UPDATE
$em->createQuery('UPDATE App\Entity\User u SET u.active = true WHERE u.invitedAt < :d')
    ->setParameter('d', $threshold)->execute();
Що persist не робить INSERT, а лише переводить обʼєкт у стан managed: реальні запити відбуваються на flush.
Що для managed-сутностей Doctrine зберігає копію оригінальних даних і на flush порівнює її з поточним станом, тому явний update не потрібен.
Що identity map гарантує один PHP-обʼєкт на один рядок у межах EntityManager, звідси й економія запитів, і ріст памʼяті.
Що flush упорядковує запити за залежностями сутностей і загортає їх в одну транзакцію.
Практику масової обробки: батчі з flush і clear через кожні N записів, або взагалі DQL/SQL для UPDATE великих обсягів.
Казати, що Unit of Work це транзакція БД або кеш запитів: це патерн відстеження змін обʼєктів у памʼяті.
Викликати flush після кожного persist у циклі: кожен flush проходить по всій identity map і відкриває транзакцію.
Забувати clear() при обробці сотень тисяч записів: identity map тримає всі обʼєкти й процес падає з OOM.
Викликати clear() і далі використовувати старі обʼєкти: вони стали detached, і зміни в них Doctrine не побачить.
Не знати, що після виключення в flush EntityManager закривається й потребує нового екземпляра.
ПОРАДА

Згадайте clear() під час обробки великих наборів — інакше identity map зʼїдає памʼять. І поясніть, чому один flush у кінці транзакції ефективніший за flush у циклі.

Сторінка питання →
SF
Symfony·Middle ·Forms ·Validator ·DTO

Форма — це двонаправлений маппер HTTP-даних на обʼєкт: submit прогонить дані через трансформери, покладе їх у модель і лише потім викличе Validator, який валідує обʼєкт, а не поля; в JSON-API цей шар зайвий, бо `handleRequest` читає `$request->request`, а не тіло запиту, і краще брати `#[MapRequestPayload]`.

Чому `$form->isValid()` повертає false, хоча жодної помилки на екрані немає?
Ми шлемо JSON у контролер із формою — форма каже, що всі поля порожні. Чому?
Де саме спрацьовують констрейнти: у формі чи в сутності?
Чим `#[MapRequestPayload]` кращий за форму для REST-ендпоінта?

Форма в Symfony — це двонаправлений маппер між HTTP-даними й обʼєктом, а не валідатор. Коли викликається submit(), кожне поле проганяє вхідний рядок через ланцюжок view- і model-трансформерів (getViewData()getNormData()getData()), після чого DataMapper записує результат у властивості обʼєкта з data_class через PropertyAccess. Валідація починається лише після цього і виконується окремим сервісом: ValidatorExtension додає до кореневої форми констрейнт Form, а його FormValidator просить ValidatorInterface перевірити вже змаплений обʼєкт за метаданими класу — тими самими атрибутами #[Assert\...], які працюють і без форм. Тому фраза «констрейнти у формі» майже завжди помилкова: у формі лежить хіба що опція constraints для полів без data_class, решта живе на DTO чи сутності.

З цього порядку випливають дві класичні загадки. Перша: помилка є, а на екрані порожньо. Порушення приходить із property path, який ViolationMapper шукає в дереві форм; якщо шляху немає (наприклад, констрейнт на рівні класу через #[Assert\Callback]), помилка залишається на кореневій формі, і побачити її можна тільки якщо шаблон рендерить form_errors(form). Лікується опцією error_mapping, яка явно каже, на яке поле повісити конкретний шлях. Друга: «This value is not valid» замість вашого повідомлення. Це TransformationFailedException із трансформера — рядок не перетворився на DateTimeImmutable чи int, дані в модель не потрапили, і Validator для цього поля просто не запускався; текст береться з опції invalid_message.

Джерело даних для форми — $request->request і $request->files, а не тіло запиту. Symfony не декодує JSON у $request->request автоматично, тому handleRequest() на JSON-ендпоінті чесно бачить порожньо і повідомляє, що обовʼязкові поля не заповнені. Далі накладається все інше, що форма тягне з собою у stateless-контекст: CSRF-токен, якого в клієнта немає (для класичних форм у Symfony 7.2 зʼявився ще й stateless-варіант із double-submit cookie); іменування полів як form_name[email], тобто чужий для API формат; помилка extra_fields на будь-який зайвий ключ, поки не виставлено allow_extra_fields; і структура помилок у вигляді дерева FormErrorIterator, яку доводиться вручну складати в плаский JSON через $form->getErrors(true, false).

Практичний висновок: у JSON-API форму варто замінити на DTO плюс #[MapRequestPayload] (Symfony 6.3+). Резолвер бере тіло, віддає його Serializer, валідує результат Validator і у разі порушень кидає виняток із кодом 422 — контролер отримує вже коректний обʼєкт. Поруч живуть #[MapQueryString] для query-параметрів (у нього дефолтний код відмови 404, бо невалідний query частіше означає неіснуючий ресурс) і #[MapUploadedFile] для файлів із Symfony 6.4. Формат відповіді при цьому не треба вигадувати: ProblemNormalizer серіалізує ValidationFailedException у RFC 7807 із масивом violations.

Межа проста. Форма виграє там, де сервер рендерить HTML і потрібна двонаправленість: адмінки, CRUD, майстри, CollectionType з allow_add, EntityType з вибіркою з Doctrine. Форма програє там, де половина її роботи не потрібна, а друга половина дублює Serializer. Окремо варто памʼятати про PATCH: HttpFoundationRequestHandler викликає submit($data, false) тільки тому, що метод запиту PATCH, і якщо ви подаєте дані у форму вручну, цей clearMissing доведеться передавати самому — інакше частковий апдейт занулить поля, яких клієнт не надсилав. І в будь-якому підході Validator не замінює обмежень бази: UniqueEntity робить окремий SELECT, тому без унікального індексу гонка двох запитів усе одно пройде.

// 1. Класична форма: валідація живе на класі, а не у формі
final class RegistrationData
{
    #[Assert\NotBlank(groups: ['registration'])]
    #[Assert\Email(mode: 'strict', groups: ['registration'])]
    public ?string $email = null;

    #[Assert\Length(min: 12, groups: ['registration'])]
    public ?string $password = null;
}

$form = $this->createForm(RegistrationType::class, new RegistrationData(), [
    'validation_groups' => ['registration'],  // саме ці групи піде перевіряти FormValidator
    'error_mapping' => ['emailAlreadyTaken' => 'email'], // порушення без свого поля не загубиться
]);

$form->handleRequest($request);           // читає $request->request[form_name] і $request->files
if ($form->isSubmitted() && $form->isValid()) {
    // isValid() без isSubmitted() кине LogicException
}

// 2. Той самий контракт для JSON-API: форма не потрібна взагалі
final class RegisterRequest
{
    public function __construct(
        #[Assert\NotBlank] #[Assert\Email(mode: 'strict')]
        public readonly string $email,
        #[Assert\Length(min: 12)]
        public readonly string $password,
    ) {}
}

#[Route('/api/register', methods: ['POST'])]
public function register(#[MapRequestPayload] RegisterRequest $data): JsonResponse
{
    // тіло вже десеріалізоване Serializer і провалідоване Validator;
    // при порушеннях сюди не зайдемо — резолвер кине 422 з ConstraintViolationList
    return new JsonResponse(['id' => $this->users->register($data)], 201);
}

// 3. Якщо форму все ж треба нагодувати JSON — руками, з урахуванням PATCH
$form->submit(json_decode($request->getContent(), true), $request->getMethod() !== 'PATCH');
Що валідація живе не у формі: форма лише додає констрейнт `Valid` на кореневий обʼєкт, а перевіряє його `ValidatorInterface` за метаданими класу (атрибути `#[Assert\...]`), і порушення потім розкладаються по полях через `error_mapping`.
Що `handleRequest()` бере дані з `$request->request` і `$request->files` за іменем форми, тому сире JSON-тіло туди не потрапляє — його треба декодувати самому й викликати `$form->submit()`.
Що для PATCH `HttpFoundationRequestHandler` викликає `submit($data, false)`, тобто не затирає відсутні поля, а для POST/PUT — затирає; це і є різниця часткового й повного оновлення.
Що помилка трансформера (`TransformationFailedException`) не долітає до Validator і показується як загальне повідомлення з опції `invalid_message`.
Що для stateless JSON-API форму зазвичай замінюють на DTO + `#[MapRequestPayload]` (Symfony 6.3+), який десеріалізує тіло, валідує і дає 422 без CSRF, теми й рендерингу.
Вішати констрейнти лише в `constraints` опції поля й дивуватися, що при `$form->submit()` з `validation_groups` іншої групи вони мовчать.
Вимкнути `csrf_protection` для API, але залишити форму — проблема була не в CSRF, а в тому, що дані не потрапляють у `$request->request` з JSON.
Вважати, що `isValid()` перевіряє форму: без `isSubmitted()` він кине `LogicException`, а перевіряє він змаплений обʼєкт.
Ловити «зайві» ключі як помилку валідації: невідоме поле дає окрему помилку форми `extra_fields`, і вимикається вона опцією `allow_extra_fields`, а не констрейнтом.
Валідувати сутність Doctrine напряму й покладатися на це як на захист БД: Validator не знає про унікальність без `UniqueEntity`, а той робить окремий запит і не рятує від гонки без унікального індексу.
Рендерити помилки форми в JSON через `$form->getErrors()` без `true` як першого аргументу — вкладені помилки дочірніх полів просто зникають.
ПОРАДА

Скажіть коротко: «Form — це UI-шар для HTML, Validator — окремий сервіс, який працює з обʼєктом». Далі покажіть, що в API ви залишаєте другий і викидаєте перший: DTO з `#[Assert]` + `#[MapRequestPayload]`, а `ConstraintViolationList` нормалізуєте у відповідь за RFC 7807.

Сторінка питання →
SF
Symfony·Middle ·Doctrine ·N+1 ·fetch join

Lazy-проксі та PersistentCollection довантажують дані окремим запитом на кожну сутність; лікується fetch join (`addSelect` приєднаної асоціації), а для пагінації — Doctrine Paginator, який ріже сутності, а не рядки.

Чому сторінка з 20 постами робить 21 запит, хоча ми нічого не додавали?
Чим `join` у DQL відрізняється від fetch join?
Чому після `setMaxResults(20)` із JOIN на колекцію повертається 7 записів?
Що дає `fetch: EXTRA_LAZY` і чи рятує воно від N+1?

N+1 у Doctrine — прямий наслідок лінивого завантаження за замовчуванням. Коли гідратор будує сутність Post, для to-one асоціації він підставляє проксі-обʼєкт із заповненим лише ідентифікатором, а для to-many — PersistentCollection у неініціалізованому стані. Обидва виглядають як звичайні обʼєкти, аж поки хтось не викличе геттер: тоді проксі йде в базу за своїм рядком, а колекція — за всім своїм вмістом. Один SELECT на список плюс по одному на кожен елемент у циклі шаблону і дає ті самі «21 запит на 20 постів». Помітно це не в коді репозиторію, а в панелі Doctrine у Symfony Profiler, яка групує ідентичні запити й підписує, скільки разів кожен виконався.

Базовий інструмент — fetch join. У DQL він відрізняється від звичайного JOIN лише тим, що приєднаний аліас потрапляє в SELECT: ->select('p', 'a')->leftJoin('p.author', 'a') або еквівалентний ->addSelect('a'). Тоді гідратор бачить колонки автора, створює повну сутність, кладе її в identity map і привʼязує до поста — жодних додаткових запитів. Саме тут ламаються найчастіше: JOIN без addSelect дає ті самі N+1, бо він служить лише для WHERE й ORDER BY. leftJoin замість innerJoin варто брати свідомо: inner join мовчки викине з видачі всі пости без автора.

З колекціями fetch join має свою ціну — множення рядків. JOIN на p.tags перетворює один пост на стільки рядків, скільки в нього тегів, і setMaxResults(20) після цього обмежує рядки, а не сутності: сторінка отримає 7 постів замість 20. Для цього й існує Doctrine\ORM\Tools\Pagination\Paginator з fetchJoinCollection: true — він спершу вибирає DISTINCT ідентифікатори з LIMIT, а потім виконує основний запит із WHERE id IN (...) уже без обмеження. Із тієї ж причини два fetch join на дві різні колекції в одному запиті дають декартів добуток: другу колекцію дешевше догрузити окремим запитом.

fetch: 'EXTRA_LAZY' в мапінгу розвʼязує іншу задачу і його регулярно плутають із лікуванням N+1. Він змушує PersistentCollection виконувати count(), contains(), containsKey() і slice() цільовим SQL, не ініціалізуючи колекцію: замість гідрації 5000 коментарів — один SELECT COUNT(*). Кількість запитів при цьому не змінюється, змінюється їхня вартість і памʼять. Протилежність, fetch: 'EAGER', теж рідко буває доброю ідеєю як глобальне налаштування: асоціація тягнеться при кожному завантаженні сутності, зокрема в тих сценаріях, де вона не потрібна взагалі, тож рішення про eager краще ухвалювати на рівні конкретного запиту.

Для фонової обробки картина інша: там проблема не в кількості запитів, а в памʼяті, і відповіддю є Query::toIterable() (замінив задепрекейчений iterate()) із flush() і clear() кожні кількасот записів. Обмеження варто назвати самому: toIterable() не працює з fetch join колекцій, бо не може зібрати сутність із кількох рядків. Якщо ж дані потрібні лише для читання, найдешевший шлях — узагалі не гідрувати сутності: DQL-оператор NEW віддає готові DTO, getArrayResult() — масиви, і в обох випадках Unit of Work не тримає копій оригінальних даних. Партіальні обʼєкти для цього більше не варіант — вони задепрекейчені в ORM 2.x і прибрані в 3.0.

// Погано: 1 запит на список + по одному на автора кожного поста
foreach ($repo->findBy(['status' => 'published']) as $post) {
    echo $post->getAuthor()->getName();   // ініціалізація проксі => SELECT ... WHERE id = ?
}

// Добре: fetch join. Ключове тут addSelect — без нього JOIN лише фільтрує
$qb = $em->createQueryBuilder()
    ->select('p', 'a')                    // 'a' у SELECT: автор гідрується разом із постом
    ->from(Post::class, 'p')
    ->leftJoin('p.author', 'a')           // leftJoin, щоб пости без автора не зникли
    ->where('p.status = :status')
    ->setParameter('status', 'published');

// Пагінація з fetch join колекції: LIMIT ріже РЯДКИ, а один пост дає рядок на кожен тег
$qb->leftJoin('p.tags', 't')->addSelect('t')
   ->setFirstResult(0)
   ->setMaxResults(20);

// Paginator робить SELECT DISTINCT p.id ... LIMIT 20, потім основний запит з WHERE p.id IN (...)
$paginator = new Paginator($qb->getQuery(), fetchJoinCollection: true);
foreach ($paginator as $post) {           // рівно 20 сутностей, теги вже в памʼяті
    echo $post->getTitle(), count($post->getTags());
}

// EXTRA_LAZY: count() не завантажує 5000 коментарів, але це все одно запит на кожен пост
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'post', fetch: 'EXTRA_LAZY')]
private Collection $comments;             // $post->getComments()->count() => SELECT COUNT(*)

// Масова обробка: toIterable() не тримає весь результат, clear() чистить identity map
$query = $em->createQuery('SELECT p FROM App\Entity\Post p');  // без fetch join колекцій!
foreach ($query->toIterable() as $i => $post) {
    $post->recalculateStats();
    if (($i + 1) % 500 === 0) {
        $em->flush();
        $em->clear();
    }
}
$em->flush();
Що N+1 у Doctrine породжує ліниве завантаження: to-one асоціація підмінюється проксі, to-many — PersistentCollection, і перший же геттер робить SELECT.
Що звичайний `leftJoin('p.author', 'a')` не рятує: без `addSelect('a')` асоціація не гідрується, JOIN лише фільтрує й сортує.
Що fetch join колекції ламає `setFirstResult`/`setMaxResults`, бо LIMIT застосовується до рядків після JOIN, і саме тому існує `Doctrine\ORM\Tools\Pagination\Paginator`.
Що EXTRA_LAZY це не ліки від N+1, а здешевлення одного звернення: `count()`, `slice()`, `contains()` перестають завантажувати всю колекцію.
Що для масової обробки правильна відповідь — `toIterable()` з `flush()`/`clear()` батчами, і що з fetch join колекцій `toIterable()` не працює.
Писати `->join('p.author', 'a')` без `->addSelect('a')` і дивуватись, що кількість запитів не змінилась.
Ставити `fetch: 'EAGER'` в мапінгу як глобальний фікс: асоціація тягнеться при кожному завантаженні сутності, зокрема там, де вона не потрібна.
Вважати EXTRA_LAZY розвʼязанням N+1: запитів залишається стільки ж, вони просто легші.
Пагінувати fetch join на колекцію через `setMaxResults` без `Paginator` і отримувати неповну сторінку через дублікати рядків.
Лікувати N+1 індексом на зовнішньому ключі або кешем на рівні HTTP: кількість round-trip до бази від цього не змінюється.
Робити три-чотири fetch join колекцій в одному запиті й отримувати декартів добуток рядків замість пришвидшення.
ПОРАДА

Назвіть діагностику до рецепта: панель Doctrine у Symfony Profiler групує однакові запити й показує «executed N times» — саме там N+1 видно за секунду. І окремо згадайте, що fetch join на дві колекції одночасно множить рядки, тому другу колекцію краще догрузити другим запитом.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти