GraphQL : une alternative puissante au REST
GraphQL permet aux clients de demander exactement les données dont ils ont besoin. Dans des projets comme ceux de Matalto et Manymore, cette flexibilité a permis de réduire considérablement le nombre de requêtes API.
Avec une API REST classique, chaque ressource a son URL et le serveur décide de la forme de la réponse. Un écran qui affiche un produit, sa catégorie et ses avis déclenche souvent trois appels, et chacun renvoie des champs dont l'interface n'a pas besoin. GraphQL inverse la logique : le serveur publie un schéma typé qui décrit tout ce qui est disponible, et le client envoie une requête qui décrit précisément la forme des données attendues. Il n'y a en général qu'un seul point d'entrée, appelé en POST, et la réponse JSON reproduit exactement la structure de la requête.
En PHP, la référence est la bibliothèque webonyx/graphql-php, qui implémente la spécification. Dans une application Symfony, on l'utilise rarement directement : overblog/graphql-bundle l'intègre au framework (configuration YAML ou attributs, conteneur de services, sécurité), et API Platform peut aussi exposer un point d'entrée GraphQL à partir des mêmes ressources que l'API REST. Cet article utilise OverblogGraphQLBundle.
Installation avec Symfony
composer require overblog/graphql-bundle
composer require overblog/graphiql-bundle --dev
Le premier paquet fournit le point d'entrée GraphQL et le moteur d'exécution. Le second ajoute GraphiQL, un IDE dans le navigateur qui propose l'autocomplétion à partir du schéma : très pratique en développement, mais à ne jamais déployer en production, d'où l'option --dev. La configuration du bundle indique où trouver les types et quel type sert de racine aux requêtes :
# config/packages/graphql.yaml
overblog_graphql:
definitions:
schema:
query: Query
mappings:
types:
- type: yaml
dir: "%kernel.project_dir%/config/graphql/types"
security:
query_max_depth: 10
query_max_complexity: 1000
enable_introspection: '%kernel.debug%'
La section security est importante et souvent oubliée ; nous y reviendrons plus bas.
Définir un schéma
Le schéma est le contrat entre le serveur et ses clients. Chaque type objet déclare ses champs, leur type et, si nécessaire, la façon de les résoudre. Le point d'exclamation signifie « non nul » : String! garantit au client que le champ aura toujours une valeur.
# config/graphql/types/Product.types.yaml
Product:
type: object
config:
fields:
id:
type: "ID!"
name:
type: "String!"
price:
type: "Float!"
category:
type: "Category"
resolve: "@=resolver('product_category', [value])"
Les champs simples (id, name, price) sont lus automatiquement sur l'objet PHP via ses getters ou ses propriétés publiques. Le champ category, lui, délègue à un resolver nommé, en lui passant l'objet parent (value). Il faut ensuite un type racine Query, qui définit les points d'entrée des lectures et leurs arguments :
# config/graphql/types/Query.types.yaml
Query:
type: object
config:
fields:
product:
type: "Product"
args:
id:
type: "ID!"
resolve: "@=resolver('products', [args])"
products:
type: "[Product!]!"
args:
first:
type: "Int"
defaultValue: 10
resolve: "@=resolver('products', [args])"
Resolver
Un resolver est un service Symfony classique : il reçoit les arguments de la requête (ou l'objet parent) et renvoie les données. Comme tout service, il bénéficie de l'injection de dépendances :
class ProductResolver implements ResolverInterface
{
public function __construct(
private ProductRepository $repository,
) {}
public function resolve(Argument $args): Product|array|null
{
if (isset($args['id'])) {
return $this->repository->find($args['id']);
}
return $this->repository->findBy([], ['id' => 'ASC'], $args['first'] ?? 10);
}
}
class ProductCategoryResolver implements ResolverInterface
{
public function resolve(Product $product): Category
{
return $product->getCategory();
}
}
Pour que les noms products et product_category utilisés dans le schéma pointent vers ces classes, déclarez-les comme alias, par exemple en implémentant AliasedInterface et sa méthode statique getAliases(). Notez aussi que ces exemples utilisent la nomenclature historique du bundle : dans les versions récentes, ResolverInterface et la fonction d'expression resolver() ont été renommées QueryInterface et query(). Vérifiez la documentation de la version installée. Pour les écritures, le principe est identique avec un type racine Mutation et des classes qui implémentent MutationInterface.
Plafonnez toujours le nombre d'éléments renvoyés (ici avec first), et passez à la pagination par curseur (le modèle « Connection » de Relay, pris en charge par le bundle) dès que les listes deviennent longues.
Requête GraphQL
query {
products(first: 10) {
id
name
price
category {
name
}
}
}
La réponse contient exactement ces champs, imbriqués de la même façon, sous une clé data. En pratique, les clients utilisent des requêtes nommées avec des variables plutôt que des valeurs écrites en dur. Cela facilite le cache côté client et évite toute concaténation de chaînes :
query ProductDetail($id: ID!) {
product(id: $id) {
id
name
price
category {
name
}
}
}
Côté HTTP, la requête et ses variables partent en JSON dans le corps d'un POST (l'URL exacte dépend de la configuration des routes du bundle) :
curl -X POST https://api.example.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query": "query ProductDetail($id: ID!) { product(id: $id) { name price } }", "variables": {"id": "42"}}'
Le piège du N+1
La souplesse de GraphQL a un coût caché. Pour la requête products(first: 10) ci-dessus, le resolver de catégorie est appelé une fois par produit : une requête SQL pour la liste, puis dix requêtes pour les catégories si la relation est chargée paresseusement. Sur des listes imbriquées, cela explose rapidement. Deux solutions complémentaires existent :
- Charger les relations les plus demandées directement dans le repository, avec une jointure et un
addSelect. - Utiliser le pattern DataLoader (via
overblog/dataloader-bundle) : les identifiants demandés pendant l'exécution sont collectés, puis chargés en une seule requête groupée.
Surveillez le nombre de requêtes SQL par opération GraphQL dans le profiler Symfony : c'est l'indicateur le plus fiable.
Sécuriser une API GraphQL
Un point d'entrée qui accepte des requêtes arbitraires doit se protéger contre les abus. Un client malveillant peut envoyer une requête très profonde ou très large qui épuise le serveur. Les réglages query_max_depth et query_max_complexity de la configuration rejettent ces requêtes avant exécution. L'introspection, qui permet à n'importe qui de télécharger le schéma complet, est ici limitée au mode debug. Enfin, l'autorisation se déclare champ par champ :
# Extrait d'un type : champ réservé aux administrateurs
Product:
type: object
config:
fields:
purchasePrice:
type: "Float"
access: "@=hasRole('ROLE_ADMIN')"
Autre différence avec REST : une requête GraphQL renvoie généralement un statut HTTP 200 même en cas d'erreur, les erreurs étant listées dans la clé errors de la réponse. Votre monitoring doit donc analyser le contenu des réponses, pas seulement les codes HTTP.
Avantages vs REST
- Pas de sur-fetching : le client choisit les champs
- Pas de sous-fetching : une seule requête pour des données liées
- Typage fort : schéma auto-documenté
- Évolution facile : ajout de champs sans casser les clients existants
Pour faire évoluer le schéma sans versionner l'API, ajoutez des champs plutôt que d'en modifier, et marquez les anciens avec deprecationReason avant de les supprimer une fois que plus aucun client ne les utilise.
Quand rester sur REST
GraphQL n'est pas un remplaçant universel. Le cache HTTP standard (CDN, reverse proxy) fonctionne mal avec des requêtes POST toutes adressées à la même URL. Les téléversements de fichiers demandent une extension de la spécification. Et pour une API publique simple, consommée par des tiers, une API REST documentée en OpenAPI reste plus familière. GraphQL brille surtout quand plusieurs clients (web, mobile) ont des besoins différents sur un modèle de données riche et fortement relié.
En résumé : définissez un schéma clair, plafonnez les listes, traquez le N+1 dès les premières requêtes, limitez la profondeur et la complexité, et désactivez l'introspection et GraphiQL en production.