La mise en cache HTTP est l’un des leviers les plus efficaces pour accélérer un site. Elle évite au navigateur de télécharger plusieurs fois les mêmes ressources et permet aux CDN de servir des réponses sans solliciter l’origine à chaque requête.
Mais un mauvais en-tête de cache peut aussi créer des bugs très pénibles : page privée visible trop longtemps, panier WooCommerce figé, ancien CSS conservé, API qui renvoie des données périmées, ou fichier JavaScript jamais mis à jour. Bref, le cache est un accélérateur. Pas une baguette magique. Et il mord si on le configure mal.
Voici comment envoyer les bons en-têtes HTTP avec PHP, selon le type de contenu : fichiers statiques, HTML dynamique, API, pages privées, WordPress et WooCommerce.
Le rôle des en-têtes HTTP de cache
Quand un navigateur demande une ressource, le serveur peut indiquer comment cette réponse doit être conservée, réutilisée ou revalidée.
Les principaux en-têtes concernés sont :
| En-tête | Rôle |
|---|---|
Cache-Control | Définit les règles de cache modernes |
Expires | Ancien mécanisme basé sur une date d’expiration |
ETag | Identifiant de version permettant la revalidation |
Last-Modified | Date de dernière modification utilisée pour revalider |
Vary | Indique que la réponse varie selon certains en-têtes de requête |
Aujourd’hui, Cache-Control est le plus important. Expires peut rester utile pour compatibilité, mais il ne doit plus piloter toute votre stratégie.
Les directives Cache-Control essentielles
Voici les directives que vous croiserez le plus souvent.
| Directive | Signification |
|---|---|
public | La réponse peut être stockée par le navigateur et les caches partagés |
private | La réponse ne doit être stockée que par le navigateur de l’utilisateur |
max-age=3600 | La réponse reste fraîche pendant 3600 secondes |
s-maxage=3600 | Durée spécifique pour les caches partagés comme CDN/proxy |
no-cache | La réponse peut être stockée, mais doit être revalidée avant réutilisation |
no-store | La réponse ne doit pas être stockée |
must-revalidate | Une réponse périmée doit être revalidée avant usage |
immutable | La ressource ne changera pas pendant sa durée de fraîcheur |
stale-while-revalidate | Un cache peut servir une réponse périmée pendant qu’il la revalide |
La nuance importante : no-cache ne veut pas dire “ne pas cacher”. Cela veut dire “tu peux stocker, mais tu dois revalider avant de réutiliser”. Si vous voulez empêcher tout stockage, utilisez no-store.
Cas 1 : contenu dynamique qui ne doit pas être stocké
Pour une page contenant des données sensibles ou personnelles, utilisez no-store.
<?php
header( 'Cache-Control: no-store, max-age=0' );
header( 'Pragma: no-cache' );
header( 'Expires: 0' );Langage du code : HTML, XML (xml)
Utilisez ce type de header pour :
- pages de compte utilisateur ;
- zones admin ;
- checkout ;
- panier ;
- pages contenant des informations personnelles ;
- réponses avec tokens ou données privées.
Dans WordPress, utilisez plutôt nocache_headers() quand vous êtes dans le cycle WordPress. La fonction existe justement pour envoyer plusieurs headers anti-cache compatibles avec différents navigateurs et caches intermédiaires.
<?php
nocache_headers();Langage du code : HTML, XML (xml)
Cas 2 : HTML dynamique avec revalidation
Pour une page HTML publique mais susceptible de changer, vous pouvez autoriser le stockage tout en forçant une revalidation.
<?php
header( 'Cache-Control: public, no-cache, must-revalidate' );Langage du code : HTML, XML (xml)
Cette réponse peut être stockée, mais le navigateur ou le cache doit vérifier auprès du serveur avant de la réutiliser. C’est utile si vous voulez profiter des validations conditionnelles avec ETag ou Last-Modified.
Pour des pages publiques très fréquentées, un cache court peut être plus efficace :
<?php
header( 'Cache-Control: public, max-age=300, stale-while-revalidate=60' );Langage du code : HTML, XML (xml)
Ici, la réponse est fraîche pendant cinq minutes. Ensuite, un cache compatible peut servir l’ancienne réponse pendant une minute pendant qu’il la revalide. MDN décrit justement stale-while-revalidate comme une directive permettant de réutiliser une réponse périmée pendant la revalidation. :contentReference[oaicite:2]{index=2}
Cas 3 : fichiers statiques versionnés
Pour les assets versionnés — CSS, JavaScript, images, polices — utilisez un cache long.
Cache-Control: public, max-age=31536000, immutableLangage du code : PHP (php)
Ce header est idéal si l’URL change quand le fichier change :
/assets/app.8f3a91c.css
/assets/theme.2025-10-14.js
/uploads/logo-v2.avif
Il est dangereux si vous servez toujours le même nom de fichier, par exemple :
/assets/style.css
Si style.css change mais que son URL reste identique, un cache long avec immutable peut garder l’ancienne version trop longtemps. La bonne stratégie est donc : cache long uniquement avec URLs versionnées.
Cas 4 : API JSON publique
Pour une API publique qui renvoie des données peu sensibles, vous pouvez choisir un cache court.
<?php
header( 'Content-Type: application/json; charset=utf-8' );
header( 'Cache-Control: public, max-age=60, stale-while-revalidate=30' );
echo json_encode(
array(
'status' => 'ok',
'time' => time(),
),
JSON_THROW_ON_ERROR
);Langage du code : HTML, XML (xml)
Pour une API privée, utilisez plutôt private ou no-store selon le niveau de sensibilité.
<?php
header( 'Content-Type: application/json; charset=utf-8' );
header( 'Cache-Control: no-store, max-age=0' );Langage du code : HTML, XML (xml)
Cas 5 : téléchargement de fichier généré par PHP
Pour un fichier généré à la demande, comme un export CSV personnalisé, empêchez le stockage si le contenu dépend de l’utilisateur.
<?php
$filename = 'export-' . gmdate( 'Y-m-d' ) . '.csv';
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Cache-Control: no-store, max-age=0' );
header( 'Pragma: no-cache' );
header( 'Expires: 0' );
echo "id,email\n";
echo "1,client@example.com\n";Langage du code : HTML, XML (xml)
Pour un fichier public identique pour tout le monde, vous pouvez au contraire autoriser le cache. Tout dépend du contenu, pas seulement de l’extension.
Ajouter Last-Modified en PHP
Last-Modified indique la date de dernière modification d’une ressource. Le navigateur peut ensuite envoyer If-Modified-Since pour demander si le contenu a changé.
Exemple simple pour un fichier servi par PHP :
<?php
$file = __DIR__ . '/data/public.json';
if ( ! is_file( $file ) ) {
http_response_code( 404 );
exit;
}
$last_modified = filemtime( $file );
$etag = '"' . md5_file( $file ) . '"';
header( 'Content-Type: application/json; charset=utf-8' );
header( 'Cache-Control: public, max-age=300, must-revalidate' );
header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $last_modified ) . ' GMT' );
header( 'ETag: ' . $etag );
$if_none_match = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
$if_modified_since = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '';
if (
$if_none_match === $etag
|| strtotime( $if_modified_since ) === $last_modified
) {
http_response_code( 304 );
exit;
}
readfile( $file );Langage du code : HTML, XML (xml)
Cette logique permet de répondre 304 Not Modified si le navigateur possède déjà la bonne version. La réponse est alors beaucoup plus légère.
ETag ou Last-Modified ?
ETag et Last-Modified servent tous les deux à revalider une réponse. Ils ne répondent pas exactement au même besoin.
| En-tête | Avantage | Limite |
|---|---|---|
Last-Modified | Simple, basé sur une date | Précision limitée, dépend du timestamp |
ETag | Identifie précisément une version | Peut être mal configuré en multi-serveur |
Pour un site classique, Last-Modified est souvent suffisant. Pour une ressource générée ou versionnée précisément, ETag peut être très utile. MDN documente ETag comme un validateur permettant au serveur de déterminer si une ressource a changé. :contentReference[oaicite:3]{index=3}
Attention à Vary
Vary indique qu’une réponse peut varier selon certains en-têtes de requête. C’est indispensable dans certains cas, mais cela peut fragmenter le cache.
Exemples courants :
Vary: Accept-EncodingLangage du code : HTTP (http)
ou :
Vary: Accept-LanguageLangage du code : HTTP (http)
Évitez de mettre Vary: Cookie sur des pages publiques si vous voulez qu’un CDN les cache efficacement. Cela peut créer une version par cookie, donc quasiment annuler le bénéfice du cache partagé.
PHP : envoyer les headers avant toute sortie
En PHP, les headers doivent être envoyés avant tout contenu. Pas d’espace avant <?php, pas d’echo, pas de HTML, pas de BOM UTF-8, pas de var_dump avant les headers.
<?php
header( 'Cache-Control: public, max-age=300' );
echo 'Contenu';Langage du code : HTML, XML (xml)
Sinon, vous risquez l’erreur classique :
Warning: Cannot modify header information - headers already sentLangage du code : HTTP (http)
Si vous développez un plugin WordPress ou un contrôleur PHP, envoyez les headers au bon moment dans le cycle d’exécution, avant la sortie.
WordPress : ne pas envoyer les mêmes headers partout
Sur WordPress, il ne faut pas appliquer une politique de cache uniforme à tout le site. Une page d’article publique, une page panier WooCommerce, une page compte client et une réponse REST privée n’ont pas les mêmes contraintes.
Exemple de logique prudente dans un plugin ou un mu-plugin :
<?php
/**
* Plugin Name: SkyMinds HTTP Cache Headers
* Description: Adds conservative HTTP cache headers for public WordPress pages.
* Author: Matt Biscay
* Version: 1.0.0
*/
declare(strict_types=1);
defined( 'ABSPATH' ) || exit;
add_action( 'send_headers', 'skyminds_send_public_cache_headers' );
/**
* Send conservative cache headers for public, anonymous front-end pages.
*
* @return void
*/
function skyminds_send_public_cache_headers(): void {
if ( is_admin() || is_user_logged_in() || wp_doing_ajax() || wp_is_json_request() ) {
return;
}
if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() || is_account_page() ) ) {
nocache_headers();
return;
}
if ( is_singular() || is_home() || is_front_page() || is_archive() ) {
header( 'Cache-Control: public, max-age=300, stale-while-revalidate=60' );
}
}Langage du code : HTML, XML (xml)
Ce snippet reste volontairement conservateur. Il évite les utilisateurs connectés, l’admin, AJAX, JSON et les pages WooCommerce sensibles.
WooCommerce : prudence maximale
Avec WooCommerce, le cache HTTP doit être précis. Ne cachez jamais comme une page statique :
- le panier ;
- le checkout ;
- le compte client ;
- les endpoints de paiement ;
- les réponses personnalisées par cookie ou session ;
- les prix dynamiques si leur affichage dépend du visiteur.
En revanche, vous pouvez souvent mettre un cache court sur les pages catalogue publiques pour les visiteurs anonymes : catégories produits, tags produits, pages marque, pages de listing. Mais testez le panier, les fragments, les prix et les variations. WooCommerce adore rappeler que “ça marche sur la home” ne veut pas dire “ça marche en checkout”.
CDN : distinguer navigateur et cache partagé
Un navigateur et un CDN ne jouent pas exactement le même rôle. max-age concerne tous les caches, tandis que s-maxage cible les caches partagés comme les CDN et proxys.
Exemple utile pour une page publique :
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=60Langage du code : PHP (php)
Ici, le navigateur garde la réponse fraîche pendant une minute, mais le CDN peut la garder dix minutes. C’est souvent intéressant pour WordPress : le visiteur garde peu, l’edge garde plus.
Vérifiez toutefois la documentation de votre CDN. Cloudflare, Fastly, Bunny, CloudFront et les hébergeurs managés peuvent interpréter ou compléter les headers avec leurs propres règles.
Tester les headers avec curl
Pour vérifier les headers d’une page :
curl -I https://www.example.com/Langage du code : JavaScript (javascript)
Pour vérifier une ressource statique :
curl -I https://www.example.com/assets/app.cssLangage du code : JavaScript (javascript)
Pour voir les headers avec compression demandée :
curl -I -H "Accept-Encoding: br,gzip" https://www.example.com/Langage du code : JavaScript (javascript)
Pour tester une revalidation conditionnelle avec ETag :
curl -I -H 'If-None-Match: "votre-etag"' https://www.example.com/data.jsonLangage du code : JavaScript (javascript)
Pour tester avec If-Modified-Since :
curl -I -H "If-Modified-Since: Wed, 15 May 2024 10:00:00 GMT" https://www.example.com/data.jsonLangage du code : JavaScript (javascript)
Recettes rapides selon le type de réponse
| Type de contenu | Header recommandé |
|---|---|
| HTML public WordPress | public, max-age=300, stale-while-revalidate=60 |
| Page privée | no-store, max-age=0 |
| API privée | no-store, max-age=0 |
| API publique courte | public, max-age=60, stale-while-revalidate=30 |
| CSS/JS versionné | public, max-age=31536000, immutable |
| Images versionnées | public, max-age=31536000, immutable |
| HTML avec CDN | public, max-age=60, s-maxage=600, stale-while-revalidate=60 |
| Réponse à revalider | public, no-cache, must-revalidate |
Erreurs fréquentes
- Mettre
no-storesur tout le site et tuer le cache navigateur. - Mettre
immutablesur des fichiers non versionnés. - Cacher des pages WooCommerce personnalisées par cookie.
- Confondre
no-cacheetno-store. - Oublier que PHP doit envoyer les headers avant toute sortie.
- Ajouter
Vary: Cookiesans comprendre l’impact sur le CDN. - Faire confiance au navigateur sans tester avec
curl -I. - Appliquer une règle globale au lieu de raisonner par type de contenu.
Checklist avant publication
- Identifier le type de contenu : public, privé, statique, dynamique, API.
- Choisir
public,private,no-cacheouno-store. - Définir une durée cohérente avec
max-age. - Utiliser
s-maxagesi un CDN doit garder plus longtemps que le navigateur. - Ajouter
immutableseulement pour des URLs versionnées. - Vérifier les pages connectées et privées.
- Tester panier, checkout et compte client sur WooCommerce.
- Tester les headers avec
curl -I. - Vérifier le comportement CDN depuis une navigation anonyme.
Articles liés sur SkyMinds
- PageSpeed à 99 : ce que vaut vraiment un score de performance web
- WordPress : faut-il optimiser le fichier .htaccess pour les permaliens ?
- PHP : remplacer APC par OPcache et APCu sur un serveur Linux
À retenir
Les bons en-têtes de cache dépendent du contenu. Il n’existe pas un header magique à mettre partout.
Pour des fichiers statiques versionnés, utilisez un cache long :
Cache-Control: public, max-age=31536000, immutableLangage du code : PHP (php)
Pour des pages privées ou sensibles, interdisez le stockage :
Cache-Control: no-store, max-age=0Langage du code : HTTP (http)
Pour des pages publiques dynamiques, utilisez un cache court et mesuré :
Cache-Control: public, max-age=300, stale-while-revalidate=60Langage du code : PHP (php)
Le cache HTTP est puissant quand il respecte la nature de la réponse. Cachez longtemps ce qui est versionné. Revalidez ce qui peut changer. Ne stockez jamais ce qui est privé. Voilà, le navigateur est content, le CDN aussi, et le serveur respire un peu.


