Profiler & Debug
TL;DR — Le Profiler est ton boîte noire Symfony en dev : il enregistre chaque requête avec timing, requêtes SQL, services appelés, événements, cache hits/miss. Active-le uniquement en dev. Les commandes
debug:*t'ouvrent le container, le router, l'event dispatcher sans deviner. Le composantVarDumper(dump(),dd()) remplacevar_dumpavec un rendu HTML/CLI lisible. En prod, utilise Monolog + APM (Blackfire, Tideways) ; jamais le Profiler.
🧠 Mental model — ASCII diagram + analogy
HTTP Request
│
▼
┌─────────────────────────────────────────┐
│ Symfony Kernel │
│ │
│ ┌───────────────────────────────────┐ │
│ │ Profiler (only in dev/test) │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ │ │
│ │ │Collectors│ │Stopwatch│ │ │
│ │ └────┬────┘ └────┬────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ Profile file (var/cache/profiler│ │
│ │ /xx/yy/zzzzzz.bin) │ │
│ └───────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
│
▼
HTTP Response ← WebDebugToolbar injected if HTMLAnalogie : le Profiler est une boîte noire d'avion. Chaque vol (= requête) génère un enregistrement consultable a posteriori via /_profiler. Les collectors sont les capteurs (temps, mémoire, SQL, mail envoyé...). Le Stopwatch est le chronomètre que tu peux dégainer toi-même pour mesurer un bloc précis. Les commandes debug:* sont les schémas de câblage : tu vois comment Symfony s'est branché à la compilation.
🛠️ Code minimal — realistic snippet (PHP 8.2+)
# config/packages/dev/web_profiler.yaml
web_profiler:
toolbar: true
intercept_redirects: false
framework:
profiler:
only_exceptions: false
collect_serializer_data: true # 6.4+ : voir les normalizations<?php
// src/DataCollector/RedisCollector.php — custom collector
namespace App\DataCollector;
use App\Service\RedisClient;
use Symfony\Bundle\FrameworkBundle\DataCollector\AbstractDataCollector;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class RedisCollector extends AbstractDataCollector
{
public function __construct(private RedisClient $redis) {}
public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
{
$this->data = [
'commands' => $this->redis->getRecordedCommands(),
'total_ms' => $this->redis->getTotalTimeMs(),
'hits' => $this->redis->getHits(),
'misses' => $this->redis->getMisses(),
];
}
public function getName(): string { return 'app.redis'; }
public function getCommandCount(): int { return count($this->data['commands'] ?? []); }
public function getTotalTime(): float { return $this->data['total_ms'] ?? 0.0; }
public function getCommands(): array { return $this->data['commands'] ?? []; }
public static function getTemplate(): ?string
{
return 'data_collector/redis.html.twig';
}
}{# templates/data_collector/redis.html.twig #}
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}
{% set icon %}
<span class="icon"><svg>...</svg></span>
<span class="sf-toolbar-value">{{ collector.commandCount }}</span>
<span class="sf-toolbar-label">Redis</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Time</b><span>{{ collector.totalTime|round(2) }} ms</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }}
{% endblock %}
{% block panel %}
<h2>Redis Commands</h2>
<table>
<thead><tr><th>#</th><th>Command</th><th>Args</th><th>Time</th></tr></thead>
<tbody>
{% for c in collector.commands %}
<tr><td>{{ loop.index }}</td><td>{{ c.command }}</td><td>{{ c.args|json_encode }}</td><td>{{ c.time }} ms</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}<?php
// Use Stopwatch in any service
use Symfony\Component\Stopwatch\Stopwatch;
final class ReportBuilder
{
public function __construct(private Stopwatch $stopwatch) {}
public function build(): Report
{
$this->stopwatch->start('report.fetch_data', 'report');
$rows = $this->repo->findAll();
$this->stopwatch->stop('report.fetch_data');
$this->stopwatch->start('report.aggregate', 'report');
$aggregated = $this->aggregate($rows);
$this->stopwatch->stop('report.aggregate');
return new Report($aggregated);
}
}# Debug commands — your daily ninja toolkit
bin/console debug:container # all services
bin/console debug:container --tag=kernel.event_listener
bin/console debug:container UserRepository # focus on one
bin/console debug:autowiring # what types are autowirable
bin/console debug:autowiring HttpClient # search by keyword
bin/console debug:router # all routes
bin/console debug:router api_books_get_collection
bin/console debug:router --show-controllers
bin/console router:match /api/books/42 # which route matches a URL?
bin/console debug:event-dispatcher # all listeners by event
bin/console debug:event-dispatcher kernel.request
bin/console debug:config framework # full resolved config
bin/console debug:translation fr # missing translations
bin/console debug:twig # functions, filters, globals
# Live log streaming
bin/console server:log # tails dev.log nicely formatted
symfony server:log --level=warning # via symfony-cli, filter level<?php
// VarDumper anywhere — never use var_dump again
dump($user, $orders); // logs to toolbar + visible in response if HTML
dd($user); // dump + die — debugging only
// In CLI / tests
\Symfony\Component\VarDumper\VarDumper::setHandler(function($var) {
fwrite(STDERR, print_r($var, true));
});🎯 Patterns courants
Toolbar inline → panel détaillé — le toolbar est la vue rapide, le panel
/_profiler/{token}est la vue forensique. Token visible dans l'URL et dans le headerX-Debug-Token-Link.Custom collector pour service externe — Redis, Elasticsearch, S3, gRPC. Implémente
DataCollectorInterface(ouAbstractDataCollector), tagdata_collector(auto via interface en 6.x).Stopwatch sections —
stopwatch.openSection()/closeSection()regroupe des events (e.g. autour d'un sous-process). Utile pour tracer un Messenger handler.server:log+ filtres — bien plus lisible quetail -f var/log/dev.log. Combine avec--level=criticalen CI.debug:container --env=prod— pour comprendre une régression prod-only, compile le container prod localement et inspecte. Souvent un service estlazyen prod eteageren dev.Profiler en mode CLI — pour les commandes : configure
framework.profiler.collect: trueet accède via/_profiler/latest. Pratique pour profiler une commande Messenger consume.
🔄 Versions
| Symfony | Notes Profiler/Debug |
|---|---|
| 5.4 | Profiler Profile API stable. debug:autowiring existe. |
| 6.0 | Suppression DebugClassLoader legacy. dump() reste. |
| 6.3 | collect_serializer_data arrive — toolbar montre les normalizations. Stopwatch supporte plus de catégories. |
| 6.4 | LTS. Profiler::loadProfile() etc. stable. framework.profiler.collect_parameter (collecte payload). |
| 7.0 | Suppression de méthodes Profiler dépréciées. Twig profile improvements. |
| 7.1+ | Webhook collector, scheduler collector. |
API Platform : son profiler-pane (panneau "API Platform") apparaît dans la toolbar et liste les operations matched, providers/processors invoked.
⚠️ Pitfalls
Profiler activé en prod — fuite massive de données (payloads, secrets en mémoire). Vérifie
web_profiler.toolbar: falseETframework.profiler.collect: falseenprod.yaml. Le bundle entier devrait être enrequire-dev./_profilerexposé — par défaut accessible. Restreins par IP via firewall ou supprime la route en prod. Si tu utilisessymfony/profiler-pack, c'estdevonly par défaut.Mémoire qui explose — chaque requête écrit un
.bindansvar/cache/dev/profiler/. Sur un projet actif, ça atteint plusieurs Go. Nettoie périodiquement (rm -rf var/cache/dev/profiler).Custom collector qui plante — si
collect()throw, la requête entière peut casser. Toujours wrapper danstry/catchet logger silencieusement.dd()en prod oublié — provoque un 500 silencieux. Solution : interdire via grep CI (! grep -r 'dd(' src/) ou un PHPStan rule.Stopwatch sans
stop()— boucle infinie d'events ouverts → memory leak. Utilisetry/finally:php$this->stopwatch->start('foo'); try { /* ... */ } finally { $this->stopwatch->stop('foo'); }server:logne montre pas tout — il litvar/log/dev.log, pas stderr ni d'autres handlers. Si tu loggues vers Slack/Sentry, ces logs n'apparaissent pas.debug:router≠ ordre d'évaluation strict — l'ordre affiché reflète l'enregistrement, mais des routes plus spécifiques peuvent matcher avant. Utiliserouter:matchpour la vérité terrain.
🧪 Testing
<?php
namespace App\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class ProfilerSmokeTest extends WebTestCase
{
public function testProfilerCollectsRequest(): void
{
$client = static::createClient();
$client->enableProfiler();
$client->request('GET', '/');
$profile = $client->getProfile();
self::assertNotFalse($profile);
/** @var \Symfony\Component\HttpKernel\DataCollector\TimeDataCollector $time */
$time = $profile->getCollector('time');
self::assertLessThan(500, $time->getDuration(), 'home page too slow');
}
}🎬 Cas d'usage concrets
Scénario 1 — Debug performance API banque (BPCE, Crédit Agricole DSP2)
Une équipe core banking constate que l'endpoint /api/accounts/{iban}/transactions répond en 2,4s en P95 alors que le SLA est à 800ms. Le Profiler est activé en pré-prod via framework.profiler.collect_serializer_data: true et l'équipe rejoue 50 requêtes via un script curl qui collecte les X-Debug-Token retournés, puis ouvre /_profiler/{token} de chacun. Le panneau Doctrine révèle 487 requêtes SQL (N+1 sur la jointure Account -> Transaction -> Category) au lieu des 3 attendues. La résolution combine : JOIN FETCH dans le DQL, ajout d'un fetch: EAGER ciblé dans le mapping, et un index composite. Le panneau Serializer montre aussi que 30% du temps est passé dans le ObjectNormalizer à cause d'un MaxDepth(5) trop permissif. Réduction à 2 + warmup du ClassMetadataFactory → 2,4s → 380ms en P95. Les debug:event-dispatcher et debug:autowiring ont aussi servi à identifier un listener legacy qui chargeait toute la table User pour vérifier les ACL.
Scénario 2 — Profiler e-commerce checkout slow (PrestaShop SaaS, Shopify-like)
Sur une boutique e-commerce avec 12k SKU, le /checkout/confirm met 4,8s en pic Black Friday. Le profile montre via le panneau Doctrine que la table cart_item est lue 3 fois (problème de hydration). Le panneau Twig révèle qu'un template email/confirmation.html.twig est rendu de manière synchrone dans le controller (envoi mail bloquant). Le panneau Messenger montre que le bus async retient 8 messages avant dispatch. La résolution : déplacer l'envoi de l'email dans un MessageHandler asynchrone (OrderConfirmedMessage), passer la lecture du cart en findOneBy cached avec result_cache, et confirmer le gain via un profil Blackfire avant/après sur la route checkout_confirm. Gain : 4,8s → 620ms. Le toolbar reste activé en pré-prod pour les commerciaux qui valident les commandes test, et debug:translation est utilisé pour repérer les clés manquantes en EN/ES.
Scénario 3 — Debug DMS cabinet (Septeo Avocat, Diapaz, Cellence)
Un DMS cabinet juridique sert 800 avocats. La page de listing des dossiers (/matters) charge en 6s pour les associés qui ont 4 000 dossiers. Le Profiler local (activé en dev avec un dump de prod anonymisé) montre dans le panneau Doctrine : 1 requête de 5,2s sur matter avec un LEFT JOIN documents LEFT JOIN clients LEFT JOIN billings. Solution : pagination obligatoire KnpPaginatorBundle, requête optimisée avec IndexBy pour grouper les documents par dossier en une passe, et ajout d'un index BTREE sur matter.assigned_lawyer_id. Le panneau Form révèle aussi un EntityType qui chargeait les 12 000 clients du cabinet dans une <select> → migration vers Symfony UX Autocomplete. Le bin/console debug:router aide à supprimer 30 routes legacy non utilisées (cabinet précédent), debug:firewall confirme que la séparation client/lawyer est bien câblée, et debug:secrets valide la rotation des clés de chiffrement RGPD des documents.
🛠️ Exemple end-to-end
Cas : pipeline de debug d'une route lente avec collecte custom de spans métier + assertion automatisée.
<?php
// src/DataCollector/MatterListingCollector.php
declare(strict_types=1);
namespace App\DataCollector;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\AbstractDataCollector;
final class MatterListingCollector extends AbstractDataCollector
{
public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
{
$this->data = [
'matters_loaded' => $request->attributes->get('_matters_loaded', 0),
'documents_joined' => $request->attributes->get('_documents_joined', 0),
'cache_hits' => $request->attributes->get('_cache_hits', 0),
'cache_misses' => $request->attributes->get('_cache_misses', 0),
'spans' => $request->attributes->get('_spans', []),
];
}
public function getMattersLoaded(): int
{
return $this->data['matters_loaded'];
}
public function getSpans(): array
{
return $this->data['spans'];
}
public static function getTemplate(): ?string
{
return 'data_collector/matter_listing.html.twig';
}
public function getName(): string
{
return 'app.matter_listing_collector';
}
}<?php
// src/Controller/MatterController.php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\MatterRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Stopwatch\Stopwatch;
final class MatterController extends AbstractController
{
#[Route('/matters', name: 'app_matters_list', methods: ['GET'])]
public function list(Request $request, MatterRepository $repo, Stopwatch $stopwatch): Response
{
$stopwatch->start('matters.query', 'persistence');
$matters = $repo->findPaginatedForLawyer($this->getUser(), page: (int) $request->query->get('page', 1));
$stopwatch->stop('matters.query');
$request->attributes->set('_matters_loaded', count($matters));
$request->attributes->set('_spans', [
'matters.query' => $stopwatch->getEvent('matters.query')->getDuration(),
]);
return $this->render('matter/list.html.twig', ['matters' => $matters]);
}
}# config/services.yaml
services:
App\DataCollector\MatterListingCollector:
tags:
- { name: data_collector, template: 'data_collector/matter_listing.html.twig', id: 'app.matter_listing_collector' }<?php
// tests/Performance/MatterListingPerformanceTest.php
declare(strict_types=1);
namespace App\Tests\Performance;
use App\Factory\MatterFactory;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
final class MatterListingPerformanceTest extends WebTestCase
{
use Factories;
use ResetDatabase;
public function testListingDoesNotTriggerNPlusOne(): void
{
MatterFactory::createMany(50);
$client = static::createClient(['debug' => true]);
$client->enableProfiler();
$client->request('GET', '/matters');
$profile = $client->getProfile();
self::assertNotFalse($profile);
/** @var \Doctrine\Bundle\DoctrineBundle\DataCollector\DoctrineDataCollector $doctrine */
$doctrine = $profile->getCollector('db');
self::assertLessThan(
5,
$doctrine->getQueryCount(),
sprintf('Trop de requêtes SQL (%d), N+1 probable', $doctrine->getQueryCount()),
);
/** @var \Symfony\Component\HttpKernel\DataCollector\TimeDataCollector $time */
$time = $profile->getCollector('time');
self::assertLessThan(300, $time->getDuration(), 'Listing trop lent : > 300ms');
}
}# Investigation interactive
bin/console debug:autowiring MatterRepository
bin/console debug:event-dispatcher kernel.request
bin/console doctrine:query:dql "SELECT m FROM App\Entity\Matter m" --hydrate=array | headCouverture : data collector custom + spans Stopwatch + assertion automatisée sur le nombre de requêtes SQL. Le test échoue si un dev casse l'optimisation N+1.
🏛️ Architecture interne — comment ça marche vraiment
Comprendre la mécanique te permet de débugger le débuggueur lui-même et de raisonner sur l'overhead.
Request ──► HttpKernel::handle()
│
│ kernel.request, kernel.controller, kernel.response ...
│ (chaque DataCollector écoute via lateCollect ou Profiler::collect)
▼
ProfilerListener (kernel.response, priorité -100)
│
│ $profiler->collect($request, $response, $exception)
│ └─► boucle sur tous les DataCollector → ->collect()
│ └─► réécrit $this->data (sérialisable) sur chaque collector
▼
Profiler::saveProfile($profile)
│
▼
ProfilerStorage (FileProfilerStorage par défaut)
│ dsn: file:%kernel.cache_dir%/profiler
▼
var/cache/dev/profiler/<2 hex>/<2 hex>/<token> (+ index.csv)Points clés que peu de devs connaissent :
collect()vslateCollect():collect()est appelé surkernel.response. Si ton collector dépend de données produites après (ex : un service qui finalise un buffer à la destruction du kernel), implémenteLateDataCollectorInterface::lateCollect(). C'est ce que faitDoctrineDataCollector: il clone les logs SQL tard pour capturer les requêtes dukernel.terminate.- Sérialisation de
$this->data: tout ce que tu mets dans$this->dataest sérialisé sur disque. Mettre un objet entité Doctrine non détaché y embarque le proxy + l'EntityManager → fichier.binénorme etunserializequi casse. Règle : ne stocke que des scalaires, arrays, etDataclones (cloneVar()).AbstractDataCollector::cloneVar()produit une structureVarDumper-safe. - Le token :
X-Debug-Tokenest un identifiant de 6 caractères base62.X-Debug-Token-Linkte donne l'URL directe — indispensable pour profiler une requête API/JSON où il n'y a pas de toolbar HTML injectée. Sur un client REST (Bruno, curl, Insomnia), lis ce header pour ouvrir le profile. - Index.csv :
Profiler::find()(la liste/_profiler) lit un CSV append-only, pas chaque.bin. C'est pourquoi la recherche par IP/URL/statut est rapide même avec des milliers de profils.
Tableau de décision — collector vs Stopwatch vs Monolog vs APM
| Besoin | Outil | Pourquoi |
|---|---|---|
| Voir le timing d'un bloc précis en dev | Stopwatch | Apparaît dans le panneau Performance, gratuit, zéro infra |
| Exposer l'état d'un service custom dans la toolbar | DataCollector | Visibilité par requête, intégré au profile forensique |
| Tracer un incident en prod a posteriori | Monolog + processor (request_id) | Persistant, agrégé, pas d'overhead Profiler |
| Mesurer une régression de perf fiable | Blackfire / Tideways | Échantillonnage statistique, call-graph, pas l'overhead 10-30% du Profiler |
| Suivre P95/P99 en continu | APM (Datadog, New Relic) | Métriques temporelles, alerting, pas du one-shot |
Le réflexe staff : le Profiler répond à "pourquoi CETTE requête est lente", l'APM répond à "quelles requêtes sont lentes statistiquement". Ne confonds jamais les deux.
🔬 Comment un staff engineer raisonne
Token-first sur une API. Le premier réflexe sur un endpoint JSON lent n'est pas
dump(), c'est lireX-Debug-Token-Linkdans la réponse et ouvrir le panneau Doctrine + Performance.dump()dans une réponse JSON casse le payload (HTML injecté) et te fait perdre du temps.Mesurer avant d'optimiser, mais avec le bon outil. Le Profiler ajoute 10-30% d'overhead et fausse les temps absolus. Pour un "combien de fois cette requête s'exécute" → Profiler. Pour un "combien de ms réelles" → Blackfire ou un
Stopwatchciblé hors Profiler. Confondre les deux mène à optimiser un faux hotspot.Le container de prod n'est pas celui de dev. Une régression "prod-only" se débugge avec
bin/console debug:container --env=prod --no-debug: serviceslazy, listeners désactivés,kernel.debug=falsequi change le comportement du cache d'annotations. Compiler le container prod localement révèle 80% de ces cas sans déployer.Assertion, pas inspection. Un staff transforme une investigation Profiler en garde-fou permanent :
$doctrine->getQueryCount()dans un test fonctionnel. Une investigation qui ne laisse pas de test est une dette : le N+1 reviendra au prochain refactor.Le Profiler est une surface d'attaque. En pré-prod accessible publiquement,
/_profilerexpose payloads, env vars, requêtes SQL, sessions. Un staff exige un firewall IP ou unaccess_controlsur^/_(profiler|wdt)même en pré-prod, et le bundle enrequire-devpour qu'il ne soit physiquement pas présent en image prod.
🔐 Sécurité & production — checklist
web-profiler-bundleetdebug-bundledoivent être dansrequire-devducomposer.json. Une image prod construite aveccomposer install --no-devne contient alors pas le code du Profiler — défense en profondeur, pas juste de la config.Vérifie en CI que la route
_profilern'existe pas en prod :bashAPP_ENV=prod bin/console debug:router 2>/dev/null | grep -q _profiler \ && { echo "FAIL: profiler route exposed in prod"; exit 1; } || echo "OK"Interdis
dd(/dump(/var_dump(/xdebug_break(danssrc/via grep CI ou une règle PHPStan (forbidden function calls) :bash! grep -rnE '\b(dd|dump|var_dump|ray)\s*\(' src/ \ || { echo "FAIL: debug call left in src/"; exit 1; }Le Profiler ne doit jamais écrire dans un volume monté partagé en prod ;
framework.profiler.collect: falseET pas de DSN storage prod.Anonymise les dumps de prod rejoués en local (PII, IBAN, secrets) avant d'activer le Profiler dessus — un
.binest lisible en clair.
🏋️ Exercices
Exercice 1 — Collector HTTP client (implémentation)
Objectif : créer un DataCollector qui compte les appels HTTP sortants (HttpClientInterface), leur durée cumulée, et le taux de 4xx/5xx, affichés dans la toolbar.
Indice/Solution : décore HttpClientInterface avec un TraceableHttpClient (Symfony fournit déjà framework.http_client traçable — inspecte getTracedRequests()), ou wrappe-le toi-même. Stocke uniquement des scalaires dans $this->data. Tag automatique via extends AbstractDataCollector. Bonus : colore l'icône en rouge si un appel > 1s.
Exercice 2 — Test anti-N+1 réutilisable (production-grade)
Objectif : écrire un trait AssertsQueryBudget réutilisable qui fait échouer un WebTestCase si le nombre de requêtes SQL d'une route dépasse un budget passé en paramètre, avec un message qui liste les requêtes en doublon.
Indice/Solution : enableProfiler(), $profile->getCollector('db'), getQueryCount(). Pour lister les doublons, parcours $doctrine->getQueries() (par connexion), normalise le SQL (retire les littéraux), groupe et compte. Échoue avec un diff lisible. Branche-le sur 5 routes critiques en CI.
Exercice 3 — Stopwatch hiérarchique sur un Messenger handler (production-grade)
Objectif : instrumenter un handler Messenger lourd avec des sections Stopwatch imbriquées (openSection/closeSection) et exposer les spans dans un collector custom, de sorte que /_profiler/latest montre la décomposition fetch / transform / persist.
Indice/Solution : active framework.profiler.collect: true + un messenger:consume -vv --limit=1. Ouvre une section par message, sous-start()/stop() par phase. Attention : un stop() manquant en cas d'exception fuit — entoure chaque phase de try/finally.
Exercice 4 — Casser puis réparer la sérialisation (break-then-fix)
Objectif : reproduire un .bin corrompu en stockant une entité Doctrine vivante dans $this->data, observer l'erreur unserialize/le profile illisible, puis corriger.
Indice/Solution : mets $this->data = ['user' => $user] (entité avec proxy + relations). Ouvre /_profiler → erreur ou fichier géant. Fix : $this->data = ['user' => $this->cloneVar($user)] ou ne garde que $user->getId() + scalaires. Mesure la taille du .bin avant/après. Leçon : un collector ne doit jamais retenir d'objet à graphe profond.
Exercice 5 — Régression prod-only via le container (break-then-fix)
Objectif : créer un service qui se comporte différemment selon kernel.debug, le voir marcher en dev et casser en prod, puis diagnostiquer sans déployer.
Indice/Solution : injecte %kernel.debug% et branche un comportement dessus. APP_ENV=prod APP_DEBUG=0 bin/console cache:clear puis debug:container --env=prod nom.service pour voir la définition compilée (arguments résolus, lazy, public/private). Compare avec --env=dev. Le diagnostic doit se faire 100% via debug:*, jamais par dump() en prod.
Exercice 6 — Mini-APM maison (architecte)
Objectif : construire un kernel.terminate listener qui, hors Profiler, pousse route + duration_ms + query_count + memory_peak vers Monolog au format JSON avec un request_id, de façon à reconstituer en prod ce que le Profiler donne en dev.
Indice/Solution : Stopwatch n'est pas dispo proprement en prod ; mesure via microtime(true) capturé sur kernel.request (stocké en attribut) et memory_get_peak_usage(). Pour query_count, utilise un middleware Doctrine ou le DebugStack-like maison. Ajoute un Monolog\Processor qui injecte le request_id (UUID v4) partout. Tu obtiens des logs corrélables — la fondation d'un APM.
🎤 En entretien
Q : Pourquoi ne jamais laisser le Profiler actif en production, au-delà de "c'est lent" ? R : Deux raisons distinctes. (1) Sécurité : /_profiler expose payloads, env vars, SQL, sessions — c'est une fuite de données et une surface d'attaque. (2) Coût : chaque requête sérialise un .bin sur disque (I/O + saturation du volume) avec 10-30% d'overhead. Le bon design met le bundle en require-dev pour qu'il soit absent de l'image prod, pas seulement désactivé par config.
Q : Tu as un endpoint JSON lent en pré-prod, pas de toolbar. Comment tu profiles ? R : Je lis le header X-Debug-Token-Link de la réponse, qui me donne l'URL directe /_profiler/{token}. J'ouvre les panneaux Performance et Doctrine. Pour une régression de perf réelle (ms absolues), je préfère Blackfire car le Profiler fausse les temps de 10-30%. Le Profiler me dit combien de requêtes SQL et lesquelles, pas le temps mur fiable.
Q : Quelle est la différence entre collect() et lateCollect() dans un DataCollector, et pourquoi ça compte ? R : collect() est appelé sur kernel.response. lateCollect() (via LateDataCollectorInterface) est appelé juste avant la sérialisation, après que tous les autres collectors ont tourné — utile pour capturer des données finalisées tard (comme Doctrine qui clone ses logs SQL à ce moment). Et règle d'or : ne stocker que des scalaires/arrays/cloneVar() dans $this->data, sinon la sérialisation du profile explose ou casse.
Q : Comment garantir qu'une optimisation N+1 ne régresse pas ? R : Un test fonctionnel avec enableProfiler() qui lit $profile->getCollector('db')->getQueryCount() et asserte un budget (assertLessThan(5, ...)). On transforme l'investigation ponctuelle du Profiler en garde-fou CI permanent. Sans ce test, le N+1 revient au prochain refactor d'hydration.
🔁 Quand utiliser / éviter
Utiliser :
- Dev quotidien : toolbar pour spot N+1, Twig render time, mémoire.
- Investigation d'un bug spécifique : ouvre le profile du token incriminé.
- Onboarding :
debug:containeretdebug:routerpour explorer un projet inconnu.
Éviter :
- En prod : ce n'est pas un APM. Pour le monitoring continu, utilise Blackfire, Tideways, New Relic, ou Datadog.
- Pour mesurer des performances réalistes : le profiler ralentit (overhead 10-30%). Pour benchmark, utilise Blackfire ou désactive-le.
- Pour logger en prod : utilise Monolog, pas
dump().
🔗 Liens
- Profiler : https://symfony.com/doc/current/profiler.html
- Custom collectors : https://symfony.com/doc/current/profiler/data_collector.html
- Stopwatch : https://symfony.com/doc/current/components/stopwatch.html
- VarDumper : https://symfony.com/doc/current/components/var_dumper.html
- Debug commands : https://symfony.com/doc/current/console/index.html
- Blackfire : https://blackfire.io