Mis à jour le 25 min de lecture

Guide API REST avec Symfony : versions, contrat OpenAPI, erreurs, webhooks et RGPD

Un guide pratique pour concevoir une API REST Symfony durable : versionnement, contrat OpenAPI, erreurs RFC 9457, webhooks idempotents et minimisation des données personnelles.

  • API REST
  • Symfony
  • OpenAPI
  • RGPD
Schéma : clients et versions d’API SPA Mobile Partenaire API /api/v1 … v2 Compatibilité & déploiement

Versionner l’API : URL, en-têtes et déploiement

Deux familles dominantes : version dans l’URL (/api/v1/…) et négociation par en-têtes (Accept / vendor media type). Le bon choix dépend surtout de vos caches HTTP, de vos générateurs de clients et de la maturité de vos consommateurs.

Version dans le chemin (URI)

Exemple : /api/v1/invoices, /api/v2/invoices. Avantages : lisible dans les logs, facile à routager dans Symfony (prefix / attributs de route), compatible avec des règles de cache CDN très explicites. Inconvénient : les URL « vivent » longtemps — il faut une politique de décommission claire.

# config/routes.yaml (extrait illustratif)
api_v1:
    resource: ../src/Controller/Api/V1/
    type: attribute
    prefix: /api/v1

api_v2: resource: ../src/Controller/Api/V2/ type: attribute prefix: /api/v2

Version par en-tête ou media type

Exemple : Accept: application/vnd.example.v2+json. Utile quand vous voulez une URL stable et que vos clients savent configurer des en-têtes (peu fréquent côté navigateur pur, plus courant serveur-à-serveur). Symfony peut discriminer via Request::getPreferredFormat() ou des RequestMatcher — mais la documentation OpenAPI doit être irréprochable sinon les intégrations dérivent.

Ce que les proxies et caches voient

Si une ressource change de forme sans changer d’URL ni d’en-tête de variance, vous risquez des réponses mises en cache incohérentes. Avec URI versionnée, la clé de cache est naturellement séparée. Avec Accept, configurez Vary: Accept et vérifiez le comportement de votre CDN.

Contrôleur : garder le métier partagé

Anti-pattern fréquent : dupliquer 80 % de la logique entre V1 et V2. Mieux : services applicatifs stables, adapters minces par version pour la forme de la réponse :

// Pseudo-code : même service, représentation différente.
final class InvoiceController {
    public function __construct(private InvoiceRepository $repo) {}
#[Route('/invoices/{id}', methods: ['GET'])]
public function showV1(string $id, InvoiceV1Normalizer $n): JsonResponse {
    return new JsonResponse($n->normalize($this->repo->get(Uuid::fromString($id))));
}

}

OpenAPI comme contrat : aligner back, front et QA

OpenAPI n’est pas qu’une doc : c’est le contrat entre back-end, front-end, partenaires et tests. Une spec à jour (même partielle mais versionnée) réduit les allers-retours et les régressions silencieuses.

Ce que le contrat doit figer

  • Chemins, méthodes, codes de réponse et schémas JSON (requête / réponse).
  • Authentification (Bearer, clé API, cookies) et en-têtes obligatoires.
  • Règles de pagination, filtres et formats d’erreur (ex. Problem Details).

Workflow réaliste

Design first : on écrit ou met à jour la spec avant d’implémenter le endpoint critique. Code first : on génère la spec depuis les attributs PHP — utile si l’équipe tient la discipline des annotations. L’essentiel est une source de vérité unique référencée en revue de code.

Clients et mocks

À partir d’OpenAPI 3, on peut générer des clients TypeScript ou des stubs serveur pour les tests d’intégration. Les mocks permettent au front d’avancer pendant que le back finalise — à condition que le contrat soit validé (PR sur le YAML/YML ou JSON).

Casser le contrat sans surprise

  • Versionner l’API (URL /v2 ou négociation documentée).
  • Marquer les champs dépréciés dans la spec avant retrait.
  • Ajouter des tests de non-régression sur le schéma (diff OpenAPI en CI ou contrats consumer-driven).

Des erreurs lisibles et stables avec la RFC 9457 (Problem Details)

Les clients d’API ont besoin d’erreurs prévisibles : code HTTP + corps JSON structuré. La RFC 9457 (Problem Details) formalise application/problem+json avec au minimum type, title et status. Sous Symfony, on mappe exceptions → payload stable, sans exposer la pile technique.

Pourquoi ne pas renvoyer du JSON « maison » à chaque fois ?

Sans convention, chaque endpoint invente ses clés (error, message, errors[]…). Les SDK et les intégrations deviennent fragiles. Problem Details donne un contrat minimal reconnu par les outils et les humains.

Champs utiles en pratique

  • type : URI (souvent sous votre domaine) identifiant la classe d’erreur — stable dans le temps.
  • title : libellé court, lisible ; peut être localisé si vous versionnez l’API.
  • status : redondance explicite avec le code HTTP (422, 404, 409…).
  • detail : message contextuel (éviter les données sensibles ; préférer un identifiant de corrélation).
  • instance : URI de la requête fautive ou id de trace pour le support.

Exemple de corps 422 (validation)

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Validation Failed",
  "status": 422,
  "detail": "One or more fields are invalid.",
  "instance": "/v1/orders",
  "errors": [
    { "field": "email", "code": "invalid_format" }
  ]
}

Le tableau errors est une extension courante (non obligatoire dans la RFC) : documentez-la dans votre spec OpenAPI.

Symfony : JsonResponse typée

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

$body = [ ‘type’ => ‘https://api.example.com/problems/not-found’, ‘title’ => ‘Resource Not Found’, ‘status’ => Response::HTTP_NOT_FOUND, ‘detail’ => ‘No order matches this id.’, ‘instance’ => $request->getRequestUri(), ];

return new JsonResponse( $body, Response::HTTP_NOT_FOUND, [‘Content-Type’ => ‘application/problem+json’] );

Listener d’exception

Centralisez la traduction ValidationFailedException → 422, EntityNotFound → 404, erreurs métier → 409, etc. Gardez un seul format de sortie pour toute l’API.

Webhooks et idempotence : éviter les doublons

Les fournisseurs redélivrent : réseau, timeouts, bugs côté émetteur. Votre endpoint doit être idempotent : rejouer le même événement ne doit pas dupliquer l’effet métier. La combinaison Idempotency-Key + stockage minimal résout 90 % des cas sans Kafka.

Contrat HTTP minimal

  • Répondre vite (2xx) si la charge est lourde — traitez en asynchrone derrière une file.
  • Valider la signature / secret partagé avant toute écriture métier.
  • Journaliser le corps brut (hash + identifiant) pour le support, pas nécessairement le PII complet.

Idempotence : schéma de table minimal

Exemple SQL (MySQL / MariaDB) : une ligne par clé vue, statut du traitement.

CREATE TABLE webhook_inbox (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  provider VARCHAR(32) NOT NULL,
  idempotency_key VARCHAR(128) NOT NULL,
  payload_hash CHAR(64) NOT NULL,
  status ENUM('received','processed','failed') NOT NULL DEFAULT 'received',
  created_at DATETIME NOT NULL,
  UNIQUE KEY uq_provider_key (provider, idempotency_key)
);

Flux PHP (lecture rapide)

$key = $request->headers->get('Idempotency-Key') ?? $payload['event_id'] ?? null;
if (!$key) {
    return new JsonResponse(['title' => 'Missing idempotency key'], 400);
}

$hash = hash(‘sha256’, $request->getContent()); $row = $db->findInboxRow($provider, $key);

if ($row && $row[‘payload_hash’] !== $hash) { return new JsonResponse([‘title’ => ‘Conflict: same key, different body’], 409); }

if ($row && $row[‘status’] === ‘processed’) { return new JsonResponse([‘status’ => ‘duplicate_ignored’], 200); }

$db->upsertInbox($provider, $key, $hash); $bus->dispatch(new ProcessWebhook($provider, $key)); return new JsonResponse([‘status’ => ‘accepted’], 202);

Retries et ordre

Si les événements doivent être ordonnés par ressource, incluez un sequence ou un horodatage fiable dans le payload et ignorez les doublons / replays obsolètes via comparaison monotone (par compte, par agrégat, etc.).

Données personnelles : minimisation, journaux et rétention (RGPD)

Une API qui renvoie « tout le modèle » multiplie les risques : fuite, journalisation abusive, durée de conservation floue. La minimisation (champs strictement nécessaires + séparation interne / public) est le levier le plus rentable avant les audits lourds.

DTO de sortie vs entité Doctrine

L’entité peut contenir email, téléphone, métadonnées internes. Le JSON public doit passer par un DTO ou serializer dédié qui n’expose que les champs prévus par le contrat (OpenAPI).

Journaux et traçabilité

  • Ne pas logger les corps de requête contenant mot de passe, token ou données de santé en clair.
  • Préférer un identifiant technique (UUID utilisateur) aux emails dans les messages d’erreur.
  • Durée de rétention des logs alignée sur la politique interne et le registre des traitements.

Exemple PHP : projection minimale

final class PublicUserView
{
    public function __construct(
        public readonly string $id,
        public readonly string $displayName,
    ) {}
}

// Ne jamais sérialiser User $entity directement en JSON public.

Documentation interne

Listez les endpoints qui traitent des données personnelles, la base légale (contrat, obligation, intérêt légitime…), les sous-traitants (hébergeur, emailing) et les transferts hors UE. Cela aide le DPO et accélère les réponses aux personnes concernées.

Besoin d’un renfort pour concevoir ou reprendre une API ? Voir développeur Symfony freelance et refonte et maintenance d’application web.

FAQ

Faut-il supporter V1 indéfiniment ?

Non : fixez une date ou une release de fin, communiquez-la dans la doc et renvoyez des en-têtes Deprecation / Sunset lorsque pertinent pour les clients API.

GraphQL remplace-t-il le versionnement REST ?

Ce sont des compromis différents : GraphQL déplace la complexité (schéma, N+1, auth par champ). Ce n’est pas un raccourci magique pour éviter le contrat et la compatibilité.

OpenAPI remplace-t-il les tests fonctionnels ?

Non : il garantit la forme des échanges, pas la logique métier. Il complète les tests E2E et les scénarios métier.

Spec trop grosse à maintenir ?

Découpez par domaine (paths/orders.yaml + components/schemas) et assemblez avec des outils de merge ; ou limitez la spec publique aux surfaces externes et gardez l’interne documentée autrement.

Faut-il toujours utiliser application/problem+json ?

Recommandé pour les erreurs ; pour les réponses 2xx vous gardez votre schéma métier. L’important est la cohérence des erreurs sur tout le périmètre API.

Et les erreurs Symfony Validator en tableau ?

Normalisez-les dans une extension (violations ou errors) avec codes stables, pas seulement les messages traduits — les clients s’appuient sur les codes.

202 vs 200 ?

202 Accepted si le travail est poussé en asynchrone ; 200 si tout est traité dans la requête. Les deux sont valides ; soyez cohérents et documentez-le.

Et sans idempotency key du fournisseur ?

Dérivez une clé stable du payload (event_id + type) après validation de schéma ; refusez les événements sans identifiant stable — c’est un signal de mauvaise conception côté émetteur.

Le RGPD impose-t-il un format d’API précis ?

Non : il impose des principes (minimisation, sécurité, transparence). La mise en œuvre technique reste à vous, mais les mauvaises pratiques d’exposition et de logs sont souvent relevées en contrôle.

Anonymisation vs pseudonymisation ?

La pseudonymisation réduit le risque mais les données restent personnelles si la ré-identification est possible avec d’autres jeux. L’anonymisation est irréversible — plus rare en API métier vivante.