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

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

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

Тема
Рівень
2 питання
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 у реальному обробнику.

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