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

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.swagger keys became api_platform.openapi; custom OpenApiFactoryInterface decorators keep working, SwaggerDecorator-style normalizer decoration does not.
  • Security: security and securityPostDenormalize are named arguments on the operation now; the expressions themselves are unchanged.
  • Pagination and filters: paginationItemsPerPage, paginationClientEnabled and friends are named arguments; per-operation filters are new GetCollection(filters: [...]).
  • Event listeners on EventPriorities::PRE_WRITE etc. still fire in 3.x (behind event_listeners_backward_compatibility_layer), but they are the next thing to migrate; 4.x turns them off by default.
  • Tests: ApiTestCase is unchanged; error responses gain @type: hydra:Error details and 3.x moves validation errors to violations arrays 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.

Scan my project Request an assessment (€750)

More guides