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

Питання на співбесіду Symfony

Питання для Symfony-розробників: dependency injection і компіляція контейнера, Doctrine ORM, Messenger, події ядра, конфігурація та DDD-підхід.

Тема
Рівень
8 питань
SF
Symfony·Junior ·HttpKernel ·EventDispatcher ·lifecycle

HttpKernel перетворює Request на Response, дорогою розсилаючи події: kernel.request (маршрутизація), kernel.controller і kernel.controller_arguments (резолвінг контролера й аргументів), kernel.view (якщо контролер повернув не Response), kernel.response, kernel.terminate після відправлення відповіді.

Що відбувається між public/index.php і моїм контролером?
У якому порядку йдуть kernel.request, kernel.controller, kernel.view, kernel.response, kernel.terminate?
Мій listener не бачить назви маршруту в атрибутах запиту — чому?
Як повернути відповідь ще до того, як викличеться контролер?

Уся Symfony зводиться до одного контракту: HttpKernelInterface::handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response. Фронт-контролер public/index.php (з Symfony 5.3 це кілька рядків, які повертають замикання, а решту робить компонент Runtime через vendor/autoload_runtime.php) створює Request::createFromGlobals(), віддає його ядру, відправляє отриманий Response і викликає terminate(). Клас Kernel із src/Kernel.php спершу завантажується — реєструє бандли й піднімає скомпільований контейнер, — а потім передає запит сервісу http_kernel. Саме HttpKernel виконує прохід від запиту до відповіді й у ключових точках розсилає події через EventDispatcher. Фреймворк тут нічого не «знає» наперед: маршрутизацію, безпеку, профайлер додають звичайні слухачі.

Порядок такий. kernel.request — найбагатша подія: тут RouterListener (пріоритет 32) зіставляє URL із маршрутом і кладе результат у $request->attributes (_route, _controller, параметри шляху), тут же працює firewall. Якщо будь-який слухач викличе $event->setResponse(), поширення події зупиняється й HttpKernel одразу переходить до kernel.response — контролер не викликається взагалі. Далі ControllerResolver перетворює _controller на callable, диспатчиться kernel.controller (контролер ще можна підмінити), ArgumentResolver збирає аргументи, диспатчиться kernel.controller_arguments — і лише тоді викликається ваш метод.

Що станеться з результатом, залежить від його типу. Повернули Response — Symfony одразу диспатчить kernel.response, де слухачі правлять заголовки, кладуть cookie, вставляють toolbar профайлера. Повернули щось інше — масив, DTO, сутність — диспатчиться kernel.view, і хтось має перетворити це на Response: атрибут #[Template] віддасть дані у Twig, у API-проєктах це робить слухач, що серіалізує обʼєкт у JSON. Якщо після kernel.view відповіді досі немає, ви отримаєте LogicException про те, що контролер має повертати Response. Виняток у будь-якому місці циклу (за $catch = true) веде до kernel.exception: слухачі можуть підставити свою відповідь, інакше ErrorListener рендерить сторінку помилки, а код береться з HttpExceptionInterface, якщо виняток його реалізує.

Останні два кроки часто плутають. kernel.finish_request закриває обробку конкретного запиту (важливо для під-запитів: він відновлює контекст батьківського запиту, наприклад локаль), а kernel.terminate спрацьовує вже після $response->send() — відповідь у користувача, а процес доробляє «хвіст». Тому змінювати відповідь там безглуздо, зате доречно відправити лист чи дописати статистику; щось справді довге має йти в чергу. І весь цей цикл повторюється для кожного під-запиту з render(controller(...)) та ESI, тому будь-який слухач, дія якого має бути одноразовою, починається з if (!$event->isMainRequest()) { return; }.

Практичний спосіб побачити все це самому: php bin/console debug:event-dispatcher kernel.request покаже реальний список слухачів із пріоритетами, а вкладка Events у веб-профайлері — які з них відпрацювали на конкретному запиті. Це швидше за читання схеми й одразу пояснює, чому ваш listener бачить або не бачить _route.

// src/EventSubscriber/MaintenanceSubscriber.php
final class MaintenanceSubscriber implements EventSubscriberInterface
{
    public function __construct(private MaintenanceMode $maintenance) {}

    public static function getSubscribedEvents(): array
    {
        return [
            // 33 > 32 (пріоритет RouterListener): маршрут ще не визначено, _route буде null
            KernelEvents::REQUEST => ['onRequest', 33],
            KernelEvents::RESPONSE => 'onResponse',
        ];
    }

    public function onRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return; // під-запити з render()/ESI не чіпаємо
        }

        if ($this->maintenance->isOn()) {
            // setResponse зупиняє поширення події: контролер не викличеться взагалі,
            // далі одразу піде kernel.response
            $event->setResponse(new Response('Технічні роботи', 503));
        }
    }

    public function onResponse(ResponseEvent $event): void
    {
        // сюди потрапляє і звичайна відповідь, і сторінка помилки з kernel.exception
        $event->getResponse()->headers->set('X-App', 'phpukraine');
    }
}

// src/Controller/ArticleController.php
#[Route('/articles/{slug}', name: 'article_show')]
public function show(Article $article): Response // обʼєкт підставив EntityValueResolver
{
    return $this->render('article/show.html.twig', ['article' => $article]);
}
Що вся робота фреймворку — це один контракт `HttpKernelInterface::handle(Request): Response`, а події лише вбудовуються в цей прохід.
Що маршрут визначає `RouterListener` на `kernel.request`, тому до цієї події атрибута `_route` в запиті ще немає.
Що `$event->setResponse()` на `kernel.request` зупиняє поширення події й контролер не викликається взагалі — так працюють firewall, maintenance mode, кеш.
Що `kernel.view` спрацьовує лише тоді, коли контролер повернув не `Response` (масив, обʼєкт), і саме там працює `#[Template]` чи серіалізація в API.
Що `kernel.terminate` виконується після відправлення відповіді користувачу і придатний для повільної роботи, а `kernel.exception` перетворює виняток на `Response`.
Що є головний запит і під-запити (`MAIN_REQUEST` / `SUB_REQUEST`), і listener зобовʼязаний перевіряти `$event->isMainRequest()`.
Казати, що контролер викликається одразу після роутингу: між ними ще `kernel.controller`, резолвінг аргументів і `kernel.controller_arguments`.
Читати `$request->attributes->get('_route')` у listener з високим пріоритетом на `kernel.request` і дивуватися `null`: `RouterListener` має пріоритет 32 і ще не відпрацював.
Очікувати `kernel.view`, коли контролер уже повернув `Response` — подія в цьому разі не диспатчиться зовсім.
Модифікувати відповідь у `kernel.terminate`: її вже відправлено, зміни нікуди не потраплять.
Забути `isMainRequest()` і виконати логіку авторизації чи логування вдруге на кожному `render()`-фрагменті Twig.
Плутати `Kernel` (бандли, конфіг, контейнер, бутстрап) з `HttpKernel` (сервіс, що реально ганяє події й викликає контролер).
ПОРАДА

Проговоріть ланцюжок уголос у порядку подій і назвіть для кожної реального слухача: `kernel.request` → `RouterListener` і `FirewallListener`, `kernel.controller_arguments` → резолвери аргументів, `kernel.view` → `#[Template]`, `kernel.exception` → `ErrorListener`, `kernel.terminate` → відправлення листів. Це одразу показує, що ви бачили стек, а не читали схему.

Сторінка питання →
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 на дві колекції одночасно множить рядки, тому другу колекцію краще догрузити другим запитом.

Сторінка питання →
SF
Symfony·Senior ·API Platform ·DTO ·state provider

API Platform виправданий, коли потрібен машиночитний контракт (OpenAPI/JSON-LD), фільтри, пагінація й HTTP-кеш «з коробки»; головна архітектурна зміна — контролер зникає, а його місце займають state provider і processor, тому ресурс має бути окремим DTO, а не Doctrine-сутністю.

У нас 40 CRUD-ендпоінтів на Doctrine-сутностях — навіщо тягнути ще один шар?
Чому в API Platform немає контролерів і де тоді жити бізнес-логіці?
Ми поставили #[ApiResource] на сутність, а тепер не можемо змінити колонку в БД без ламання клієнтів. Що зробили не так?
Чим state provider і state processor відрізняються від DataProvider і DataPersister?

API Platform (4.x, вимагає PHP 8.2+ і Symfony 6.4/7.x) — це не «генератор CRUD», а машина, що з одних метаданих виводить кілька представлень одразу: специфікацію OpenAPI, гіпермедіа-формат JSON-LD/Hydra за замовчуванням, за потреби JSON:API, HAL і GraphQL, а разом з ними — узгоджену пагінацію, фільтри, обробку помилок валідації у форматі RFC 7807 і заголовки кешу. Виправданий він тоді, коли ця машиночитність комусь потрібна: є фронтенд або мобільний клієнт, що генерує собі SDK з OpenAPI; є десятки read-моделей із фільтрами й сортуванням, які інакше довелося б писати руками; є вимога до стабільного, задокументованого контракту. Якщо ж API — це десяток викликів-команд («створити», «підтвердити», «скасувати») і жодної колекції з фасетним пошуком, звичайні контролери плюс Messenger дадуть менше магії й менше шарів.

Головна архітектурна зміна — зникнення контролера як точки розширення. У 3.x і 4.x кожна операція має свій provider (клас із ApiPlatform\State\ProviderInterface::provide()) і processor (ProcessorInterface::process()); вони замінили DataProviderInterface і DataPersisterInterface з 2.x разом із їхнім supports(), бо тепер прив'язка явна, прямо в атрибуті операції. Дефолтні реалізації для Doctrine ORM — ItemProvider, CollectionProvider, PersistProcessor, RemoveProcessor — можна декорувати, коли треба лише додати крок до стандартного шляху. Далі йдуть Doctrine-розширення (QueryCollectionExtensionInterface, QueryItemExtensionInterface), які втручаються у сам QueryBuilder і тому підходять для наскрізних умов на кшталт multi-tenancy. Кастомний controller: в операції теж існує, але він обходить денормалізацію, валідацію й security-експресії, тому це шлях для виняткових відповідей, а не для бізнес-логіки.

Друга зміна — де проходить межа контракту. Найшвидший старт — повісити #[ApiResource] на Doctrine-сутність: фільтри SearchFilter, OrderFilter, RangeFilter, DateFilter через #[ApiFilter] працюють одразу, eager-loading-розширення саме будує join-и за групами серіалізації. Ціна — схема бази стає публічним контрактом: перейменування колонки ламає клієнтів, а групи серіалізації й #[Assert]-обмеження для API розповзаються по доменних класах. Дисципліноване рішення — ресурс як окремий клас у src/Api/Resource, свій provider для читання (аж до простого DBAL-запиту з проєкцією рівно потрібних полів) і processor, що викликає команду домену. Тоді сутність вільно змінюється, а API — ні.

Безпека й продуктивність теж переїжджають у метадані, і це місце типових помилок. security: "is_granted('ROLE_CUSTOMER')" виконується до денормалізації, тому бачить лише користувача й request; усе, що залежить від надісланого тіла, перевіряється в securityPostDenormalize, де доступні object і previous_object. Обмеження видимості колекції краще робити не security-виразом, а розширенням запиту: воно дає 404 замість 403 і не витягує з бази чужий рядок, щоб потім його відкинути. З продуктивністю тримайте в голові дві речі: maxJoins (за замовчуванням 30) обмежує автоматичний eager loading, а COUNT для пагінації на великих таблицях знімається pagination_partial: true або курсорною пагінацією через paginationViaCursor.

Межі варто називати чесно. API Platform не має вбудованого версіонування API — його роблять окремими ресурсами під різними uriTemplate або через content negotiation, і це ручна робота. Дії-команди лягають на HTTP-семантику погано: POST /orders/{id}/confirm з окремим input-DTO працює, але виглядає як вправа на обхід моделі ресурсів. Серіалізація нормалізатором коштує помітного CPU, тому широкі групи на гарячих ендпоінтах доводиться замінювати вузькими read-моделями й HTTP-кешем (cacheHeaders плюс інвалідація через Varnish або Souin). І, нарешті, команді потрібно розуміти сам фреймворк: помилку в конфігурації операції видно не в стектрейсі контролера, а у відповіді, якої ніхто не очікував.

// Ресурс API — окремий DTO, а не Doctrine-сутність: схема БД лишається приватною
#[ApiResource(
    shortName: 'Order',
    operations: [
        new GetCollection(provider: OrderCollectionProvider::class),
        new Get(provider: OrderItemProvider::class, security: "is_granted('ORDER_VIEW', object)"),
        new Post(
            processor: PlaceOrderProcessor::class,
            security: "is_granted('ROLE_CUSTOMER')",       // до денормалізації: перевірка ролі
            validationContext: ['groups' => ['order:create']],
        ),
    ],
    normalizationContext: ['groups' => ['order:read']],
    denormalizationContext: ['groups' => ['order:write']],
    paginationItemsPerPage: 30,
)]
final class OrderResource
{
    #[Groups(['order:read'])]
    public ?string $id = null;

    #[Assert\NotBlank(groups: ['order:create'])]
    #[Groups(['order:read', 'order:write'])]
    public string $sku = '';

    #[Assert\Positive(groups: ['order:create'])]
    #[Groups(['order:read', 'order:write'])]
    public int $quantity = 1;
}

// Processor замінює контролер: HTTP-шар лише передає команду в домен
final readonly class PlaceOrderProcessor implements ProcessorInterface
{
    public function __construct(private PlaceOrder $placeOrder) {}

    /** @param OrderResource $data */
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): OrderResource
    {
        $order = $this->placeOrder->handle(new PlaceOrderCommand($data->sku, $data->quantity));
        $data->id = $order->id()->toString();   // повернутий ресурс серіалізується автоматично

        return $data;
    }
}
Що API Platform дає не «CRUD швидше», а генерований контракт: OpenAPI, JSON-LD/Hydra, JSON:API, GraphQL, фільтри, пагінацію, ETag і валідацію з одних метаданих.
Що в 3.x/4.x шар даних це ProviderInterface::provide() і ProcessorInterface::process(), які замінили DataProviderInterface та DataPersisterInterface з 2.x, — контролер більше не точка розширення.
Що #[ApiResource] на Doctrine-сутності перетворює схему БД на публічний контракт, і межа між моделлю персистентності та представленням зникає; на serious-проєкті ресурс роблять окремим класом-DTO.
Що операції описуються поокремо (Get, GetCollection, Post, Patch, Delete) і кожна має власні security, validationContext і групи серіалізації — це і є заміна набору контролерів.
Що межі відомі: складні дії-команди, версіонування API і нестандартні відповіді доводиться робити руками, і в цих випадках звичайний контролер чесніший.
Ставити #[ApiResource] прямо на сутність і потім не мати змоги перейменувати поле в БД без міграції клієнтів.
Писати бізнес-логіку в кастомному контролері операції: у 3.x/4.x правильна точка розширення — processor, а контролер обходить серіалізацію, валідацію й security-експресії.
Плутати security і securityPostDenormalize: перевірку, що залежить від надісланих даних чи від об'єкта після денормалізації, треба робити другим, а не першим.
Обмежувати вибірку в кастомному провайдері замість Doctrine-розширення (QueryCollectionExtensionInterface), через що multi-tenancy не діє на всі операції одразу.
Вважати, що фільтри вмикаються самі: без #[ApiFilter] або #[QueryParameter] параметр у query string просто ігнорується, а не фільтрує.
Не дивитися на кількість запитів: eager-loading-розширення робить join-и автоматично, але має ліміт maxJoins (за замовчуванням 30) і легко ламається на глибоких групах серіалізації.
ПОРАДА

Формулюйте вибір через контракт, а не через швидкість: «API Platform беремо, коли клієнтам потрібен стабільний машиночитний контракт і купа read-моделей з фільтрами; ресурси при цьому — DTO, а сутності лишаються всередині». Одна фраза про DTO-ресурси одразу відрізняє того, хто це підтримував у проді.

Сторінка питання →
SF
Symfony·Senior ·Messenger ·retry ·failed transport

Транспорт налаштовується з retry_strategy: кількість спроб, початкова затримка й множник; вичерпані повідомлення йдуть у failed transport, а логічні помилки відкидаються одразу через UnrecoverableMessageHandlingException.

Що відбувається з повідомленням, яке впало в handler?
Як відрізнити тимчасову помилку від логічної в черзі?
Як не втратити повідомлення після вичерпання спроб?

Повтори в Messenger — частина конфігурації транспорту, а не коду обробника. retry_strategy задає кількість спроб, початкову затримку, множник і максимальну затримку, тобто експоненційний backoff. При помилці Messenger додає до повідомлення RedeliveryStamp з номером спроби й публікує його назад у транспорт із затримкою, тому лічильник живе в самому повідомленні й працює однаково з AMQP, Doctrine чи Redis.

Надійність починається з класифікації помилок. Тимчасові — таймаут, обрив зʼєднання, deadlock — повторювати є сенс. Логічні — сутність не існує, дані невалідні, картка відхилена — повторювати шкідливо: пʼять спроб із backoff лише відкладають той самий результат. Для них є UnrecoverableMessageHandlingException, яка одразу відправляє повідомлення у failed transport. Зворотний випадок — RecoverableMessageHandlingException, яка змушує повторити навіть після ліміту.

Failed transport — не смітник, а черга на ручний розбір. Після виправлення причини повідомлення повертають через messenger:failed:retry, безнадійні видаляють. На розмір цієї черги має бути алерт, бо саме він, а не кількість retry, показує реальні втрати.

Оскільки між спробами частина роботи могла виконатись, обробник мусить бути ідемпотентним: перевіряти стан агрегату перед дією і зберігати результат під унікальним ключем. Різні типи повідомлень варто розводити по різних транспортах із власними стратегіями: платежам потрібно більше спроб і довший backoff, ніж сповіщенням.

// config/packages/messenger.yaml
// framework:
//   messenger:
//     failure_transport: failed
//     transports:
//       payments:
//         dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
//         retry_strategy: { max_retries: 5, delay: 2000, multiplier: 3, max_delay: 300000 }
//       failed: 'doctrine://default?queue_name=failed'
//     routing:
//       App\Message\CapturePayment: payments

#[AsMessageHandler]
final class CapturePaymentHandler
{
    public function __invoke(CapturePayment $message): void
    {
        $order = $this->orders->find($message->orderId)
            ?? throw new UnrecoverableMessageHandlingException('Order gone'); // не повторювати

        if ($order->isCaptured()) {
            return; // ідемпотентність: повтор після часткового виконання безпечний
        }

        try {
            $this->gateway->capture($order->paymentId(), $order->total());
        } catch (GatewayTimeout | ConnectionException $e) {
            throw new RecoverableMessageHandlingException('Gateway unavailable', previous: $e); // повторити з backoff
        } catch (CardDeclined $e) {
            $order->markDeclined($e->reason());
            throw new UnrecoverableMessageHandlingException('Declined', previous: $e);
        }

        $order->markCaptured();
    }
}
Що retry живе в конфігурації транспорту, а не в try/catch усередині handler: max_retries, delay, multiplier, max_delay дають експоненційний backoff.
Розділення помилок на два класи: тимчасові (мережа, таймаут, deadlock) повторюємо, логічні (невалідні дані, сутність не існує) відкидаємо одразу через UnrecoverableMessageHandlingException.
Що RecoverableMessageHandlingException навпаки змушує повторити, навіть якщо ліміт вичерпано.
Що failed transport це не смітник, а черга для ручного розбору: messenger:failed:show, retry, remove, і на її розмір має бути алерт.
Що handler мусить бути ідемпотентним, бо між спробами частина роботи могла виконатись, і що для власної логіки можна реалізувати RetryStrategyInterface.
Реалізовувати повтори циклом try/catch у handler: воркер блокується на sleep, і немає ні backoff, ні обмеження спроб, ні failed transport.
Повторювати все підряд: валідаційна помилка після п'яти спроб з backoff лише відкладає неминуче й засмічує логи.
Не налаштувати failed transport: після вичерпання спроб повідомлення просто зникає з ack.
Ставити однакову затримку без множника: під час падіння зовнішнього API всі повідомлення повертаються одночасно і добивають його.
Ігнорувати, що повідомлення після redelivery може отримати інший воркер: локальний стан у handler між спробами не зберігається.
ПОРАДА

Сильна відповідь містить моніторинг: алерт на розмір failed-черги, а не тільки на кількість retry. І приклад, як ви розділили Unrecoverable і Recoverable у реальному обробнику.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти
ПІДТЕМИ
Dependency injection Doctrine ORM Messenger Event dispatcher Конфігурація API Platform
НА ЧОМУ ВАЛЯТЬСЯ

Symfony-співбесіди технічніші за Laravel: більше про контейнер, Doctrine та межі шарів. Готуйтесь пояснювати Unit of Work, а не тільки писати запити.