API Platform 2.6 to 3: migrating resources, data providers and persisters
The 2.7 bridge release, api:upgrade-resource, explicit operations, state providers and processors, subresources as URI templates, and the filters that moved.
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.
Why this is the migration Rector cannot do
API Platform 3 replaced the entire metadata layer and the data layer. @ApiResource with an array of operation names became #[ApiResource] with explicit operation objects. DataProviderInterface and DataPersisterInterface, where you wrote "give me the item for this id" and "save this", became ProviderInterface and ProcessorInterface, which receive the operation and URI variables. Filters, subresources, DTO data transformers and OpenAPI decoration all moved with them. That is a redesign of the edges of your application, not a rename, and it is why 2.x projects stall.
The path that works uses 2.7, the bridge release. 2.7 runs on Symfony 4.4 through 6.x, contains the entire 3.0 metadata system as an opt-in, and ships a command that rewrites your resource annotations. You migrate on 2.7, resource by resource, while still on Symfony 5.4. Then the bump to 3.x is a version change. 3.4, the last 3.x, requires Symfony 6.4 and PHP 8.1. Everything below 4 is unmaintained, so the goal is to continue to 4.3 (see 3 to 4) once on 6.4.
Step 1: get to 2.7 and turn on the new metadata
composer require api-platform/core:^2.7 --with-all-dependencies
# config/packages/api_platform.yaml
api_platform:
metadata_backward_compatibility_layer: false # use the 3.0 metadata system on 2.7
With the flag at true (the 2.7 default) nothing changes and your app keeps running; that is your safety net. Flip it to false on a branch and the new system is in charge: old annotations still load through a compatibility layer, but every deprecation you now see is an item on the list.
Step 2: rewrite resources with api:upgrade-resource
bin/console api:upgrade-resource --dry-run # shows the rewrite
bin/console api:upgrade-resource # rewrites src/Entity/*
The command converts @ApiResource and @ApiProperty annotations to the new attributes with explicit operations. Review the output, because it can only translate what the old array config expressed:
// Before (2.6)
/**
* @ApiResource(
* collectionOperations={"get", "post"},
* itemOperations={"get", "put"={"security"="object.owner == user"}},
* normalizationContext={"groups"={"order:read"}},
* denormalizationContext={"groups"={"order:write"}}
* )
* @ApiFilter(SearchFilter::class, properties={"status": "exact"})
*/
class Order {}
// After (2.7 new metadata / 3.x)
#[ApiResource(
operations: [
new GetCollection(),
new Post(),
new Get(),
new Put(security: 'object.owner == user'),
],
normalizationContext: ['groups' => ['order:read']],
denormalizationContext: ['groups' => ['order:write']],
)]
#[ApiFilter(SearchFilter::class, properties: ['status' => 'exact'])]
class Order {}
Namespaces change with it: ApiPlatform\Core\Annotation\ApiResource → ApiPlatform\Metadata\ApiResource, and the Doctrine filters move from ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\ to ApiPlatform\Doctrine\Orm\Filter\. A project-wide search and replace on those two prefixes covers most files.
Step 3: data providers become state providers
This is the manual part. A 2.x item provider:
final class OrderItemDataProvider implements ItemDataProviderInterface, RestrictedDataProviderInterface
{
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return Order::class === $resourceClass;
}
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
return $this->orders->findForCurrentTenant((int) $id);
}
}
Becomes a provider bound to the operation instead of guessed from the class:
final class OrderProvider implements ProviderInterface
{
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->orders->findAllForCurrentTenant();
}
return $this->orders->findForCurrentTenant((int) $uriVariables['id']);
}
}
#[ApiResource(operations: [
new Get(provider: OrderProvider::class),
new GetCollection(provider: OrderProvider::class),
])]
class Order {}
Two consequences worth planning for. First, supports() is gone: a provider is either attached to an operation or it is not. Second, decorating the built-in Doctrine provider (a common 2.x pattern for tenant filtering) is done with #[AsDecorator('api_platform.doctrine.orm.state.item_provider')], or by injecting the built-in ProviderInterface $itemProvider with #[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')] and delegating.
Step 4: data persisters become state processors
// Before
final class OrderDataPersister implements ContextAwareDataPersisterInterface
{
public function supports($data, array $context = []): bool { return $data instanceof Order; }
public function persist($data, array $context = []) { $this->workflow->apply($data, 'submit'); $this->em->flush(); return $data; }
public function remove($data, array $context = []) { $this->em->remove($data); $this->em->flush(); }
}
// After: one processor, delegating persistence to the Doctrine processor
final class OrderProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] private ProcessorInterface $persist,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] private ProcessorInterface $remove,
private WorkflowInterface $workflow,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($operation instanceof DeleteOperationInterface) {
return $this->remove->process($data, $operation, $uriVariables, $context);
}
$this->workflow->apply($data, 'submit');
return $this->persist->process($data, $operation, $uriVariables, $context);
}
}
Attach it with new Post(processor: OrderProcessor::class). Persisters that handled several classes with one supports() switch become one processor per resource, which is usually an improvement.
Step 5: subresources become URI templates
// Before: @ApiSubresource on Customer::$orders → /customers/{id}/orders
// After: an operation on the Order resource with a Link
#[ApiResource(
uriTemplate: '/customers/{customerId}/orders',
uriVariables: ['customerId' => new Link(fromClass: Customer::class, toProperty: 'customer')],
operations: [new GetCollection()],
)]
class Order {}
This is more code than @ApiSubresource but it removes the magic: the URL, the join and the operation are visible in one place.
Step 6: DTOs and data transformers
DataTransformerInterface is gone. Input and output DTOs are declared on the operation and the conversion happens in your provider (for output) or processor (for input):
#[Post(input: CreateOrderInput::class, processor: CreateOrderProcessor::class)]
class Order {}
final class CreateOrderProcessor implements ProcessorInterface
{
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Order
{
\assert($data instanceof CreateOrderInput);
$order = Order::fromInput($data);
$this->em->persist($order);
$this->em->flush();
return $order;
}
}
Step 7: the rest of the checklist
- OpenAPI:
api_platform.swaggerkeys becameapi_platform.openapi; customOpenApiFactoryInterfacedecorators keep working,SwaggerDecorator-style normalizer decoration does not. - Security:
securityandsecurityPostDenormalizeare named arguments on the operation now; the expressions themselves are unchanged. - Pagination and filters:
paginationItemsPerPage,paginationClientEnabledand friends are named arguments; per-operation filters arenew GetCollection(filters: [...]). - Event listeners on
EventPriorities::PRE_WRITEetc. still fire in 3.x (behindevent_listeners_backward_compatibility_layer), but they are the next thing to migrate; 4.x turns them off by default. - Tests:
ApiTestCaseis unchanged; error responses gain@type: hydra:Errordetails and 3.x moves validation errors toviolationsarrays under the same keys. Snapshot tests will need updating.
Step 8: bump to 3.x
With the flag at false, all resources converted and no deprecations in the test run, the bump is:
composer require api-platform/core:^3.4 --with-all-dependencies # requires Symfony 6.4
Remove metadata_backward_compatibility_layer from the config, delete any ApiPlatform\Core\ import that survived, run the tests. Then continue to 4.x; 3.4 is a waypoint, not a destination.
How many resources, providers and persisters are we talking about?
The €750 assessment inventories every ApiResource, data provider, persister, filter and subresource in your repository and returns a fixed price for the migration. Credited in full against the upgrade.
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 3 to 4: the state-based flow, RFC 7807 errors and Hydra changes
- Upgrading Doctrine ORM 2 to 3 (and DBAL 3 to 4) in a Symfony application
- Replacing abandoned Symfony bundles: SensioFrameworkExtra, Guard, Swiftmailer, FOSRest, FOSUser