Sylius14 min

Migrer l'API Sylius de API Platform 2.7 vers 4 : Guide Technique Complet

Par Pierre-Arthur Demengel
SyliusAPI PlatformREST APIPHPMigrationHeadless

Le passage d'API Platform 2.7 a la version 4 est l'un des changements les plus impactants de Sylius 2.0, surtout si vous exploitez l'API pour un frontend headless, une application mobile ou des integrations tierces. Ce guide technique couvre chaque aspect de la migration : namespaces, providers, processors, serialisation, schema XML et strategies de retrocompatibilite.

Vue d'ensemble des changements

API Platform 4 (anciennement 3.x) est une reecriture architecturale majeure. Ce n'est pas une evolution incrementale mais un changement de paradigme dans la facon dont les ressources API sont definies et traitees.

Changements de namespaces

Les namespaces fondamentaux ont ete renommes pour refleter la nouvelle architecture :

API Platform 2.7API Platform 4
ApiPlatform\Core\DataProvider\*ApiPlatform\State\ProviderInterface
ApiPlatform\Core\DataPersister\*ApiPlatform\State\ProcessorInterface
ApiPlatform\Core\DataTransformer\*Supprime (absorbe par Provider/Processor)
ApiPlatform\Core\Bridge\Doctrine\*ApiPlatform\Doctrine\*
ApiPlatform\Core\Annotation\ApiResourceApiPlatform\Metadata\ApiResource
ApiPlatform\Core\Annotation\ApiFilterApiPlatform\Metadata\ApiFilter

Migration des DataProviders vers StateProviders

Dans API Platform 2.7, les DataProviders avaient deux variantes : CollectionDataProviderInterface (listes) et ItemDataProviderInterface (element unique). Dans la version 4, les deux sont unifies dans un seul ProviderInterface.

Avant : DataProvider API Platform 2.7

// src/DataProvider/ActiveProductCollectionDataProvider.php
namespace App\DataProvider;

use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use App\Entity\Product\Product;
use App\Repository\ProductRepositoryInterface;

class ActiveProductCollectionDataProvider implements
    CollectionDataProviderInterface,
    RestrictedDataProviderInterface
{
    public function __construct(
        private ProductRepositoryInterface $productRepository,
    ) {}

    public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
    {
        return Product::class === $resourceClass;
    }

    public function getCollection(string $resourceClass, string $operationName = null, array $context = []): iterable
    {
        return $this->productRepository->findActiveProducts();
    }
}

Apres : StateProvider API Platform 4

// src/State/Provider/ActiveProductProvider.php
namespace App\State\Provider;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\ProductRepositoryInterface;

/**
 * @implements ProviderInterface<Product>
 */
class ActiveProductProvider implements ProviderInterface
{
    public function __construct(
        private ProductRepositoryInterface $productRepository,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        if ($operation instanceof CollectionOperationInterface) {
            return $this->productRepository->findActiveProducts();
        }

        // Item operation
        return $this->productRepository->find($uriVariables['id']);
    }
}

Le provider est ensuite associe a la ressource via l'attribut PHP ou la configuration XML :

// src/Entity/Product/Product.php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Get;
use App\State\Provider\ActiveProductProvider;

#[ApiResource(
    operations: [
        new GetCollection(provider: ActiveProductProvider::class),
        new Get(provider: ActiveProductProvider::class),
    ]
)]
class Product
{
    // ...
}

Migration des DataPersisters vers StateProcessors

Meme logique de simplification pour les operations d'ecriture :

Avant : DataPersister API Platform 2.7

// src/DataPersister/OrderDataPersister.php
namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\DataPersisterInterface;
use App\Entity\Order\Order;

class OrderDataPersister implements DataPersisterInterface
{
    public function supports($data, array $context = []): bool
    {
        return $data instanceof Order;
    }

    public function persist($data, array $context = [])
    {
        // Logique custom avant persistance
        $data->setUpdatedAt(new \DateTimeImmutable());
        $this->entityManager->persist($data);
        $this->entityManager->flush();

        return $data;
    }

    public function remove($data, array $context = []): void
    {
        $this->entityManager->remove($data);
        $this->entityManager->flush();
    }
}

Apres : StateProcessor API Platform 4

// src/State/Processor/OrderProcessor.php
namespace App\State\Processor;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Order\Order;
use Doctrine\ORM\EntityManagerInterface;

/**
 * @implements ProcessorInterface<Order, Order|void>
 */
class OrderProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager,
    ) {}

    public function process(
        mixed $data,
        Operation $operation,
        array $uriVariables = [],
        array $context = [],
    ): Order|null {
        if ($operation instanceof DeleteOperationInterface) {
            $this->entityManager->remove($data);
            $this->entityManager->flush();
            return null;
        }

        // Logique custom avant persistance
        $data->setUpdatedAt(new \DateTimeImmutable());
        $this->entityManager->persist($data);
        $this->entityManager->flush();

        return $data;
    }
}

Suppression des DataTransformers

Les DataTransformers, utilises dans API Platform 2.7 pour convertir entre DTOs et entites, ont ete completement supprimes. Leur logique est absorbee par les StateProviders (lecture) et StateProcessors (ecriture).

Avant : DataTransformer pour un DTO de sortie

// API Platform 2.7 : DataTransformer pour convertir Order -> OrderOutput
namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\OrderOutput;
use App\Entity\Order\Order;

class OrderOutputDataTransformer implements DataTransformerInterface
{
    public function transform($object, string $to, array $context = []): OrderOutput
    {
        $output = new OrderOutput();
        $output->number = $object->getNumber();
        $output->total = $object->getTotal();
        $output->state = $object->getState();
        $output->itemCount = $object->countItems();
        return $output;
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return OrderOutput::class === $to && $data instanceof Order;
    }
}

Apres : Logique integree dans le StateProvider

// API Platform 4 : Le Provider retourne directement le DTO
namespace App\State\Provider;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Dto\OrderOutput;
use App\Repository\OrderRepositoryInterface;

class OrderOutputProvider implements ProviderInterface
{
    public function __construct(
        private OrderRepositoryInterface $orderRepository,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?OrderOutput
    {
        $order = $this->orderRepository->find($uriVariables['id']);
        if (null === $order) {
            return null;
        }

        $output = new OrderOutput();
        $output->number = $order->getNumber();
        $output->total = $order->getTotal();
        $output->state = $order->getState();
        $output->itemCount = $order->countItems();

        return $output;
    }
}

Prefixage des groupes de serialisation avec sylius:

Dans Sylius 2.0, tous les groupes de serialisation sont prefixes avec sylius: pour eviter les collisions avec les groupes custom des projets. C'est un breaking change qui affecte toute la couche API.

Changements concrets

# Avant (Sylius 1.x)
normalization_context:
    groups: ['shop:product:read', 'shop:product:index']
denormalization_context:
    groups: ['shop:product:write']

# Apres (Sylius 2.0)
normalization_context:
    groups: ['sylius:shop:product:read', 'sylius:shop:product:index']
denormalization_context:
    groups: ['sylius:shop:product:write']

Si vous avez des entites custom qui utilisent les groupes Sylius dans leurs attributs #[Groups], elles doivent toutes etre mises a jour :

// Avant
use Symfony\Component\Serializer\Annotation\Groups;

class Product
{
    #[Groups(['shop:product:read', 'admin:product:read'])]
    private string $name;
}

// Apres
class Product
{
    #[Groups(['sylius:shop:product:read', 'sylius:admin:product:read'])]
    private string $name;
}

Le Sylius Upgrade Analyzer detecte automatiquement chaque usage d'ancien groupe de serialisation dans votre code PHP, vos fichiers de configuration XML/YAML et vos annotations. Son auto-fixer dedie peut appliquer le renommage automatiquement :

vendor/bin/sylius-upgrade-analyzer sylius-upgrade:analyze --fix --dry-run

Migration du schema XML des ressources API

Si vous definissez vos ressources API via des fichiers XML (pratique courante dans les plugins Sylius), le schema a change de resources-2.0 a resources-3.0 :

Avant : Schema XML API Platform 2.7

<!-- config/api_platform/Product.xml -->
<?xml version="1.0" ?>
<resources xmlns="https://api-platform.com/schema/metadata/resources-2.0">
    <resource class="App\Entity\Product\Product" shortName="Product">
        <collectionOperations>
            <collectionOperation name="get">
                <attribute name="normalization_context">
                    <attribute name="groups">
                        <attribute>shop:product:read</attribute>
                    </attribute>
                </attribute>
            </collectionOperation>
            <collectionOperation name="post" />
        </collectionOperations>
        <itemOperations>
            <itemOperation name="get" />
            <itemOperation name="put" />
            <itemOperation name="delete" />
        </itemOperations>
    </resource>
</resources>

Apres : Schema XML API Platform 4 (resources-3.0)

<!-- config/api_platform/Product.xml -->
<?xml version="1.0" ?>
<resources xmlns="https://api-platform.com/schema/metadata/resources-3.0">
    <resource class="App\Entity\Product\Product" shortName="Product">
        <operations>
            <operation class="ApiPlatform\Metadata\GetCollection"
                       normalizationContext-groups="sylius:shop:product:read" />
            <operation class="ApiPlatform\Metadata\Post" />
            <operation class="ApiPlatform\Metadata\Get" />
            <operation class="ApiPlatform\Metadata\Put" />
            <operation class="ApiPlatform\Metadata\Patch" />
            <operation class="ApiPlatform\Metadata\Delete" />
        </operations>
    </resource>
</resources>

Points cles du nouveau schema :

  • Plus de distinction collectionOperations / itemOperations — tout est dans <operations>
  • Chaque operation a une class explicite (GetCollection, Get, Post, Put, Patch, Delete)
  • La methode PATCH est ajoutee par defaut (JSON Merge Patch)
  • Les groupes de serialisation utilisent le prefixe sylius:

Migration des endpoints custom

Si vous avez cree des endpoints API custom dans Sylius 1.x, la migration suit ce pattern :

Avant : Endpoint custom avec API Platform 2.7

// src/Entity/Product/Product.php (annotations API Platform 2.7)
use ApiPlatform\Core\Annotation\ApiResource;

#[ApiResource(
    collectionOperations: [
        'get',
        'post',
        'featured' => [
            'method' => 'GET',
            'path' => '/shop/products/featured',
            'controller' => FeaturedProductsController::class,
            'normalization_context' => ['groups' => ['shop:product:read']],
        ],
    ],
)]
class Product { /* ... */ }

Apres : Endpoint custom avec API Platform 4

// src/Entity/Product/Product.php (attributs API Platform 4)
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;

#[ApiResource(
    operations: [
        new GetCollection(),
        new Post(),
        new Get(),
        new GetCollection(
            uriTemplate: '/shop/products/featured',
            name: 'featured',
            provider: FeaturedProductProvider::class,
            normalizationContext: ['groups' => ['sylius:shop:product:read']],
        ),
    ],
)]
class Product { /* ... */ }

Le controller est remplace par un provider (pour les lectures) ou un processor (pour les ecritures). Si votre controller effectuait les deux (lecture + logique metier), scindez-le en un Provider et un Processor.

Migration des extensions de requetes (QueryExtensions)

Les signatures des extensions Doctrine ont egalement change :

Avant : API Platform 2.7

// src/Doctrine/Extension/ActiveProductExtension.php
namespace App\Doctrine\Extension;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;

class ActiveProductExtension implements QueryCollectionExtensionInterface
{
    public function applyToCollection(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        string $operationName = null,
    ): void {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    private function addWhere(QueryBuilder $qb, string $resourceClass): void
    {
        if (Product::class !== $resourceClass) {
            return;
        }
        $rootAlias = $qb->getRootAliases()[0];
        $qb->andWhere(sprintf('%s.enabled = :enabled', $rootAlias))
           ->setParameter('enabled', true);
    }
}

Apres : API Platform 4

// src/Doctrine/Extension/ActiveProductExtension.php
namespace App\Doctrine\Extension;

use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;

class ActiveProductExtension implements QueryCollectionExtensionInterface
{
    public function applyToCollection(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        Operation $operation = null,  // Operation au lieu de string $operationName
        array $context = [],
    ): void {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    private function addWhere(QueryBuilder $qb, string $resourceClass): void
    {
        if (Product::class !== $resourceClass) {
            return;
        }
        $rootAlias = $qb->getRootAliases()[0];
        $qb->andWhere(sprintf('%s.enabled = :enabled', $rootAlias))
           ->setParameter('enabled', true);
    }
}

Le changement cle : le parametre string $operationName est remplace par Operation $operation, un objet riche qui contient toutes les metadonnees de l'operation (classe, methode HTTP, groupes de serialisation, etc.).

Tester la migration API

Les tests API doivent couvrir trois aspects : la structure des reponses, les groupes de serialisation, et les endpoints custom. Utilisez le client API Platform integre :

// tests/Api/ProductApiTest.php
namespace App\Tests\Api;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Product\Product;

class ProductApiTest extends ApiTestCase
{
    public function testGetProductCollection(): void
    {
        $response = static::createClient()->request('GET', '/api/v2/shop/products');

        $this->assertResponseIsSuccessful();
        $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
        $this->assertJsonContains([
            '@context' => '/api/v2/contexts/Product',
            '@type' => 'hydra:Collection',
        ]);
    }

    public function testSerializationGroupsApplied(): void
    {
        $response = static::createClient()->request('GET', '/api/v2/shop/products');

        $data = $response->toArray();
        // Verifier que les champs admin ne sont PAS exposes en shop
        foreach ($data['hydra:member'] as $product) {
            $this->assertArrayNotHasKey('internalCode', $product);
            $this->assertArrayHasKey('name', $product);
            $this->assertArrayHasKey('slug', $product);
        }
    }

    public function testCustomFeaturedEndpoint(): void
    {
        $response = static::createClient()->request('GET', '/api/v2/shop/products/featured');

        $this->assertResponseIsSuccessful();
        $data = $response->toArray();
        foreach ($data['hydra:member'] as $product) {
            $this->assertTrue($product['featured']);
        }
    }
}

Strategies de retrocompatibilite pour les clients headless

Si vous avez un frontend headless (Next.js, Nuxt, React Native) qui consomme l'API Sylius, vous ne pouvez pas tout casser d'un coup. Voici trois strategies :

1. API versioning avec routes prefixees

# config/routes/api_platform.yaml
api_platform_v1:
    resource: .
    type: api_platform
    prefix: /api/v1

api_platform_v2:
    resource: .
    type: api_platform
    prefix: /api/v2

2. Decorateur de StateProvider pour compatibilite arriere

// src/State/Provider/BackwardCompatibleProductProvider.php
namespace App\State\Provider;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;

class BackwardCompatibleProductProvider implements ProviderInterface
{
    public function __construct(
        private ProviderInterface $decorated,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        $result = $this->decorated->provide($operation, $uriVariables, $context);

        // Si un client legacy est detecte (header custom ou query param)
        if (isset($context['request']) && $context['request']->headers->has('X-Api-Version')) {
            $version = $context['request']->headers->get('X-Api-Version');
            if ($version === '1') {
                return $this->transformToLegacyFormat($result);
            }
        }

        return $result;
    }

    private function transformToLegacyFormat(mixed $result): mixed
    {
        // Transformer la reponse v2 en format v1
        // ...
        return $result;
    }
}

3. Middleware Symfony pour transformation des reponses

// src/EventSubscriber/LegacyApiResponseSubscriber.php
namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class LegacyApiResponseSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::RESPONSE => ['onKernelResponse', -10],
        ];
    }

    public function onKernelResponse(ResponseEvent $event): void
    {
        $request = $event->getRequest();

        if (!str_starts_with($request->getPathInfo(), '/api/')) {
            return;
        }

        // Ajouter des headers de depreciation pour guider les clients
        $response = $event->getResponse();
        $response->headers->set('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
        $response->headers->set('Deprecation', 'true');
        $response->headers->set('Link', '</api/v2/docs>; rel="successor-version"');
    }
}

Checklist de migration API

  1. Recenser tous les DataProviders, DataPersisters et DataTransformers custom
  2. Migrer les DataProviders vers des StateProviders
  3. Migrer les DataPersisters vers des StateProcessors
  4. Integrer la logique des DataTransformers dans les Providers/Processors
  5. Mettre a jour les namespaces ApiPlatform\Core\* vers ApiPlatform\Metadata\* et ApiPlatform\Doctrine\*
  6. Convertir les fichiers XML de resources-2.0 a resources-3.0
  7. Prefixer tous les groupes de serialisation avec sylius:
  8. Mettre a jour les signatures des QueryExtensions (Operation au lieu de string)
  9. Migrer les endpoints custom (controller vers provider/processor)
  10. Ecrire des tests API pour chaque endpoint
  11. Mettre en place une strategie de retrocompatibilite si necessaire

Le Sylius Upgrade Analyzer identifie automatiquement chacun de ces points dans votre codebase. Lancez vendor/bin/sylius-upgrade-analyzer sylius-upgrade:analyze pour obtenir un rapport detaille des changements API necessaires, avec estimation du temps de migration par item.

Questions fréquentes

13 projets livresGrand-Est & BelgiqueLighthouse >90Disponible immédiatement

Un projet en tete ?

Discutons de votre site web. Réponse garantie sous 24h.

Ou appelez directement :06 95 41 30 25

WhatsApp
Appeler