Optimiser la vitesse d'une API PHP/Symfony : sept leviers classés par impact
Une API lente tue l'adoption
Amazon a publié en 2006 un benchmark devenu classique : 100 ms de latence supplémentaire coûtent 1% de conversion. Google a confirmé des chiffres comparables sur la recherche. Sur une API B2B facturée à l'usage, l'effet est tout aussi brutal : au-delà de 500 ms au p95, les clients intègrent un cache côté leur pour vous contourner, votre valeur perçue s'effondre.
Budget de latence que nous engageons contractuellement sur les APIs que nous livrons :
- p50 sous 80 ms.
- p95 sous 200 ms.
- p99 sous 500 ms.
- taux d'erreur 5xx sous 0,1%.
Ces chiffres ne viennent pas du ciel : ils correspondent à ce qu'une architecture Symfony 7 + PostgreSQL + Redis peut tenir sans acrobatie, sous réserve d'appliquer sept leviers classés par rapport impact sur effort.
Les sept leviers
Ordre d'application que nous respectons sur les missions de tuning.
| # | Levier | Effort | Impact latence typique |
|---|---|---|---|
| 1 | Détecter et corriger les N+1 | Faible | -50 à -80% |
| 2 | Choisir le bon serializer | Faible | -15 à -40% |
| 3 | Cache HTTP (ETag, Cache-Control, Varnish) | Moyen | -70 à -95% sur hit |
| 4 | Cache applicatif Redis | Moyen | -60 à -90% sur hit |
| 5 | Pagination efficace (cursor) | Moyen | -50% sur gros offsets |
| 6 | Compression Brotli | Faible | -20 à -30% de poids payload |
| 7 | HTTP/2 et HTTP/3 | Faible à moyen | -10 à -30% selon latence réseau |
Le gain composé de ces sept leviers sur une API typique : passage de 800 ms à 80 ms au p95. Cas réel sur une mission de 2025, mesuré par Blackfire et Datadog avant/après.
1. Détecter le N+1
Le N+1 est le problème numéro un des APIs Doctrine et Eloquent. Le code ressemble à ceci.
// Mauvais : 1 + N requêtes
$invoices = $entityManager->getRepository(Invoice::class)->findAll();
foreach ($invoices as $invoice) {
$customer = $invoice->getCustomer(); // requête supplémentaire par facture
}
Sur 500 factures, ce code fait 501 requêtes SQL. Chacune coûte un round-trip réseau. Même en local, on paie 5 à 50 ms par requête. La solution est de charger les relations en même temps.
// Bon : 1 requête
$invoices = $entityManager->createQueryBuilder()
->select('i', 'c')
->from(Invoice::class, 'i')
->leftJoin('i.customer', 'c')
->getQuery()
->getResult();
Le profiler Symfony (barre de debug en dev) compte les requêtes SQL par page. Règle que nous imposons dans nos CI : pas plus de 5 requêtes SQL pour un endpoint de liste, pas plus de 10 pour un endpoint agrégé complexe. Au-delà, doctrine/doctrine-bundle rapporte un problème.
Pour détecter automatiquement les N+1 en CI, nous utilisons un assertion Blackfire.
blackfire run --samples=3 \
--assert='metrics.sql.queries.count <= 5' \
php bin/phpunit tests/Api/InvoiceListTest.php
2. Choisir le bon serializer
La sérialisation JSON consomme facilement 30% du temps CPU d'une API PHP. Sur un endpoint de liste avec 100 objets imbriqués, le Symfony Serializer configuré par défaut peut passer 150 ms à construire la réponse. Plusieurs alternatives existent.
Benchmark que nous avons mené sur une entité Invoice avec 5 relations et 100 objets par réponse (PHP 8.3, Symfony 7.2, moyenne sur 1000 requêtes).
| Serializer | Temps moyen | Relatif |
|---|---|---|
| Symfony Serializer (normalizers, groups) | 148 ms | 100% |
| JMS Serializer | 112 ms | 76% |
| API Platform Serializer (groupes optimisés) | 68 ms | 46% |
Symfony Serializer + json_encode direct sur DTO |
12 ms | 8% |
La dernière ligne est décisive. Passer par un DTO de projection pur (un objet avec des propriétés publiques typées, sans réflexion, sans groupes, sans metadata) et appeler json_encode directement est 12 fois plus rapide que les groupes Symfony classiques. Nous généralisons ce pattern sur les endpoints à forte charge.
// DTO de projection
final readonly class InvoiceListItemDto
{
public function __construct(
public int $id,
public string $number,
public string $customerName,
public int $amountCents,
public string $currency,
public string $issuedAt,
) {}
}
// Controller
#[Route('/api/invoices', methods: ['GET'])]
public function list(InvoiceRepository $repo): JsonResponse
{
// Projection native via QueryBuilder avec SELECT NEW
$dtos = $repo->listProjections(page: 1, limit: 20);
return new JsonResponse($dtos);
}
Le SELECT NEW de Doctrine construit directement les DTO à partir du SQL, sans hydratation d'entités, sans UnitOfWork.
3. Cache HTTP : la meilleure latence est celle qu'on évite
Une requête qui n'arrive pas au serveur est infiniment plus rapide qu'une requête optimisée. Le cache HTTP est le levier le plus rentable pour les endpoints à lecture dominante.
ETag et If-None-Match
Symfony gère les ETag nativement.
use Symfony\Component\HttpFoundation\Response;
#[Route('/api/invoices/{id}', methods: ['GET'])]
public function show(Invoice $invoice, Request $request): Response
{
$response = new JsonResponse($this->mapper->toDto($invoice));
$response->setEtag(md5($invoice->getUpdatedAt()->format('U') . $invoice->getId()));
$response->setPublic();
$response->setMaxAge(60);
if ($response->isNotModified($request)) {
return $response; // 304 Not Modified, body vide
}
return $response;
}
Le client envoie If-None-Match: "hash" à la requête suivante. Si le hash correspond, le serveur répond 304 en moins de 10 ms (aucune sérialisation, aucune requête DB si bien fait).
Cache-Control et Varnish
Devant l'application, un reverse proxy (Varnish, ou directement Nginx avec proxy_cache) absorbe le trafic non authentifié. Configuration Varnish minimale pour cacher les endpoints publics.
vcl 4.1;
backend default {
.host = "app";
.port = "80";
}
sub vcl_recv {
# Ne pas cacher les requêtes authentifiées
if (req.http.Authorization || req.http.Cookie ~ "PHPSESSID") {
return (pass);
}
if (req.method != "GET") {
return (pass);
}
return (hash);
}
sub vcl_backend_response {
if (bereq.url ~ "^/api/public/") {
set beresp.ttl = 60s;
set beresp.grace = 1h;
set beresp.http.Cache-Control = "public, max-age=60";
}
}
Sur un endpoint GET /api/public/services avec 10 000 requêtes par minute, Varnish absorbe 99% du trafic avec une latence inférieure à 5 ms.
4. Cache applicatif Redis
Le cache applicatif intervient après authentification, sur les données partagées entre requêtes d'utilisateurs différents ou les projections coûteuses.
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
#[Route('/api/dashboard/stats', methods: ['GET'])]
public function stats(CacheInterface $cache, StatsService $service): JsonResponse
{
$tenantId = $this->getUser()->getTenantId();
$data = $cache->get(
"dashboard.stats.{$tenantId}",
function (ItemInterface $item) use ($service, $tenantId) {
$item->expiresAfter(300);
$item->tag(["tenant.{$tenantId}", 'dashboard']);
return $service->compute($tenantId);
}
);
return new JsonResponse($data);
}
Les tags permettent l'invalidation ciblée. Quand une facture change, on invalide tenant.42 et toutes les projections dépendantes sont recalculées. Adapter symfony/cache avec un RedisTagAwareAdapter et utiliser Redis Stack pour supporter les tags nativement.
Attribut #[Cache] de Symfony 6.3+ pour les cas simples :
use Symfony\Component\HttpKernel\Attribute\Cache;
#[Route('/api/invoices/{id}', methods: ['GET'])]
#[Cache(smaxage: 60, maxage: 30, public: true)]
public function show(Invoice $invoice): JsonResponse
{
return new JsonResponse($this->mapper->toDto($invoice));
}
5. Pagination efficace : cursor plutôt qu'offset
La pagination par offset (LIMIT 20 OFFSET 10000) est un piège classique. PostgreSQL doit parcourir les 10 020 lignes avant de renvoyer les 20 demandées. Sur une table de 10 millions de lignes, OFFSET 500000 prend 3 secondes.
La pagination par cursor contourne le problème en utilisant un WHERE sur la colonne d'ordre.
-- Page 1 : pas de cursor
SELECT id, number, issued_at
FROM invoice
WHERE tenant_id = 42
ORDER BY issued_at DESC, id DESC
LIMIT 20;
-- Page suivante : cursor = (issued_at, id) du dernier élément reçu
SELECT id, number, issued_at
FROM invoice
WHERE tenant_id = 42
AND (issued_at, id) < ('2026-06-01 10:00:00', 12345)
ORDER BY issued_at DESC, id DESC
LIMIT 20;
L'index (tenant_id, issued_at DESC, id DESC) rend chaque page équivalente en coût, quelle que soit la profondeur. Temps constant, à contraster avec l'offset dont le coût est linéaire.
La spec Relay (GraphQL) a popularisé le format : on renvoie un edges avec cursor base64-encodé par ligne, plus pageInfo.endCursor et pageInfo.hasNextPage. Sur une API REST, adopter la convention RFC 8288 avec des headers Link: <...>; rel="next".
6. Compression : Brotli plutôt que gzip
Gzip est universel mais Brotli compresse 20 à 25% plus efficacement sur du JSON. Configuration Nginx.
# nginx.conf
http {
brotli on;
brotli_comp_level 4;
brotli_types application/json application/javascript text/css text/html;
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_types application/json application/javascript text/css text/html;
}
Important : brotli_comp_level 4 suffit. Monter à 11 fait exploser le CPU pour un gain marginal. On compresse à la volée, pas à l'avance.
Sur un payload JSON de 120 Ko, gzip produit 18 Ko, Brotli 14 Ko. Sur une API mobile avec un réseau 4G lent, c'est 50 à 100 ms de latence gagnée sur le téléchargement.
7. HTTP/2 et HTTP/3
HTTP/1.1 ouvre une connexion TCP par requête parallèle. Les navigateurs limitent à 6 par origine. Sur une page SPA qui déclenche 40 appels API, on attend en file.
HTTP/2 multiplexe tout sur une seule connexion. Une dizaine de requêtes API sont traitées en parallèle sans overhead TCP. Gain de latence sur le chargement initial d'une SPA : 20 à 40% typiquement.
HTTP/3 va plus loin avec QUIC (basé sur UDP). Le handshake est divisé par deux, la reprise après perte de paquet est quasi-instantanée. Critique sur mobile où les pertes sont courantes. Cloudflare, Fastly et les reverse proxies modernes supportent HTTP/3 d'un simple toggle.
Activation sur Nginx 1.25+ :
server {
listen 443 ssl http2;
listen 443 quic reuseport;
http2 on;
http3 on;
http3_hq on;
quic_retry on;
add_header Alt-Svc 'h3=":443"; ma=86400';
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ...
}
Les "early hints" (103 Early Hints) de HTTP/2 permettent aussi au serveur d'indiquer au client quelles ressources précharger avant que la réponse principale ne soit prête. Symfony 6.4+ les supporte via ServerSendEvent.
Architecture cible
Stack recommandée pour une API Symfony tenant 1000 requêtes par seconde au p95 sous 200 ms.
┌──────────┐ HTTP/3 ┌──────────┐ Cache ┌──────────┐
│ Client │─────────────▶│ Cloudflare│────────────▶│ Varnish │
│ (mobile, │ │ ou Fastly │ │ reverse │
│ SPA) │ └──────────┘ │ proxy │
└──────────┘ └────┬─────┘
│ miss
▼
┌──────────┐
│ Nginx │
│ + FPM/ │
│ FrankenPHP│
└────┬─────┘
│
┌───────────────┼────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Redis │ │PostgreSQL│ │Elasticsearch│
│ (cache) │ │ (data) │ │ (search) │
└─────────┘ └──────────┘ └──────────┘
Redis encaisse les requêtes répétitives. PostgreSQL reçoit uniquement les lectures cache miss et les écritures. Elasticsearch sert les recherches full-text. Varnish absorbe le trafic anonyme ou faiblement personnalisé. Cloudflare/Fastly servent les assets statiques et font le edge cache.
Monitoring continu
Une fois en production, ne pas regarder les métriques c'est les laisser dériver. Dashboards minimums à brancher sur Prometheus ou Datadog :
- Latence p50, p95, p99 par endpoint, avec alerte si p95 dépasse 300 ms pendant plus de 5 minutes.
- Taux d'erreur 5xx par endpoint, alerte si supérieur à 0,5%.
- Ratio de hit du cache HTTP (Varnish) et du cache applicatif (Redis).
- Nombre de requêtes SQL par transaction (via APM), alerte si soudaine augmentation.
- Temps CPU et mémoire par worker FPM.
Cas client : de 1,2 s à 110 ms au p95
Exemple réel sur une mission 2025 : une API Symfony 6 pour une plateforme B2B avec 40 000 utilisateurs actifs. État initial : p95 à 1,2 s, pics à 4 s sous charge, plaintes clients quotidiennes. Application des leviers dans l'ordre.
- Détection N+1 sur 3 endpoints critiques (
invoices,customers,analytics). Correction par fetch-join et DTOs. p95 passe à 680 ms. - Serializer remplacé par DTOs de projection sur les 3 endpoints. p95 à 420 ms.
- ETag activé sur tous les endpoints GET. Taux de 304 : 35%. p95 à 310 ms.
- Cache Redis sur les endpoints
dashboard(TTL 5 min). p95 à 180 ms. - Pagination cursor sur
invoices(remplaceLIMIT/OFFSET). p95 à 140 ms. - Brotli activé. Poids payload -20%.
- HTTP/2 activé sur le reverse proxy. Gain sur le chargement SPA.
Résultat final : p95 à 110 ms, p99 à 320 ms. Temps total de mission : 18 jours développeur répartis sur 5 semaines. Rentabilité immédiate (rétention client +4%, réduction coût infra -30%).
Conclusion
Une API lente est rarement lente pour une seule raison. La somme de sept micro-optimisations bien choisies fait la différence entre un produit subi et un produit apprécié. La clé est la discipline : mesurer avant chaque changement, appliquer un levier à la fois, benchmark systématique.
Pour un audit de latence sur votre API PHP, une mission de tuning ou un cadrage avant une refonte, écrivez-nous à contact@your-digital-hub.com ou découvrez notre service performance et scalabilité. Voir aussi notre article RAG en production avec pgvector, Claude et Symfony pour une architecture de bout en bout.