Skip to content

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 composant VarDumper (dump(), dd()) remplace var_dump avec 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 HTML

Analogie : 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+)

yaml
# 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
<?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';
    }
}
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
<?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);
    }
}
bash
# 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
<?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

  1. 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 header X-Debug-Token-Link.

  2. Custom collector pour service externe — Redis, Elasticsearch, S3, gRPC. Implémente DataCollectorInterface (ou AbstractDataCollector), tag data_collector (auto via interface en 6.x).

  3. Stopwatch sectionsstopwatch.openSection() / closeSection() regroupe des events (e.g. autour d'un sous-process). Utile pour tracer un Messenger handler.

  4. server:log + filtres — bien plus lisible que tail -f var/log/dev.log. Combine avec --level=critical en CI.

  5. debug:container --env=prod — pour comprendre une régression prod-only, compile le container prod localement et inspecte. Souvent un service est lazy en prod et eager en dev.

  6. Profiler en mode CLI — pour les commandes : configure framework.profiler.collect: true et accède via /_profiler/latest. Pratique pour profiler une commande Messenger consume.

🔄 Versions

SymfonyNotes Profiler/Debug
5.4Profiler Profile API stable. debug:autowiring existe.
6.0Suppression DebugClassLoader legacy. dump() reste.
6.3collect_serializer_data arrive — toolbar montre les normalizations. Stopwatch supporte plus de catégories.
6.4LTS. Profiler::loadProfile() etc. stable. framework.profiler.collect_parameter (collecte payload).
7.0Suppression 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

  1. Profiler activé en prod — fuite massive de données (payloads, secrets en mémoire). Vérifie web_profiler.toolbar: false ET framework.profiler.collect: false en prod.yaml. Le bundle entier devrait être en require-dev.

  2. /_profiler exposé — par défaut accessible. Restreins par IP via firewall ou supprime la route en prod. Si tu utilises symfony/profiler-pack, c'est dev only par défaut.

  3. Mémoire qui explose — chaque requête écrit un .bin dans var/cache/dev/profiler/. Sur un projet actif, ça atteint plusieurs Go. Nettoie périodiquement (rm -rf var/cache/dev/profiler).

  4. Custom collector qui plante — si collect() throw, la requête entière peut casser. Toujours wrapper dans try/catch et logger silencieusement.

  5. dd() en prod oublié — provoque un 500 silencieux. Solution : interdire via grep CI (! grep -r 'dd(' src/) ou un PHPStan rule.

  6. Stopwatch sans stop() — boucle infinie d'events ouverts → memory leak. Utilise try/finally :

    php
    $this->stopwatch->start('foo');
    try { /* ... */ } finally { $this->stopwatch->stop('foo'); }
  7. server:log ne montre pas tout — il lit var/log/dev.log, pas stderr ni d'autres handlers. Si tu loggues vers Slack/Sentry, ces logs n'apparaissent pas.

  8. debug:router ≠ ordre d'évaluation strict — l'ordre affiché reflète l'enregistrement, mais des routes plus spécifiques peuvent matcher avant. Utilise router:match pour la vérité terrain.

🧪 Testing

php
<?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
<?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
<?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]);
    }
}
yaml
# config/services.yaml
services:
    App\DataCollector\MatterListingCollector:
        tags:
            - { name: data_collector, template: 'data_collector/matter_listing.html.twig', id: 'app.matter_listing_collector' }
php
<?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');
    }
}
bash
# 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 | head

Couverture : 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() vs lateCollect() : collect() est appelé sur kernel.response. Si ton collector dépend de données produites après (ex : un service qui finalise un buffer à la destruction du kernel), implémente LateDataCollectorInterface::lateCollect(). C'est ce que fait DoctrineDataCollector : il clone les logs SQL tard pour capturer les requêtes du kernel.terminate.
  • Sérialisation de $this->data : tout ce que tu mets dans $this->data est sérialisé sur disque. Mettre un objet entité Doctrine non détaché y embarque le proxy + l'EntityManager → fichier .bin énorme et unserialize qui casse. Règle : ne stocke que des scalaires, arrays, et Data clones (cloneVar()). AbstractDataCollector::cloneVar() produit une structure VarDumper-safe.
  • Le token : X-Debug-Token est un identifiant de 6 caractères base62. X-Debug-Token-Link te 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

BesoinOutilPourquoi
Voir le timing d'un bloc précis en devStopwatchApparaît dans le panneau Performance, gratuit, zéro infra
Exposer l'état d'un service custom dans la toolbarDataCollectorVisibilité par requête, intégré au profile forensique
Tracer un incident en prod a posterioriMonolog + processor (request_id)Persistant, agrégé, pas d'overhead Profiler
Mesurer une régression de perf fiableBlackfire / TidewaysÉchantillonnage statistique, call-graph, pas l'overhead 10-30% du Profiler
Suivre P95/P99 en continuAPM (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

  1. Token-first sur une API. Le premier réflexe sur un endpoint JSON lent n'est pas dump(), c'est lire X-Debug-Token-Link dans 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.

  2. 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 Stopwatch ciblé hors Profiler. Confondre les deux mène à optimiser un faux hotspot.

  3. 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 : services lazy, listeners désactivés, kernel.debug=false qui change le comportement du cache d'annotations. Compiler le container prod localement révèle 80% de ces cas sans déployer.

  4. 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.

  5. Le Profiler est une surface d'attaque. En pré-prod accessible publiquement, /_profiler expose payloads, env vars, requêtes SQL, sessions. Un staff exige un firewall IP ou un access_control sur ^/_(profiler|wdt) même en pré-prod, et le bundle en require-dev pour qu'il ne soit physiquement pas présent en image prod.

🔐 Sécurité & production — checklist

  • web-profiler-bundle et debug-bundle doivent être dans require-dev du composer.json. Une image prod construite avec composer install --no-dev ne contient alors pas le code du Profiler — défense en profondeur, pas juste de la config.

  • Vérifie en CI que la route _profiler n'existe pas en prod :

    bash
    APP_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( dans src/ 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: false ET pas de DSN storage prod.

  • Anonymise les dumps de prod rejoués en local (PII, IBAN, secrets) avant d'activer le Profiler dessus — un .bin est 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:container et debug:router pour 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

Bibliothèque tech perso — Achref