Quand on travaille avec Laravel sans Octane, certaines habitudes de code semblent inoffensives. Mais dès qu'on passe à Octane, elles peuvent provoquer des bugs difficiles à reproduire et encore plus difficiles à débugger en production.
C'est exactement ce qui m'est arrivé avec une simple variable static dans un helper.
Le contexte
Dans mon projet, j'avais un helper current_tax_label() dont le rôle est de retourner le label de taxe applicable à l'utilisateur selon sa zone géographique (TTC ou HT).
Pour éviter de refaire le calcul plusieurs fois dans la même requête, j'avais utilisé une technique classique de memoization :
1function current_tax_label(): string2{3 static $label = null;45 if ($label !== null) {6 return $label;7 }89 $zone = ZoneSessionManager::getSession();1011 if (! $zone instanceof CountryByZoneData) {12 return $label = '';13 }1415 $taxZone = TaxZone::query()16 ->whereHas('country', fn ($q) => $q->where('cca2', $zone->countryCode))17 ->whereNull('province_code')18 ->first();1920 return $label = $taxZone?->is_tax_inclusive ? __('TTC') : __('HT');21}1function current_tax_label(): string2{3 static $label = null;45 if ($label !== null) {6 return $label;7 }89 $zone = ZoneSessionManager::getSession();1011 if (! $zone instanceof CountryByZoneData) {12 return $label = '';13 }1415 $taxZone = TaxZone::query()16 ->whereHas('country', fn ($q) => $q->where('cca2', $zone->countryCode))17 ->whereNull('province_code')18 ->first();1920 return $label = $taxZone?->is_tax_inclusive ? __('TTC') : __('HT');21}
En PHP classique, ce code est parfaitement correct. La variable static $label est initialisée à null à chaque nouvelle requête, et mémorisée le temps de celle-ci.
Pourquoi ça fonctionne en PHP classique
En PHP standard (Apache, PHP-FPM), chaque requête HTTP démarre un nouveau processus PHP qui repart de zéro. Toute la mémoire est libérée à la fin de la requête. Donc static $label est bien réinitialisée à null à chaque fois.
1Requête 1 → nouveau processus → $label = null → calcul → 'TTC' ✅2Requête 2 → nouveau processus → $label = null → calcul → 'HT' ✅1Requête 1 → nouveau processus → $label = null → calcul → 'TTC' ✅2Requête 2 → nouveau processus → $label = null → calcul → 'HT' ✅
Pourquoi c'est un bug avec Octane
Laravel Octane (avec FrankenPHP ou Swoole) fonctionne différemment. L'application est chargée une seule fois en mémoire et des workers restent actifs en permanence pour traiter les requêtes entrantes. Il n'y a plus de "nouveau processus" à chaque requête.
Le problème : les variables static ne sont jamais réinitialisées entre les requêtes dans le même worker. Elles gardent leur valeur indéfiniment.
1Worker 1 — Requête A (utilisateur en France, zone EUR)2 → $label est null → calcul → $label = 'TTC'34Worker 1 — Requête B (utilisateur au Cameroun, zone XAF)5 → $label est déjà 'TTC' (valeur de la requête précédente !)6 → retourne 'TTC' sans recalculer7 → ❌ mauvais label affiché1Worker 1 — Requête A (utilisateur en France, zone EUR)2 → $label est null → calcul → $label = 'TTC'34Worker 1 — Requête B (utilisateur au Cameroun, zone XAF)5 → $label est déjà 'TTC' (valeur de la requête précédente !)6 → retourne 'TTC' sans recalculer7 → ❌ mauvais label affiché
L'utilisateur camerounais voit le label de l'utilisateur français. Et selon l'ordre d'arrivée des requêtes, ça peut s'inverser. Le bug est aléatoire, silencieux, et ne génère aucune erreur.
La distinction importante
Beaucoup confondent les différents usages du mot-clé static en PHP. Voici ce qui pose problème et ce qui ne pose pas problème :
| Syntaxe | Comportement | Danger pour Octane |
|---|---|---|
static $var = null dans une fonction |
Variable persistante entre les appels | ❌ Oui |
Propriété static de classe mutable |
Persistante dans le process | ❌ Oui |
public static function foo() |
Simple méthode appelable sans instance | ✅ Non |
public const CACHE_TTL = 7200 |
Valeur immuable, définie à la compilation | ✅ Non |
La règle : ce n'est pas le mot-clé
staticsur une méthode qui pose problème, c'est le mot-cléstaticsur une variable — parce qu'elle est mutable et persiste dans le worker.
Le fix
La solution est simple : supprimer la memoization. Le coût d'une petite requête SQL supplémentaire est négligeable comparé au risque de données incorrectes en production.
1function current_tax_label(): string2{3 $zone = ZoneSessionManager::getSession();45 if (! $zone instanceof CountryByZoneData) {6 return '';7 }89 $taxZone = TaxZone::query()10 ->whereHas('country', fn ($q) => $q->where('cca2', $zone->countryCode))11 ->whereNull('province_code')12 ->first();1314 return $taxZone?->is_tax_inclusive ? __('TTC') : __('HT');15}1function current_tax_label(): string2{3 $zone = ZoneSessionManager::getSession();45 if (! $zone instanceof CountryByZoneData) {6 return '';7 }89 $taxZone = TaxZone::query()10 ->whereHas('country', fn ($q) => $q->where('cca2', $zone->countryCode))11 ->whereNull('province_code')12 ->first();1314 return $taxZone?->is_tax_inclusive ? __('TTC') : __('HT');15}
Si la performance est vraiment un enjeu, la bonne approche avec Octane est d'utiliser le cache Laravel (Cache::remember(...)) keyed sur un identifiant de requête ou de session — pas une variable statique globale.
À retenir
Si tu utilises Laravel Octane, audite ton code à la recherche de :
static $variabledans des fonctions ou helpers- Propriétés
staticde classes modifiées à l'exécution - Singletons qui stockent un état lié à l'utilisateur ou à la requête
Ces patterns sont courants et fonctionnent parfaitement sans Octane. Avec Octane, ils deviennent des bombes à retardement.
Ce bug a été découvert lors d'une revue architecturale de l'application e-commerce demo multi-zones construite avec Laravel Shopper.