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.x | What it means |
|---|---|---|
keep_legacy_inflector: true | false / removed | Resource 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_layer | removed | 2.x listener behaviour; superseded by step 2. |
enable_docs, enable_entrypoint, enable_swagger_ui | unchanged | Still there. |
defaults.stateless: true | unchanged | Keep 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
- 3.4, deprecations to zero
use_symfony_listeners: false, listeners rewritten as providers/processorsrfc_7807_compliant_errors: true, clients and tests updatedhydra_prefix: false, frontend updated- Inflector and remaining flags
- 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.
More guides
- Upgrading Symfony 5.4 to 6.4: the complete guide
- Upgrading Symfony 6.4 to 7.4: deprecations, return types and the jump
- API Platform 2.6 to 3: migrating resources, data providers and persisters
- Upgrading Doctrine ORM 2 to 3 (and DBAL 3 to 4) in a Symfony application
- Replacing abandoned Symfony bundles: SensioFrameworkExtra, Guard, Swiftmailer, FOSRest, FOSUser