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

Коли API Platform виправданий і як він змінює архітектуру?

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 DTO state provider OpenAPI Doctrine

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-ресурси одразу відрізняє того, хто це підтримував у проді.

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

Цінність — у метаданих, з яких генеруються OpenAPI, JSON-LD, фільтри й валідація; точки розширення — ProviderInterface і ProcessorInterface, а DTO-ресурс відв'язує публічний контракт від схеми БД.