Table des matières :
- Pré-requis et périmètre : Symfony/Doctrine dans PrestaShop, et ce que Scout va (vraiment) voir
- Installation et configuration Scout APM sur une stack Symfony (FPM + CLI)
- Lire une trace Scout : transactions, spans SQL, et signaux faibles d’un N+1
- Cas concret : un N+1 Doctrine dans un module PrestaShop (et le correctif)
- Réduire la dette perf Doctrine : patterns anti N+1 qui tiennent en prod
- Memory bloat : diagnostiquer l’hydratation massive, les workers, et les fuites “logiques”
- Mettre Scout au service de la remédiation : alerting, qualité, et limites en production
Pré-requis et périmètre : Symfony/Doctrine dans PrestaShop, et ce que Scout va (vraiment) voir
Contexte typique côté e‑commerce : PrestaShop 8.x/9.x exécute une partie du Back Office et certains points d’extension via Symfony, avec Doctrine pour la couche ORM dans les pages “modernes” et dans vos modules (controllers Symfony, commandes, services). Les exemples ci-dessous sont pensés pour PrestaShop 9.1, PHP 8.2 (valable pour 8.1+), et une stack Doctrine ORM 2.x via doctrine/orm + doctrine/doctrine-bundle. Adaptez si vous êtes encore sur PrestaShop 8.x (Symfony plus ancien) : les concepts restent identiques, mais certains points de configuration Doctrine changent.
Avant de parler “détection N+1” et “memory bloat”, clarifions le périmètre observé dans un shop PrestaShop :
- Ce que Scout verra très bien : transactions HTTP Symfony (routes BO “modernes”, pages de modules en Symfony), commandes CLI Symfony, et généralement les appels SQL (PDO) déclenchés dans ces transactions.
- Ce que Scout verra partiellement : pages hybrides où Symfony et legacy cohabitent (ex. certaines pages BO/FO historiques), ou des traitements déclenchés via hooks qui appellent beaucoup de legacy. Vous aurez du signal (durée, SQL, mémoire), mais parfois moins de “structure” côté framework.
- Ce que Scout ne remplacera pas : les métriques système (CPU, IO disque, saturation MySQL), ni une stratégie de cache, ni une refonte d’architecture. Un APM vous met face aux faits, il ne “répare” rien tout seul.
Scout Monitoring (Scout APM) côté PHP fonctionne généralement via une extension PHP (agent) chargée par PHP‑FPM/CLI. Ce point est critique : si l’extension n’est chargée que dans FPM, vous n’aurez aucune visibilité sur vos commandes Symfony (imports, jobs CRON, workers Messenger) qui sont précisément des endroits où le memory bloat explose. Vérifiez donc les deux SAPI : php -m | grep -i scout et php-fpm -i | grep -i scout.
Enfin, gardez à l’esprit une limite structurelle : le core PrestaShop n’est pas observability‑first. Vous aurez des requêtes SQL héritées, des hooks qui multiplient les appels, et des pages hybrides qui n’entrent pas dans le périmètre Symfony/Doctrine. Scout vous aidera à isoler des transactions et des requêtes, mais pas à “réparer” les choix d’architecture. Si vous développez des modules Doctrine dans PrestaShop, vous avez intérêt à partir sur des patterns robustes et testables (références concrètes dans l’article interne : Symfony PrestaShop : développer des modules robustes avec Doctrine).
Installation et configuration Scout APM sur une stack Symfony (FPM + CLI)
L’intégration propre commence par une installation système cohérente. Sur Debian/Ubuntu, vous déployez typiquement un fichier .ini dans les répertoires de configuration pour FPM et CLI. Exemple (à adapter selon version PHP et chemins) :
; /etc/php/8.2/fpm/conf.d/99-scoutapm.ini
extension=scoutapm.so
scoutapm.key=${SCOUT_KEY}
scoutapm.name=prestashop-prod
scoutapm.monitor=true
scoutapm.log_level=warn
; Recommandé en prod : éviter de capturer des valeurs sensibles
scoutapm.collect_payloads=false
Même logique côté CLI : /etc/php/8.2/cli/conf.d/99-scoutapm.ini. Dans un contexte PrestaShop, l’oubli le plus fréquent est le worker longue durée (Messenger ou cron “maison”) lancé par systemd : si vous ne passez pas les variables d’environnement (SCOUT_KEY, SCOUT_MONITOR=true, SCOUT_NAME=...) à l’unité systemd, Scout ne verra rien. Côté conteneurs, assurez‑vous aussi que l’extension est présente dans l’image et activée pour les deux SAPI.
Exemple minimal (indicatif) pour systemd, l’idée étant surtout de ne pas “perdre” les variables au démarrage :
# /etc/systemd/system/prestashop-messenger.service
[Service]
Environment="SCOUT_KEY=xxx"
Environment="SCOUT_MONITOR=true"
Environment="SCOUT_NAME=prestashop-prod"
ExecStart=/usr/bin/php /var/www/html/bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
Restart=always
Sur Symfony, vous n’avez pas besoin d’ajouter du code pour obtenir une première valeur : l’agent instrumente la requête (transaction) et, selon la configuration, les appels SQL. En revanche, pour une exploitation sérieuse (corrélation erreurs, tags métier, instrumentation de segments), vous finirez tôt ou tard par ajouter de l’instrumentation applicative au bon niveau : typiquement autour d’un use‑case (ex. “Import transporteur”, “Sync ERP”, “Export marketplace”), pas autour de micro‑fonctions.
Si vous cherchez une base saine côté gestion d’erreurs (logger/monolog, séparation dev/prod, niveaux, stack traces), référez‑vous à : Gestion d’erreur PHP : bonnes pratiques et configuration développement/production.
Deux rappels de “garde‑fous” en prod :
- l’overhead existe (mesure, export), donc évitez tout ce qui ressemble à du tracing “full fidelity” partout, surtout sur des boutiques à fort trafic ;
- ne remontez pas de PII par accident (paramètres de requêtes, contenus de formulaires).
Sur PHP, la directive memory_limit du runtime est un exemple de contrainte dure : le manuel PHP est explicite — « This sets the maximum amount of memory in bytes that a script is allowed to allocate. » (PHP Manual, directive memory_limit, PHP Manual — memory_limit). Les pages qui “tiennent” au dev peuvent simplement mourir au premier pic en prod.
Et côté conformité (très concret en France/UE), rappelez‑vous que l’APM est un traitement de données : minimisation, finalité, et contrôle des données remontées. La base “texte” utile à garder sous la main est le RGPD (règlement (UE) 2016/679) publié sur EUR‑Lex : EUR‑Lex — RGPD (règlement (UE) 2016/679) (notamment les principes de l’article 5 sur la minimisation).
Lire une trace Scout : transactions, spans SQL, et signaux faibles d’un N+1
Sur Symfony/Doctrine, un N+1 ne se “voit” pas en regardant le code : il se voit en profilant une transaction réelle, avec des données réalistes. Dans Scout, vous partez d’une transaction lente (endpoint Back Office, API, page admin module), puis vous inspectez la timeline : une grappe de spans SQL très similaires (même SELECT ... FROM ... WHERE ... = ?) répétée des dizaines/centaines de fois est le pattern classique.
Définition opérationnelle : le N+1 se produit quand vous chargez une collection d’entités (N) puis que chaque entité déclenche une requête additionnelle (+1) via lazy‑loading (ou des accès indirects à des associations). Exemple : vous récupérez 100 commandes, puis en template/twig ou dans une boucle, vous accédez à $order->getCustomer()->getEmail() sur une relation lazy : Doctrine initialise l’association à la demande, et vous vous retrouvez avec 101 requêtes au lieu de 1 à 3.
Scout ne “taguera” pas forcément explicitement “N+1” (selon agent/version/langage), mais la lecture est mécanique :
- Nombre total de requêtes élevé sur une seule transaction (ex. 200+) ;
- Requêtes identiques avec juste un bind qui change ;
- Temps cumulé SQL dominant (ex. 70–90% de la durée transaction) ;
- Et parfois un effet secondaire : montée mémoire par hydratation d’objets Doctrine (UnitOfWork/identity map) alors que vous ne manipulez que des champs simples.
Pour rendre l’analyse plus systématique (et moins “au feeling”), gardez ce mini‑checklist quand vous ouvrez une trace :
- Est‑ce que les requêtes répétées concernent une association (customer, currency, shop, carrier…) ?
- Est‑ce que les répétitions sont déclenchées côté rendu (Twig), sérialisation JSON, normalizer, provider de grille ?
- Est‑ce qu’on voit un pattern “1 requête pour la liste” puis “1 requête par ligne” ?
- La transaction est‑elle lente à cause de SQL ou d’un mélange SQL + CPU (transformation, concat, logs) ?
- Le volume de données est‑il “réaliste prod” (ex. boutique multi‑boutique, historique commandes long, clients B2B avec beaucoup d’adresses) ?
Pour l’impact, ne raisonnez pas “latence SQL unitaire” : un N+1 avec des requêtes à 2–5 ms fait quand même exploser le temps total, sans compter la charge sur MySQL et le pool de connexions. C’est exactement le genre de dérive qui finit par transformer un serveur “ok” en serveur saturé, puis par déclencher du nettoyage en urgence (verrous, tables gonflées, historiques). À ce stade, l’article interne sur la routine de maintenance DB est un bon complément : Base de données PrestaShop : routine de maintenance et nettoyage automatisé.
Cas concret : un N+1 Doctrine dans un module PrestaShop (et le correctif)
Scénario réaliste : dans un controller Symfony de module (Back Office), vous listez des entités “métier” associées à un employé, et vous affichez aussi des données d’une relation (ex. customer, shop, currency). La première version, écrite rapidement, utilise un findBy() sur le repository puis itère. En dev, avec 10 lignes, ça passe ; en prod, avec 500 lignes, Scout montre 500 requêtes supplémentaires.
Exemple volontairement “mauvais” :
// PrestaShop 9.1 / Symfony 6.4 / Doctrine ORM 2.x
$orders = $orderRepository->findBy(['shop' => $shopId], ['id' => 'DESC'], 200);
foreach ($orders as $order) {
// Déclenche potentiellement une requête par order si association lazy
$email = $order->getCustomer()->getEmail();
$iso = $order->getCurrency()->getIsoCode();
}
Ce qui se passe réellement : findBy() charge Order uniquement. customer et currency restent en proxy lazy. Le premier accès à getCustomer() déclenche un SELECT ; le deuxième, idem, etc. En trace Scout, vous verrez typiquement :
- un
SELECT ... FROM orders ... LIMIT 200; - suivi d’une série de
SELECT ... FROM customer WHERE id = ?; - et d’une série de
SELECT ... FROM currency WHERE id = ?.
Correction standard : fetch join ciblé via QueryBuilder, avec addSelect() pour forcer Doctrine à hydrater les associations en une seule requête (ou en un petit nombre maîtrisé). Cela réduit le nombre de requêtes et stabilise le temps DB. Exemple :
$qb = $orderRepository->createQueryBuilder('o')
->leftJoin('o.customer', 'c')->addSelect('c')
->leftJoin('o.currency', 'cur')->addSelect('cur')
->andWhere('o.shop = :shop')
->setParameter('shop', $shopId)
->orderBy('o.id', 'DESC')
->setMaxResults(200);
$orders = $qb->getQuery()->getResult();
En pratique, faites ensuite un aller‑retour Scout :
- Rechargez la page avec un volume comparable prod (ou des fixtures proches) ;
- Comparez avant/après : nombre total de requêtes, temps cumulé SQL, et mémoire ;
- Vérifiez qu’il n’y a pas d’effet de bord : doublons, tri cassé, filtres qui changent.
Trois points à ne pas ignorer en contexte PrestaShop :
- les “listings admin” finissent souvent par vouloir trier/filtrer sur des colonnes de relations : si vous ne maîtrisez pas les JOIN, vous allez produire du SQL non indexé ou des résultats dupliqués ;
- la tentation de
EAGERau niveau mapping est une mauvaise réponse : vous déplacez le problème, vous ne le corrigez pas ; - si votre page n’a besoin que de 4 champs, n’hydratez pas une arborescence d’objets : utilisez des projections (DTO/array hydration) ou du SQL maîtrisé.
À noter (cas fréquent) : si vous faites un fetch join sur une association to‑many (ex. o.orderDetails) et que vous mettez setMaxResults(), vous risquez d’obtenir des résultats surprenants (doublons au niveau PHP, pagination faussée). Dans ce cas, une stratégie courante est :
- paginer d’abord sur les IDs (requête “light”) ;
- puis charger le graphe sur ce sous‑ensemble (requête “heavy” avec JOIN maîtrisés).
Réduire la dette perf Doctrine : patterns anti N+1 qui tiennent en prod
Le fetch join n’est pas l’unique arme. Sur des pages “grilles” (listes) ou des exports, le bon pattern est souvent une projection : vous sélectionnez exactement les colonnes nécessaires, vous récupérez un tableau (getArrayResult()), vous mappez vers un DTO simple, et vous n’instanciez pas 200 entités + leur graphe. Résultat : moins de requêtes, mais surtout moins de mémoire consommée par l’UnitOfWork.
Exemple d’intention (sans surcharger) : pour une grille “Commandes”, vous avez souvent besoin de id, reference, total_paid, customer.email, currency.iso_code. Une projection vous évite de “traîner” l’entité complète et ses proxys.
Pour choisir rapidement le bon outil selon le cas d’usage, cette grille de décision aide :
| Besoin | Approche recommandée | Pourquoi |
|---|---|---|
| Affichage BO “liste” (colonnes simples) | Projection (<code>ArrayResult</code> / DTO) | Moins d’hydratation, moins de mémoire, plus prévisible |
| Affichage BO “détail” (gros graphe) | Fetch join ciblé | Limite les requêtes sans charger “tout” |
| Export/flux long (10k+) | Itération + batch (flush/clear) | Stabilité mémoire, temps total stable |
| Accès ponctuel à une relation | Lazy OK (mais explicite) | Évite de surcharger la requête de base |
Deuxième pattern très efficace : la pagination et l’itération batch. Doctrine documente depuis longtemps les traitements en lots (batch processing) : itérer en paquets, flusher, et surtout clear() l’EntityManager pour libérer l’identity map. Sur des imports/synchronisations (cas ultra courant en PrestaShop), c’est la différence entre un job stable et un process qui gonfle jusqu’au kill par memory_limit. Doc officielle : Doctrine — Batch processing
Troisième pattern : rendre explicite ce qui doit être lazy et ce qui doit être chargé. Les problèmes N+1 apparaissent souvent “ailleurs” que dans le repository : dans un normalizer, un serializer, un template, un hook, ou un provider de grille. La discipline efficace est simple :
- le repository définit le graphe de données nécessaire à un use‑case ;
- le rendu (Twig, JSON) ne déclenche aucun accès lazy inattendu ;
- et vous mesurez avec Scout sur des datasets réalistes.
Cette approche rejoint un principe d’ingénierie plus large : Knuth a popularisé l’idée que l’optimisation “trop tôt” est contre‑productive (“premature optimization…”, Structured Programming with go to Statements, 1974). Traduction concrète ici : n’optimisez pas “à l’intuition”, mais dès que Scout prouve un N+1 en prod/staging, vous corrigez au niveau du graphe de chargement et des index, pas en ajoutant du cache au hasard.
Memory bloat : diagnostiquer l’hydratation massive, les workers, et les fuites “logiques”
En PHP‑FPM, une requête web “meurt” puis le process est recyclé : ça masque certains problèmes. Le memory bloat devient critique dans les processus long‑running : commandes Symfony, workers Messenger, scripts d’import, synchronisation ERP/transporteurs, génération de flux, etc. Dans PrestaShop, ces traitements sont fréquents (et souvent écrits dans des modules), donc il faut instrumenter CLI avec Scout, pas seulement HTTP.
Les causes les plus communes côté Doctrine/Symfony :
- hydratation d’un trop gros dataset (
findAll(), export sans pagination) ; - accumulation dans l’UnitOfWork (vous chargez/instanciez des entités et vous ne
clear()jamais) ; - services stateful qui stockent des tableaux d’objets (collecteurs, caches maison) ;
- logs trop verbeux ou exceptions répétées qui retiennent de gros contextes ;
- workers “infinis” qui ne redémarrent jamais (et finissent par fragmenter la mémoire).
Scout, selon son niveau d’instrumentation PHP, vous donnera au minimum des métriques de mémoire par transaction et vous permettra de repérer les transactions “anormalement” gourmandes. Le bon réflexe est de distinguer :
- pic mémoire acceptable (ex. une génération PDF ponctuelle) ;
- croissance monotone (suspecte dans un loop / worker) ;
- variabilité extrême (souvent liée aux volumes, à des jointures mal maîtrisées, ou à des payloads/logs).
Pour isoler, cherchez une corrélation : montée mémoire monotone au fil des itérations + temps CPU/GC qui grimpe. Ensuite, corrigez avec des primitives simples et testables :
- traiter en pages (
LIMIT/OFFSETou pagination par id) ; - utiliser
toIterable()/ un curseur DBAL quand applicable (gros volumes) ; flush()/clear()régulièrement (ex. toutes les 100–500 entités) ;- éviter de garder des références (tableaux d’entités) plus longtemps que nécessaire ;
- et désactiver tout debug SQL en prod.
Exemple de squelette de job batch (Doctrine ORM 2.x) :
$batchSize = 200;
$i = 0;
foreach ($repo->iterateForExport($shopId) as $entity) {
$exporter->push($entity);
if ((++$i % $batchSize) === 0) {
$entityManager->clear();
gc_collect_cycles();
}
}
Deux précisions “terrain” (souvent oubliées) :
clear()vide l’identity map : si vous gardez des objets Doctrine ailleurs (ex. dans un service singleton), ils deviennent “détachés”. C’est normal en batch, mais il faut le savoir.- sur un worker Symfony Messenger, vous pouvez aussi limiter la durée de vie du process avec
--time-limit/--memory-limitet laisser systemd superviser. Ce n’est pas une “rustine” : c’est une stratégie de robustesse pour éviter qu’un daemon PHP vive des jours.
Sur la partie “observabilité”, le concept n’est pas magique : c’est la capacité à déduire l’état interne (ici : volumes hydratés, boucles, effets des proxys Doctrine) à partir de sorties mesurables (temps SQL, nombre de requêtes, mémoire, erreurs). En e‑commerce, l’enjeu est très concret : un job d’import qui “gonfle” jusqu’à 1–2 Go peut finir par impacter le reste (swap, CPU, contention IO), même si le front semble “aller”.
Mettre Scout au service de la remédiation : alerting, qualité, et limites en production
Une fois N+1 et memory bloat identifiés, l’objectif n’est pas de “jolies courbes” : c’est de poser des seuils et des régressions interdites. Concrètement : créez des alertes sur (a) p95/p99 de endpoints clés BO/API, (b) nombre de requêtes SQL par transaction, (c) mémoire moyenne/max par transaction, (d) erreurs PHP fatales. L’approche est complémentaire des enjeux front (CWV) : si le Back Office ou l’API se dégrade, l’impact finit par se voir sur les temps de réponse et donc sur l’expérience globale. Référence utile côté performance perçue : Core Web Vitals : actions concrètes pour améliorer LCP, INP et CLS.
Une manière simple (et réaliste) de démarrer sans inventer des “chiffres magiques” :
- Prenez 3 parcours BO réellement utilisés (ex. liste commandes, fiche commande, page module critique).
- Mesurez sur staging avec un dataset proche prod.
- Posez des alertes sur une hausse relative (ex. +50% sur le temps SQL cumulé, x2 sur le nombre de requêtes, +100 Mo de pic mémoire) plutôt que sur une valeur absolue dès le premier jour.
Côté qualité technique, vous pouvez industrialiser : en staging, rejouez un scénario (fixtures de commandes, clients, produits), capturez une trace Scout, et comparez (baseline vs branche). Si une MR double le temps SQL ou fait passer une page de 15 requêtes à 150, vous bloquez. Ce n’est pas “du luxe” : dans PrestaShop, les hooks et surcharges peuvent additionner des coûts invisibles. L’observabilité est une discipline, pas un plugin.
Enfin, assumez les limites :
- l’agent APM peut rater des zones (legacy FO, appels externes non instrumentés), donc complétez avec logs structurés et métriques système ;
- la confidentialité : masquez les paramètres et évitez de remonter des payloads ;
- la dérive “tool‑driven” : on ne corrige pas un N+1 en ajoutant du cache global, on le corrige dans les requêtes, les index, et le graphe de chargement.
Pour les bases Doctrine et la structuration correcte des repositories/services, revenez à l’article interne (utile aussi pour éviter les “repositories fourre‑tout”) : Développer des modules Symfony/Doctrine robustes dans PrestaShop. Et pour la documentation amont, gardez sous la main : docs Scout PHP et docs Symfony/Doctrine.




