En API Platform 4, un contrôleur custom qui renvoie une entité n’est plus forcément sérialisé ni validé comme en 3.x. Sur le back Symfony d’un SaaS d’abonnements, la montée d’API Platform 3.4.17 à 4.3.3 a cassé trois contrôleurs de ce type, dont celui qui active un abonnement. La correction : valider explicitement avec le validator Symfony, lever la ValidationException d’API Platform, et renvoyer une JsonResponse sérialisée en JSON-LD. Le reste de la migration s’est fait en quatre lots, avec 935 tests et 2 874 assertions identiques avant et après.
Une migration en lots, compatible 3.4 d’abord
PHP 8.4, Symfony 7.4, Doctrine ORM 3.6.3 : la base était à jour, State Processors partout, couche de compatibilité déjà désactivée. J’ai découpé la migration en lots, chacun mergé avec les tests verts :
- Configuration, encore en 3.4 :
use_symfony_listeners: true,serializer.hydra_prefix: truepour figer explicitement le comportement 3.x, suppression deevent_listeners_backward_compatibility_layeretkeep_legacy_inflector, namespacesApiPlatform\Core\*remplacés dansexception_to_status. - Composer :
api-platform/core ^3.4remplacé parapi-platform/symfonyetapi-platform/doctrine-orm ^4.3. - Front : le format Hydra sans préfixe.
- Filtres :
#[ApiFilter]versQueryParameter.
L’audit préalable avait listé les risques connus. La migration en a révélé cinq autres :
| Changement | Anticipé ? | Correctif |
|---|---|---|
ApiPlatformBundle absent de bundles.php | non | ajouté : les packages séparés ne l’enregistrent plus |
openapiContext | non | openapi: new Operation(...), 4 occurrences |
NotFoundAction | non | ApiPlatform\Action → ApiPlatform\Symfony\Action, 17 entités |
ValidationException dans src/ | non | ApiPlatform\Symfony\Validator → ApiPlatform\Validator, 6 fichiers |
| contrôleurs qui renvoient une entité | non | JsonResponse + validation explicite, 3 contrôleurs |
Fausse piste au passage : le plan affirmait que SerializerContextBuilderInterface gardait son namespace. Faux, il passe de ApiPlatform\Serializer à ApiPlatform\State.
Pourquoi un contrôleur custom n’est plus sérialisé ni validé en API Platform 4 ?
L’opération d’activation est un Post avec un contrôleur maison, deserialize: false et un groupe de validation full. En 3.4, le contrôleur renvoyait l’entité et API Platform faisait le reste : validation avec les groupes de l’opération, sérialisation JSON-LD avec son normalizationContext.
public function __invoke(Subscription $subscription): Subscription
{
if (!$subscription->isSimpleWorkflow()) {
$this->subscriptionPdfGenerator->saveSubscriptionAsPdf($subscription);
}
$this->subscriptionService->unDraft($subscription);
$this->entityManager->flush();
return $subscription;
}
Après la montée en 4.3, ce retour d’entité n’était plus pris en charge par la chaîne d’API Platform : ni sérialisation automatique, ni validation de l’entité. Le test fonctionnel qui active un abonnement sans client et attend un 422 est exactement le genre de filet qui rend ce changement visible.
En API Platform 4, un contrôleur custom qui renvoie une entité ne peut plus compter sur la validation et la sérialisation implicites : il doit les faire lui-même.
Le correctif : valider et sérialiser explicitement
use ApiPlatform\Validator\Exception\ValidationException;
public function __invoke(Subscription $subscription): JsonResponse
{
$violations = $this->validator->validate($subscription, null, ['full']);
if ($violations->count() > 0) {
throw new ValidationException($violations);
}
if (!$subscription->isSimpleWorkflow()) {
$this->subscriptionPdfGenerator->saveSubscriptionAsPdf($subscription);
}
$this->subscriptionService->unDraft($subscription);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($subscription, 'jsonld'),
JsonResponse::HTTP_CREATED,
[],
true,
);
}
La ValidationException d’API Platform reste mappée sur 422 dans exception_to_status, donc le format d’erreur ne change pas pour le front. La validation se fait désormais avant tout effet de bord : génération du PDF du contrat d’abonnement, flush(). Ce PDF est d’ailleurs celui qui m’a valu une EntityNotFoundException dans un listener postRemove.
⚠️ Le normalizationContext de l’opération ne s’applique plus non plus. Le contrôleur qui renvoie l’utilisateur courant passe ses groupes à la main : ['groups' => ['user:get', 'user:current', 'address:get', 'data_source:get']].
Hydra sans préfixe et QueryParameter
En 4.x, les clés JSON-LD perdent le préfixe hydra: : member et totalItems au lieu de hydra:member et hydra:totalItems, @type vaut Collection. Quatre fichiers de tests back ont suivi. Côté front React, les clés étaient centralisées dans un enum, la correction tient en deux lignes :
export enum HydraProps {
HYDRA_MEMBER = 'member', // était 'hydra:member'
HYDRA_TOTAL_ITEMS = 'totalItems', // était 'hydra:totalItems'
}
Dernier lot, optionnel : les cinq entités en #[ApiFilter] passent en QueryParameter dans #[ApiResource] :
parameters: [
'subscriptions' => new QueryParameter(filter: new IriFilter(), property: 'subscriptions'),
'type.code' => new QueryParameter(filter: new ExactFilter(), property: 'type.code'),
'name' => new QueryParameter(filter: new PartialSearchFilter(), property: 'name'),
],
Lessons learned
- Un contrôleur custom API Platform 4 doit valider et sérialiser lui-même. Renvoyer une
JsonResponseconstruite avec le serializer enjsonldrend le comportement explicite et indépendant des listeners. - La validation explicite se place avant les effets de bord. Valider au début du contrôleur évite de générer un PDF ou de flusher un objet invalide.
- Un lot de configuration compatible 3.4 réduit la marche.
hydra_prefix,use_symfony_listenerset les namespaces se corrigent avant de toucher à Composer. - Un audit de migration ne voit pas tout. Les contrôleurs custom,
NotFoundAction,openapiContextetbundles.phpn’étaient pas dans le plan, d’où l’intérêt de lots courts et d’une suite de tests complète. - Centraliser les clés d’un format d’API côté front limite le coût d’un changement de format. La suppression du préfixe Hydra a coûté deux lignes.