Symfony Web Debug Toolbar: What the Hidden Tabs Tell You About Production Problems

#symfony web debug toolbar
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Ask a Symfony developer what they use the Symfony Web Debug Toolbar for and you will hear the same two answers: the request time on the left, and the database query count a few icons over. Those are the numbers that turn red when something is obviously wrong, so those are the numbers people learn to read.

The rest of the toolbar goes largely unopened. That is a shame, because the tabs nobody clicks are the ones that explain the bugs that survive code review and only surface under production load. A listener that adds 180 ms to every request does not show up as a slow query. A cache pool with a 4 percent hit rate looks fine on a graph of response times until traffic triples. An authorization check that passes in staging and fails for one customer role is invisible unless you can see which voter decided it.

Everything below assumes a standard symfony/profiler-pack install in the dev environment. No extra bundles required.

The events tab is where the Symfony Web Debug Toolbar hides your latency

The Events tab lists every event dispatched during the request, every listener and subscriber attached to it, and how long each one took. It is the fastest way to answer the question "where did the other 300 milliseconds go" when the query count is low and the controller is trivial.

The pattern we see most often in audits is a kernel.request or kernel.controller subscriber that does I/O. Someone adds a listener that loads the current user's organisation to populate a Twig global. It works. Six months later that listener also checks a feature flag service, which calls an HTTP API with a 200 ms timeout, on every single request including asset routes and health checks. Nobody notices, because the cost is spread evenly across the whole application rather than concentrated in one slow page.

Open the Events tab, sort by duration, and the culprit is usually at the top. Two things are worth checking beyond raw duration.

Listener ordering matters more than people expect. A subscriber with a high priority on kernel.request runs before the firewall has authenticated anyone, so $security->getUser() returns null there and the code silently falls through to a default branch. The tab shows execution order explicitly, which turns a confusing null into an obvious cause.

Not-called listeners are listed separately. If a subscriber you expect to fire is sitting in the orphaned or not-called section, then the event name is wrong, the priority put it after something that stopped propagation, or a previous listener called stopPropagation(). That list has resolved more "my listener does nothing" tickets than any amount of dump() calls.

The cache tab exposes key collisions and pools that never hit

The Cache tab breaks down reads, writes, hits, and misses per pool. A pool with hits near zero is either useless or broken, and the difference matters.

Multi-tenant applications are where this goes wrong most reliably. A cache key built as user_permissions instead of user_permissions_{tenantId}_{userId} produces one of two failure modes depending on the pool's TTL. Either every tenant reads the first tenant's data, which is a security incident, or the key is rewritten constantly and the hit rate collapses into noise. The tab shows both. A pool with 40 writes and 2 hits inside a single request is a key that includes something it should not, usually a timestamp or a request-scoped identifier.

The opposite signal is worth watching too. A pool with a very high hit count and a stale-looking response often means a key that is missing a variable it needs, which is the multi-tenant leak above viewed from the other side. When we run a code audit for a client on a shared-database SaaS, the Cache tab is one of the first places we look, because cache key design is rarely documented and almost never tested. If that sounds like territory you want covered properly, our code quality consulting work usually starts with exactly this kind of pass.

The security tab shows the whole voter chain behind a decision

Access control bugs are hard to reason about from source alone, because the decision is the aggregate of several voters under a strategy you probably configured once and forgot.

The Security tab lists each authorization check performed during the request, the attribute and subject involved, every voter that participated, what each one returned, and the final decision. That last detail is the useful one. A voter returning ACCESS_ABSTAIN when you expected ACCESS_GRANTED almost always means the supports() method rejected the subject, often because the subject arrived as a Doctrine proxy or as an ID rather than an entity.

The access decision strategy then determines what the abstentions do. Under affirmative, one granting voter is enough and a broken voter goes unnoticed. Under unanimous, a single denial overrides everything, and a voter written for an unrelated feature can block a route its author never considered. Reading the chain in the toolbar makes that interaction concrete, which beats tracing it through three bundles by hand.

The tab also shows the authenticated token class, the firewall name, and the resolved roles including those inherited through the role hierarchy. When a customer reports "I can see the page but the button is missing", comparing the roles in the token against the roles in the Twig is_granted() call usually ends the investigation in under a minute.

The validator and mailer tabs remove guesswork from two noisy areas

The Validator tab lists the objects validated during the request, the constraints evaluated on each, and which ones failed. Its value is in what it shows you that you did not expect: validation groups that were never applied, constraints inherited from a parent class, a cascade into an embedded object that you did not intend. Form errors that appear with no visible cause in the form type are almost always a Valid cascade or a group sequence, and both are visible here.

The Mailer tab holds every message dispatched during the request, with recipients, headers, and both the HTML and text bodies rendered. In development this replaces the ritual of sending test mail to a personal inbox and waiting. You can confirm the template rendered with the right variables, the right locale, and the right From address without any mail transport configured at all. For applications built around transactional email, this alone shortens the feedback loop enough to change how people work on templates. It is a small thing, but small things compound across a custom software development project that runs for months.

Custom data collectors put your own metrics in the toolbar

Anything you can measure can go in the toolbar. A data collector implements DataCollectorInterface, gathers what you need in collect(), and ships a small Twig template for the panel.

final class PricingEngineCollector extends AbstractDataCollector
{
    public function __construct(private readonly PricingLog $log) {}

    public function collect(Request $request, Response $response, ?\Throwable $e = null): void
    {
        $this->data = [
            'rules_evaluated' => $this->log->count(),
            'total_ms' => $this->log->totalMilliseconds(),
        ];
    }

    public static function getTemplate(): ?string
    {
        return 'data_collector/pricing.html.twig';
    }
}

Register it with the data_collector tag and it appears alongside the built-in panels. Good candidates are the parts of the domain that have no natural representation elsewhere: rules fired by a pricing engine, external API calls and their latency, messages published to the bus, feature flags evaluated. Once a number is on the toolbar, developers see it on every page they load, and problems get caught during feature work rather than after deployment.

Profiling requests you cannot see in a browser

The toolbar is a browser convenience. The profiler underneath it is not, and that distinction is what makes the profiler useful for API requests, webhook handlers, and console-triggered work.

Every profiled request writes a profile to storage with a token, returned in the X-Debug-Token response header. You can open any of them at /_profiler/{token}, or browse the recent list at /_profiler/. For a JSON API with no HTML to inject a toolbar into, this is the whole workflow: fire the request, read the token from the response headers, open the profile, and get every panel described above for a request that never touched a browser.

Two configuration details make this practical. Set framework.profiler.collect: false and call $profiler->enable() from a listener to capture only the requests you care about, which keeps profiler storage from filling with health check noise. The only_exceptions and only_main_requests options narrow it further when you are chasing one specific failure.

Running the profiler in a real production environment is a separate decision, and usually the wrong one. The collectors add overhead, the stored profiles contain request bodies and authentication details, and the profiler route is a serious exposure if it is reachable. The safer pattern is a staging environment that mirrors production data volume and configuration closely enough that the profiles mean something. Getting that environment honest is often the actual work. It is a common finding in our legacy code optimization engagements, where staging has drifted so far from production that nobody trusts measurements taken there.

Where to start

Pick the slowest page in your application, open it in dev, and read the Events tab before anything else. If the listener timings look reasonable, move to Cache and check whether the hit rates match what you assumed when you wrote the caching. Those two tabs account for most of the performance problems we find that were not already visible in the query count.

If you would rather have someone else do that pass across the whole application, that is roughly what we do. Write to hello@wolf-tech.io or take a look at wolf-tech.io to see how we work.