Le fonctionnement du web
Ce qui se passe vraiment entre le clic de l'utilisateur et ton contrôleur Symfony.
Symfony cache beaucoup de choses : on reçoit un objet Request, on renvoie une Response, et le reste semble magique. Mais le jour où un cookie de session disparaît, où un site refuse d'appeler ton API, où l'adresse IP enregistrée est toujours la même, ou où un changement de serveur « ne marche que pour certains », il faut savoir ce qui circule entre le navigateur et le serveur.
Cette fiche suit le trajet d'une requête, étape par étape. Chaque étape est une source de bugs connue, et souvent de failles.
Les exemples sont écrits dans leur propre format : HTTP brut, YAML de configuration Symfony, JSON. Ce sont des illustrations, marquées « non exécuté » : aucune requête réseau n'a été faite pour cette fiche. Seul le découpage d'une adresse en PHP a été exécuté. Les noms de domaine et les adresses IP sont fictifs, choisis dans les plages réservées à la documentation.
1.Le trajet d'une requête
Une adresse web (une URL) se découpe en morceaux, et chacun sert à une étape différente :
<?php
declare(strict_types=1);
// Les morceaux d'une adresse, tels que PHP les découpe
$url = 'https://shop.example.com:8443/produits/clavier?couleur=noir&page=2#avis';
foreach (parse_url($url) as $part => $value) {
printf("%-8s %s\n", $part, $value);
}
parse_str(parse_url($url, PHP_URL_QUERY), $query);
echo "\nParamètres de la requête : ", json_encode($query), "\n";scheme https
host shop.example.com
port 8443
path /produits/clavier
query couleur=noir&page=2
fragment avis
Paramètres de la requête : {"couleur":"noir","page":"2"}Quand l'utilisateur valide cette adresse, voici ce qui se passe :
- Le DNS traduit le nom
shop.example.comen adresse IP, l'adresse numérique du serveur (section 2). - Une connexion s'ouvre vers cette adresse, sur le port 443 (le port par défaut de HTTPS, ici remplacé par 8443).
- TLS chiffre la connexion et vérifie l'identité du serveur grâce à son certificat (section 5).
- Le navigateur envoie la requête HTTP : la méthode, le chemin
/produits/clavier, la requête?couleur=noir&page=2, les en-têtes et les cookies (section 3). - Côté serveur, un reverse proxy comme nginx reçoit la requête et la transmet à PHP-FPM, qui exécute Symfony (section 6).
- Symfony construit l'objet
Request, trouve la route, appelle le contrôleur et renvoie uneResponse. - Le navigateur reçoit la réponse et l'affiche. Puis il recommence pour chaque image, feuille de style et script de la page.
Le fragment (#avis) n'est jamais envoyé au serveur : il ne sert qu'au navigateur, pour se placer dans la page. C'est pour cela qu'il n'apparaît jamais dans l'objet Request.
2.Le DNS : l'annuaire du web
Le DNS (Domain Name System) traduit un nom de domaine en adresse IP. Chaque domaine publie des enregistrements :
; Les enregistrements DNS d'un domaine (adresses de documentation, fictives)
; nom TTL type valeur
shop.example.com. 3600 A 203.0.113.10 ; adresse IPv4
shop.example.com. 3600 AAAA 2001:db8::10 ; adresse IPv6
www.shop.example.com. 3600 CNAME shop.example.com. ; un alias vers un autre nom
shop.example.com. 3600 MX 10 mail.example.com. ; où livrer les e-mails
shop.example.com. 3600 TXT "v=spf1 include:_spf.example.com -all"
; texte libre : SPF, vérifications…- A et AAAA : l'adresse IPv4 et l'adresse IPv6 du serveur.
- CNAME : un alias, qui renvoie vers un autre nom. Il ne peut pas être posé sur le domaine racine lui-même (
example.com). - MX : le serveur qui reçoit les e-mails du domaine.
- TXT : du texte libre, utilisé pour prouver qu'on possède le domaine ou pour lutter contre l'usurpation d'e-mails (SPF, DKIM, DMARC).
Pour trouver la réponse, le navigateur interroge d'abord son cache, puis celui du système, puis un résolveur (celui du fournisseur d'accès, ou un résolveur public). Si le résolveur ne la connaît pas, il remonte une chaîne de serveurs : la racine, puis le serveur de .com, puis le serveur qui fait autorité pour example.com.
Chaque réponse est gardée en cache pendant sa durée de vie, le TTL (time to live, en secondes : 3600 = une heure). Tant que le TTL n'est pas écoulé, une partie des utilisateurs reçoit encore l'ancienne adresse. Avant une migration de serveur, baisse le TTL à quelques minutes un ou deux jours à l'avance, change l'adresse, puis remonte le TTL.
3.HTTP : une requête, une réponse
HTTP est un échange très simple : le client envoie une requête, le serveur renvoie une réponse. En HTTP/1.1, les deux sont du texte lisible. Voici l'ajout d'un article au panier :
POST /panier/articles HTTP/1.1
Host: shop.example.com
Content-Type: application/json
Accept: application/json
Cookie: PHPSESSID=8f2c1e0b7d6a4c39
Content-Length: 34
{"productId": 42, "quantity": 2}HTTP/1.1 201 Created
Content-Type: application/json
Location: /panier/articles/7
Cache-Control: no-store
Set-Cookie: PHPSESSID=8f2c1e0b7d6a4c39; Path=/; Secure; HttpOnly; SameSite=Lax
{"id": 7, "productId": 42, "quantity": 2}Chaque message a la même forme : une première ligne (la méthode et le chemin, ou le code de statut), des en-têtes (Nom: valeur), une ligne vide, puis un corps facultatif. HTTP/2 et HTTP/3 transportent exactement les mêmes informations, mais en binaire et plus efficacement : plusieurs requêtes partagent une seule connexion. Pour toi, côté Symfony, rien ne change.
Voici la même requête, vue depuis Symfony :
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\AddToCart;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
final class CartController extends AbstractController
{
// La même requête, vue par Symfony : la méthode, le chemin, le corps JSON
#[Route('/panier/articles', methods: ['POST'])]
public function add(#[MapRequestPayload] AddToCart $payload): JsonResponse
{
// $payload est déjà désérialisé ET validé : sinon Symfony répond 422 tout seul
$item = [
'id' => 7,
'productId' => $payload->productId,
'quantity' => $payload->quantity,
];
return $this->json($item, Response::HTTP_CREATED, [
'Location' => '/panier/articles/7',
]);
}
}Les méthodes
| Méthode | Rôle | Sûre | Idempotente |
|---|---|---|---|
GET | Lire une ressource | Oui | Oui |
POST | Créer, ou déclencher une action | Non | Non |
PUT | Remplacer entièrement une ressource | Non | Oui |
PATCH | Modifier une partie d'une ressource | Non | Pas forcément |
DELETE | Supprimer une ressource | Non | Oui |
- Une méthode sûre ne modifie rien. Un navigateur, un robot d'indexation ou un outil de préchargement peut appeler un
GETà tout moment : un lien « supprimer » enGETfinira par être cliqué par un robot. - Une méthode idempotente donne le même résultat si on la répète : supprimer deux fois la même ressource la laisse supprimée. C'est ce qui permet de rejouer une requête sans danger après une coupure réseau. Un
POSTrépété, lui, peut créer deux commandes.
Les codes de statut
| Famille | Sens | Les plus utiles |
|---|---|---|
| 2xx | Succès | 200 OK, 201 Created (avec l'en-tête Location), 204 No Content |
| 3xx | Redirection | 301 et 308 (définitive), 302, 303 et 307 (temporaire), 304 Not Modified (cache) |
| 4xx | Erreur du client | 400 requête invalide, 401 non authentifié, 403 interdit, 404 introuvable, 409 conflit, 422 données invalides, 429 trop de requêtes |
| 5xx | Erreur du serveur | 500 erreur interne, 502 et 504 (le proxy n'obtient pas de réponse correcte de PHP), 503 indisponible |
La différence entre 401 et 403 revient souvent : 401 veut dire « je ne sais pas qui tu es, connecte-toi », 403 veut dire « je sais qui tu es, et tu n'as pas le droit ». Et une erreur 502 ou 504 renvoyée par nginx indique presque toujours que PHP-FPM est arrêté, saturé ou trop lent.
4.Cookies et sessions : se souvenir de l'utilisateur
HTTP est sans état : chaque requête est indépendante, et le serveur ne se souvient pas de la précédente. Pour savoir que la requête vient du même utilisateur, il faut une information qui voyage avec chaque requête : un cookie.
- Le serveur envoie l'en-tête
Set-Cookie(voir la réponse de la section 3). - Le navigateur le garde, et le renvoie automatiquement dans l'en-tête
Cookiede chaque requête vers ce site. - Une session s'appuie sur ce mécanisme : le cookie ne contient qu'un identifiant au hasard (
PHPSESSID), et les données (l'utilisateur connecté, le panier) restent sur le serveur.
Les attributs d'un cookie décident où et comment il voyage :
Secure: envoyé seulement en HTTPS.HttpOnly: invisible pour JavaScript. Un script injecté dans la page (une faille XSS, fiche 10) ne peut pas le lire.SameSite=Lax: pas envoyé quand un autre site soumet un formulaire vers le tien. C'est une protection importante contre le CSRF (fiche 10).Strictva plus loin,Nonel'envoie partout et exigeSecure.Max-AgeouExpires: sa durée de vie. Sans eux, il disparaît à la fermeture du navigateur.
# config/packages/framework.yaml : le cookie de session
framework:
session:
cookie_secure: auto # Secure dès que la requête arrive en HTTPS
cookie_httponly: true # invisible pour JavaScript
cookie_samesite: lax # pas envoyé par les formulaires POST venus d'un autre siteQui possède l'identifiant de session est connecté à la place de l'utilisateur. Il ne doit donc jamais apparaître dans une URL, un log ou un message d'erreur. Et il doit changer à la connexion : sinon, un attaquant qui a fait adopter un identifiant connu à sa victime se retrouve connecté avec elle. C'est la fixation de session. Le composant Security de Symfony régénère l'identifiant à la connexion par défaut : ne désactive pas ce comportement.
Pour un site classique, la session avec un cookie HttpOnly est simple et sûre. Pour une API appelée par une application mobile ou un autre serveur, on utilise plutôt un jeton envoyé dans l'en-tête Authorization. Stocker un jeton dans le localStorage du navigateur, comme on le voit souvent, l'expose à n'importe quel script injecté dans la page.
5.HTTPS et TLS
HTTPS, c'est HTTP transporté dans une connexion TLS. TLS apporte trois garanties :
- La confidentialité : personne sur le chemin (le Wi-Fi du café, le fournisseur d'accès) ne peut lire les échanges.
- L'intégrité : personne ne peut les modifier en route, par exemple pour injecter une publicité ou un script.
- L'authentification du serveur : le certificat prouve que le serveur contrôle bien le nom de domaine.
Un certificat est signé par une autorité de certification que le navigateur connaît. Let's Encrypt en délivre gratuitement, avec une durée de vie courte, et des outils les renouvellent automatiquement. Le cadenas veut dire « la connexion avec ce domaine est protégée ». Il ne veut pas dire « ce site est honnête » : un site d'hameçonnage a aussi son certificat.
Une fois le site en HTTPS, l'en-tête HSTS demande au navigateur de ne plus jamais l'appeler en HTTP simple, même si l'utilisateur tape http:// :
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomainsLe navigateur retient la consigne pendant toute la durée indiquée (ici un an). Avec includeSubDomains, un sous-domaine encore en HTTP simple devient inaccessible. Commence avec une durée courte, vérifie tous les sous-domaines, puis allonge.
6.Proxys, répartiteurs de charge et CDN
Entre le navigateur et Symfony, la requête traverse souvent plusieurs intermédiaires :
- Un reverse proxy (nginx, Caddy, Traefik) reçoit les requêtes à la place de l'application. Il sert les fichiers statiques, gère souvent HTTPS, et transmet le reste à PHP-FPM.
- Un répartiteur de charge (load balancer) distribue les requêtes entre plusieurs serveurs identiques. Conséquence directe : les sessions ne peuvent plus être stockées dans des fichiers locaux, sinon l'utilisateur perd sa session en changeant de serveur. On les met dans Redis ou en base.
- Un CDN (Content Delivery Network) garde des copies des fichiers dans des serveurs proches des utilisateurs, partout dans le monde.
- Un proxy tout court (ou forward proxy) est placé du côté du client, par exemple celui d'une entreprise qui filtre la navigation de ses salariés.
Problème : pour Symfony, la requête vient maintenant de nginx, plus de l'utilisateur. L'adresse IP vue est celle du proxy, et le protocole est HTTP alors que l'utilisateur est en HTTPS. Les proxys transmettent les vraies valeurs dans les en-têtes X-Forwarded-*, mais Symfony ne les lit que si on le lui demande :
# config/packages/framework.yaml : faire confiance aux en-têtes X-Forwarded-*
# SEULEMENT quand ils viennent de nos propres proxys
framework:
trusted_proxies: '10.0.0.0/8' # le réseau privé où se trouvent nos proxys
trusted_headers:
- 'x-forwarded-for'
- 'x-forwarded-proto'
- 'x-forwarded-port'
- 'x-forwarded-host'N'importe qui peut envoyer X-Forwarded-For: 1.2.3.4 dans sa requête. Si Symfony fait confiance à cet en-tête quelle que soit sa provenance, un attaquant choisit l'adresse IP que tu enregistres : il contourne une limitation par IP, une liste d'adresses autorisées, ou fausse tes logs. Ne déclare comme trusted_proxies que les adresses de tes proxys, et vérifie que l'application n'est pas joignable en les contournant.
7.Les API REST
Une API permet à un programme (une application mobile, un front JavaScript, un autre serveur) d'utiliser ton application. REST est un style de conception qui s'appuie entièrement sur HTTP : les ressources sont des noms dans l'URL, et les méthodes HTTP sont les verbes.
| Requête | Sens | Réponse en cas de succès |
|---|---|---|
GET /orders | Lister les commandes | 200, avec une pagination |
GET /orders/42 | Lire la commande 42 | 200, ou 404 si elle n'existe pas |
POST /orders | Créer une commande | 201, avec l'en-tête Location: /orders/43 |
PATCH /orders/42 | Modifier une partie de la commande 42 | 200 avec la ressource, ou 204 |
DELETE /orders/42 | Supprimer la commande 42 | 204 |
POST /orders/42/cancel | Une action qui n'est pas un simple changement de champ | 200 ou 204 |
- Des noms, pas des verbes :
/orders, pas/getOrders. La méthode HTTP dit déjà ce qu'on fait. - Les bons codes de statut : un client d'API lit le code avant le corps. Une erreur renvoyée avec un code 200 casse tous les outils.
- Des erreurs dans un format standard, les problem details (RFC 9457), avec le type de contenu
application/problem+json:
{
"type": "https://shop.example.com/errors/out-of-stock",
"title": "Stock insuffisant",
"status": 409,
"detail": "Il ne reste que 1 exemplaire de « Souris », 2 demandés.",
"instance": "/panier/articles"
}- Une pagination pour toute liste qui peut grandir (fiche 7, section 11).
- Un versionnage (
/v1/orders, ou un en-tête) dès qu'une API est utilisée par des clients que tu ne contrôles pas : tu ne peux pas les obliger à se mettre à jour le jour où tu changes un champ.
Dans Symfony, #[MapRequestPayload] transforme le corps JSON en objet et le valide (section 3), et $this->json() produit la réponse. Pour une API riche, API Platform génère une grande partie de ce travail.
REST colle bien aux ressources qu'on crée, lit, modifie et supprime. Pour une action métier (annuler, payer, renvoyer une facture), une route comme POST /orders/42/cancel est plus claire qu'un PATCH sur un champ status. Et pour une API interne utilisée par un seul front, la cohérence et la documentation comptent plus que la pureté du style.
8.CORS : quand le navigateur bloque ton API
Ton front est sur https://app.example.com, ton API sur https://api.example.com. Le code JavaScript du front appelle l'API, et le navigateur bloque la réponse, avec une erreur « CORS » dans la console.
C'est la politique de même origine : par défaut, un script ne peut lire que les réponses de sa propre origine, c'est-à-dire du même protocole, du même domaine et du même port. Sans elle, n'importe quel site que tu visites pourrait lire tes e-mails dans ton webmail, avec tes cookies. CORS (Cross-Origin Resource Sharing) permet au serveur d'autoriser explicitement certaines autres origines :
# 1. Le navigateur, sur https://app.example.com, demande d'abord la permission (« preflight »)
OPTIONS /api/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
# 2. L'API répond quelles origines, méthodes et en-têtes elle accepte
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 3600
# 3. Seulement ensuite, le navigateur envoie la vraie requête POSTDans Symfony, le bundle NelmioCorsBundle gère ces en-têtes à partir d'une configuration, plutôt que de les écrire à la main dans chaque contrôleur.
- CORS est appliqué par le navigateur, pour protéger l'utilisateur. Un script
curlou un autre serveur l'ignore complètement. Ton API doit donc toujours vérifier l'authentification et les droits. Access-Control-Allow-Origin: *autorise tous les sites. C'est acceptable pour des données publiques, sans cookie. Pour une API qui utilise les cookies de l'utilisateur, liste les origines autorisées une par une. Et ne renvoie jamais en écho l'en-têteOriginreçu sans le comparer à cette liste : ce serait autoriser tout le monde, cookies compris.
Pièges fréquents
| Piège | Ce qui arrive | La parade |
|---|---|---|
| Changer un DNS sans baisser le TTL avant | Une partie des utilisateurs reste sur l'ancien serveur pendant des heures | Baisser le TTL un ou deux jours avant |
Une action destructrice en GET | Un robot ou un préchargement la déclenche | POST ou DELETE, avec protection CSRF |
| Une erreur renvoyée avec un code 200 | Les clients et les outils croient que tout va bien | Le bon code 4xx ou 5xx, et des problem details |
| Confondre 401 et 403 | Le client ne sait pas s'il doit se connecter ou abandonner | 401 : non authentifié ; 403 : non autorisé |
Un cookie de session sans HttpOnly ni Secure | Volé par un script injecté, ou en HTTP simple | La configuration de session de Symfony |
Un jeton dans le localStorage | Lisible par n'importe quel script de la page | Un cookie HttpOnly, ou un jeton gardé en mémoire |
| Des sessions dans des fichiers avec plusieurs serveurs | L'utilisateur est déconnecté au hasard | Sessions dans Redis ou en base |
Faire confiance à X-Forwarded-For partout | Un attaquant choisit son adresse IP | trusted_proxies limité à tes proxys |
Access-Control-Allow-Origin: * par réflexe | N'importe quel site appelle l'API | Une liste d'origines autorisées |
| Compter sur CORS pour protéger l'API | curl l'appelle quand même | Authentification et contrôle d'accès côté serveur |
Checklist
- Chaque route utilise la méthode HTTP qui correspond à ce qu'elle fait, et aucune action ne modifie des données en
GET. - Chaque réponse a le bon code de statut, y compris en cas d'erreur.
- Le site est entièrement en HTTPS, avec HSTS une fois tous les sous-domaines vérifiés.
- Le cookie de session est
Secure,HttpOnlyetSameSite=Lax. - Les sessions sont partagées entre serveurs s'il y en a plusieurs.
trusted_proxiesne contient que mes propres proxys.- L'API renvoie des erreurs au format problem details, et ses listes sont paginées.
- CORS n'autorise que les origines nécessaires, et l'API vérifie quand même les droits.
Conseils
- Ouvre l'onglet Réseau des outils de développement de ton navigateur sur tes propres pages. Clique sur une requête : tu y retrouves la méthode, le statut, les en-têtes et les cookies de cette fiche. C'est le meilleur outil pour comprendre un bug HTTP.
- Utilise le profiler de Symfony : son panneau « Request / Response » montre ce que Symfony a reçu et renvoyé, y compris les en-têtes ajoutés par les proxys.
- Lis les messages d'erreur CORS en entier. La console du navigateur dit précisément quel en-tête manque ou quelle origine a été refusée.
- Commence petit avec les API. Quelques routes bien nommées, avec les bons codes et des erreurs claires, valent mieux qu'un grand cadre théorique.
Documentation officielle
- Symfony et les bases de HTTP
- HTTP Semantics (RFC 9110), la norme de référence, et la documentation HTTP de MDN, plus accessible
- Les sessions dans Symfony
- Symfony derrière un proxy ou un répartiteur de charge
- Mapper le corps d'une requête avec
#[MapRequestPayload] - Problem Details for HTTP APIs (RFC 9457)
- Le fonctionnement de Let's Encrypt
- CORS sur MDN