KwikTech Symfony · API Platform · Doctrine upgrades
Upgrade-Leitfaden · 13 Min. Lesezeit

API Platform 2.6 auf 3: Resources, Data Provider und Persister migrieren

Das Brücken-Release 2.7, api:upgrade-resource, explizite Operationen, State Provider und Processor, Subresources als URI-Templates und die Filter, die umgezogen sind.

Veröffentlicht 2026-09-12. Geschrieben für Teams, die das Upgrade selbst machen; wer es lieber machen lässt, findet hier Upgrades zum Festpreis. Auch verfügbar auf English.

Warum das die Migration ist, die Rector nicht kann

API Platform 3 hat die gesamte Metadata-Schicht und die Datenschicht ersetzt. @ApiResource mit einem Array von Operationsnamen wurde zu #[ApiResource] mit expliziten Operationsobjekten. DataProviderInterface und DataPersisterInterface, in denen Sie "gib mir das Item zu dieser ID" und "speichere das" schrieben, wurden zu ProviderInterface und ProcessorInterface, die Operation und URI-Variablen erhalten. Filter, Subresources, DTO-Data-Transformer und OpenAPI-Dekoration sind mit umgezogen. Das ist ein Redesign der Ränder Ihrer Anwendung, keine Umbenennung, und deshalb bleiben 2.x-Projekte stecken.

Der Weg, der funktioniert, nutzt 2.7, das Brücken-Release. 2.7 läuft auf Symfony 4.4 bis 6.x, enthält das komplette 3.0-Metadata-System als Opt-in und liefert einen Befehl mit, der Ihre Resource-Annotations umschreibt. Sie migrieren auf 2.7, Resource für Resource, noch auf Symfony 5.4. Dann ist der Sprung auf 3.x ein Versionswechsel. 3.4, das letzte 3.x, setzt Symfony 6.4 und PHP 8.1 voraus. Alles unter 4 ist nicht mehr gepflegt, das Ziel ist also, auf 6.4 weiter auf 4.3 zu gehen (siehe 3 auf 4).

Schritt 1: auf 2.7 und das neue Metadata-System einschalten

composer require api-platform/core:^2.7 --with-all-dependencies
# config/packages/api_platform.yaml
api_platform:
    metadata_backward_compatibility_layer: false   # das 3.0-Metadata-System auf 2.7 nutzen

Mit dem Flag auf true (Default in 2.7) ändert sich nichts und die App läuft weiter; das ist Ihr Sicherheitsnetz. Stellen Sie es auf einem Branch auf false, und das neue System übernimmt: Alte Annotations laden noch über eine Kompatibilitätsschicht, aber jede Deprecation, die Sie jetzt sehen, ist ein Punkt auf der Liste.

Schritt 2: Resources mit api:upgrade-resource umschreiben

bin/console api:upgrade-resource --dry-run   # zeigt die Umschreibung
bin/console api:upgrade-resource             # schreibt src/Entity/* um

Der Befehl wandelt @ApiResource- und @ApiProperty-Annotations in die neuen Attributes mit expliziten Operationen um. Prüfen Sie die Ausgabe, denn er kann nur übersetzen, was die alte Array-Konfiguration ausdrückte:

// Vorher (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 {}

// Nachher (2.7 neues 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 ändern sich mit: ApiPlatform\Core\Annotation\ApiResource → ApiPlatform\Metadata\ApiResource, und die Doctrine-Filter wandern von ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\ nach ApiPlatform\Doctrine\Orm\Filter\. Ein projektweites Suchen und Ersetzen dieser beiden Präfixe deckt die meisten Dateien ab.

Schritt 3: Data Provider werden State Provider

Das ist der manuelle Teil. Ein 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);
    }
}

Wird zu einem Provider, der an die Operation gebunden ist, statt aus der Klasse geraten zu werden:

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 {}

Zwei Konsequenzen, die man einplanen sollte. Erstens: supports() ist weg; ein Provider ist an eine Operation gebunden oder nicht. Zweitens: Den eingebauten Doctrine-Provider zu dekorieren (ein häufiges 2.x-Muster für Tenant-Filterung) geht mit #[AsDecorator('api_platform.doctrine.orm.state.item_provider')], oder indem man den eingebauten ProviderInterface $itemProvider mit #[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')] injiziert und delegiert.

Schritt 4: Data Persister werden State Processor

// Vorher
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(); }
}

// Nachher: ein Processor, der die Persistenz an den Doctrine-Processor delegiert
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);
    }
}

Anbinden mit new Post(processor: OrderProcessor::class). Persister, die mehrere Klassen mit einem supports()-Switch bedienten, werden zu einem Processor pro Resource, was meist eine Verbesserung ist.

Schritt 5: Subresources werden URI-Templates

// Vorher: @ApiSubresource auf Customer::$orders → /customers/{id}/orders

// Nachher: eine Operation auf der Order-Resource mit einem Link
#[ApiResource(
    uriTemplate: '/customers/{customerId}/orders',
    uriVariables: ['customerId' => new Link(fromClass: Customer::class, toProperty: 'customer')],
    operations: [new GetCollection()],
)]
class Order {}

Das ist mehr Code als @ApiSubresource, entfernt aber die Magie: URL, Join und Operation stehen an einer Stelle.

Schritt 6: DTOs und Data Transformer

DataTransformerInterface ist weg. Input- und Output-DTOs werden an der Operation deklariert, und die Umwandlung passiert in Ihrem Provider (für Output) oder Processor (für 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;
    }
}

Schritt 7: der Rest der Checkliste

  • OpenAPI: api_platform.swagger-Schlüssel wurden zu api_platform.openapi; eigene OpenApiFactoryInterface-Dekoratoren funktionieren weiter, Normalizer-Dekoration im Stil von SwaggerDecorator nicht.
  • Security: security und securityPostDenormalize sind jetzt benannte Argumente der Operation; die Expressions selbst sind unverändert.
  • Pagination und Filter: paginationItemsPerPage, paginationClientEnabled und Co. sind benannte Argumente; Filter pro Operation sind new GetCollection(filters: [...]).
  • Event-Listener auf EventPriorities::PRE_WRITE usw. feuern in 3.x noch (hinter event_listeners_backward_compatibility_layer), sind aber das Nächste, was migriert wird; 4.x schaltet sie standardmäßig ab.
  • Tests: ApiTestCase ist unverändert; Fehlerantworten bekommen @type: hydra:Error-Details, und 3.x verschiebt Validierungsfehler in violations-Arrays unter denselben Schlüsseln. Snapshot-Tests müssen aktualisiert werden.

Schritt 8: Sprung auf 3.x

Mit dem Flag auf false, allen Resources konvertiert und ohne Deprecations im Testlauf ist der Sprung:

composer require api-platform/core:^3.4 --with-all-dependencies   # setzt Symfony 6.4 voraus

metadata_backward_compatibility_layer aus der Konfiguration entfernen, jeden überlebenden ApiPlatform\Core\-Import löschen, Tests ausführen. Dann weiter auf 4.x; 3.4 ist ein Zwischenstopp, kein Ziel.

Über wie viele Resources, Provider und Persister reden wir?

Das Assessment für €750 inventarisiert jede ApiResource, jeden Data Provider, Persister, Filter und jede Subresource in Ihrem Repository und liefert einen Festpreis für die Migration. Wird beim Upgrade vollständig verrechnet.

Mein Projekt scannen Assessment anfragen (€750)

Weitere Leitfäden