KwikTech Symfony · API Platform · Doctrine upgrades
Upgrade guide · 10 min read

API Platform 3 to 4: the state-based flow, RFC 7807 errors and Hydra changes

Flip the 3.4 compatibility flags one at a time, move off the event listeners, adopt the new error format and the unprefixed Hydra keys, then bump to 4.x.

Published 2026-09-12. Written for teams doing the upgrade themselves; if you would rather have it done, see fixed-price upgrades. Also available in Deutsch.

What 4.0 removed

API Platform 4 is the version where the 2.x compatibility layers finally disappear. The event-listener based request flow, the legacy inflector, the pre-RFC 7807 error format and the hydra:-prefixed collection keys were all switchable in 3.x and are gone or default-off in 4. 4.x requires Symfony 6.4 or newer and PHP 8.2 or newer; 4.3 is the maintained bug-fix branch and 4.2 the security branch. Everything below is unmaintained.

The method is the same as for 2 to 3: get to the last 3.x (3.4), flip each compatibility flag to its 4.x value one at a time, fix what breaks, then bump. Each flag is a pull request. Nothing here requires a big-bang release.

Step 1: 3.4 and zero deprecations

composer require api-platform/core:^3.4 --with-all-dependencies

Run the test suite with the PHPUnit bridge (SYMFONY_DEPRECATIONS_HELPER=max[self]=0). API Platform 3.4 deprecates everything 4.0 removes; the report is your list.

Step 2: the state-based request flow

In 2.x and early 3.x, a request passed through a chain of Symfony event listeners (read, deserialize, validate, write, serialize) at fixed priorities, and projects hooked their logic on EventPriorities::PRE_WRITE and friends. 3.2 introduced the state-based flow, where the same steps are providers and processors composed around your operation, and 4.0 makes it the only flow.

# config/packages/api_platform.yaml (3.2+)
api_platform:
    use_symfony_listeners: false    # 4.x default; true keeps the old listeners for now

Flip it and run the tests. Every listener your project registered on the kernel.view or kernel.request events with API Platform priorities now needs a home:

// Before: listener on PRE_WRITE
final class OrderSubmitListener implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::VIEW => ['onView', EventPriorities::PRE_WRITE]];
    }

    public function onView(ViewEvent $event): void
    {
        $order = $event->getControllerResult();
        if (!$order instanceof Order || !$event->getRequest()->isMethod('POST')) {
            return;
        }
        $order->submittedAt = new \DateTimeImmutable();
    }
}

// After: a processor that decorates the persist processor for this operation
#[Post(processor: OrderSubmitProcessor::class)]
class Order {}

final class OrderSubmitProcessor implements ProcessorInterface
{
    public function __construct(
        #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] private ProcessorInterface $persist,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        $data->submittedAt = new \DateTimeImmutable();

        return $this->persist->process($data, $operation, $uriVariables, $context);
    }
}

Listeners that touched the response (adding headers, wrapping output) become a processor that runs after serialization, or a plain Symfony response listener without API Platform priorities. Listeners that did authorization should already be security: expressions or voters; if they are not, this is the moment.

Step 3: RFC 7807 errors

4.x returns errors as application/problem+json (and its JSON-LD and Hydra variants) with type, title, status, detail. Validation errors carry a violations array. Enable it on 3.x first:

api_platform:
    defaults:
        rfc_7807_compliant_errors: true

What changes for clients: the @type of an error is Error or ConstraintViolation (previously hydra:Error and ConstraintViolationList), hydra:description becomes detail, and the HTTP status of a validation failure stays 422. Custom exception-to-status mapping in api_platform.exception_to_status keeps working. If you had a custom error normalizer, it is almost certainly replaceable by an ErrorResource-style resource or by ProblemExceptionInterface on your own exceptions.

final class InsufficientStockException extends \RuntimeException implements ProblemExceptionInterface
{
    public function getType(): string { return '/errors/insufficient-stock'; }
    public function getTitle(): ?string { return 'Insufficient stock'; }
    public function getStatus(): ?int { return 409; }
    public function getDetail(): ?string { return $this->getMessage(); }
    public function getInstance(): ?string { return null; }
}

Step 4: Hydra keys without the prefix

Collections in 4.x serialize as member, totalItems, view and search instead of hydra:member, hydra:totalItems and so on. The @context still declares the Hydra vocabulary, so JSON-LD consumers are unaffected; plain JSON consumers that hard-coded hydra:member are.

api_platform:
    serializer:
        hydra_prefix: false    # 4.x default; set true to keep the old keys during a transition

Flip it on 3.4, fix the frontend and the API tests, ship. If your API has external consumers you do not control, keep hydra_prefix: true in 4.x for as long as you need; the option still exists there.

Step 5: the smaller flags

Option (3.x)4.xWhat it means
keep_legacy_inflector: truefalse / removedResource short names pluralize with symfony/string. A resource named Person was /people in both, but irregular names can change; check debug:router.
event_listeners_backward_compatibility_layerremoved2.x listener behaviour; superseded by step 2.
enable_docs, enable_entrypoint, enable_swagger_uiunchangedStill there.
defaults.stateless: trueunchangedKeep it; it is what turns off session-based firewall behaviour for API routes.

Step 6: packages and namespaces

4.x split the monolith into components. api-platform/core still installs everything, and staying on it is fine. If you want the smaller footprint, the Symfony integration is api-platform/symfony plus the pieces you use (api-platform/doctrine-orm, api-platform/graphql, api-platform/openapi). Namespaces did not move for application code: ApiPlatform\Metadata\*, ApiPlatform\State\* and ApiPlatform\Doctrine\Orm\Filter\* are the same in 3.4 and 4.3.

Doctrine ORM 3 is supported by 4.x (any 3.x except exactly 3.0.0, which is excluded). GraphQL moves to webonyx/graphql-php 15; custom GraphQL resolvers implementing QueryItemResolverInterface continue to work.

Step 7: bump

composer require api-platform/core:^4.3 --with-all-dependencies
bin/console cache:clear
bin/console debug:router | grep api_       # compare against a dump from before
vendor/bin/phpunit

Remove the flags you set in steps 2 to 5 from the config, since they are now the defaults. Regenerate the OpenAPI document (bin/console api:openapi:export --yaml) and diff it against the 3.4 export: this catches renamed operations and changed error schemas before a client does.

Order of the pull requests

  1. 3.4, deprecations to zero
  2. use_symfony_listeners: false, listeners rewritten as providers/processors
  3. rfc_7807_compliant_errors: true, clients and tests updated
  4. hydra_prefix: false, frontend updated
  5. Inflector and remaining flags
  6. Bump to 4.3, OpenAPI diff

On top of a Symfony upgrade this lands at €3,000 to €6,000 for most APIs; the drivers are the number of custom listeners and of consumers that parse error bodies.

Still on API Platform 2 or 3?

The scan flags API Platform below 4 as a cliff, shows which Symfony versions your installed release allows, and orders the API Platform steps correctly relative to the Symfony and Doctrine ones.

Scan my project Request an assessment (€750)

More guides