Reading the Profiler

The profiler records what each dev request actually did: read the timeline, the query count and the events panel instead of guessing.

SyntaxStopwatch::start(string $name, ?string $category = null): StopwatchEvent

The profiler is a recorder. In the dev environment Symfony stores, for every request, a profile: the measurements gathered by a set of small classes called data collectors. Each profile has a token, a short identifier, and is readable at /_profiler/{token}. It exists so you can look at what a request did instead of reasoning about what it should have done.

Where to find it

composer require --dev symfony/profiler-pack installs the collectors and the toolbar strip at the bottom of HTML pages. A response that is not HTML - JSON, a redirect, a download - cannot carry the toolbar, so Symfony adds an X-Debug-Token-Link header holding the URL of that request's profile. Console commands have profiles too: run them with --profile.

Read it in this order

Start with the Performance panel. It draws the request as a timeline of named events: kernel boot, controller, each listener, each template render. Find the widest bar before changing anything. Then the Doctrine panel, which shows the number of queries and the time of each; twenty near-identical queries is a different problem from one slow query. Then the Events panel, which lists every listener that ran, in call order.

You can add your own bars with the Stopwatch service, so the panel names your code instead of lumping it under "controller".

What trips people up

A redirect produces its own profile, and the toolbar shows that one, which did almost nothing. The list of recent profiles in the profiler is where you find the page that followed it.

Example

Example · php
<?php

// src/Report/SalesReport.php

declare(strict_types=1);

namespace App\Report;

use App\Repository\OrderRepository;
use Symfony\Component\Stopwatch\Stopwatch;

final class SalesReport
{
    public function __construct(
        private readonly OrderRepository $orders,
        // Nullable on purpose: symfony/stopwatch is a dev dependency, so the
        // autowiring alias for it may not exist in the prod container.
        private readonly ?Stopwatch $stopwatch = null,
    ) {
    }

    /**
     * @return array<int, int> month number => total in cents
     */
    public function monthlyTotals(int $year): array
    {
        $this->stopwatch?->start('report.query', 'app');
        $rows = $this->orders->monthlyTotals($year);
        $this->stopwatch?->stop('report.query');

        $this->stopwatch?->start('report.format', 'app');
        $totals = [];

        foreach ($rows as $row) {
            $totals[(int) $row['month']] = (int) $row['total'];
        }

        $this->stopwatch?->stop('report.format');

        return $totals;
    }
}

// The Performance panel of this request's profile now shows two extra bars
// inside the timeline, named report.query and report.format, so you can see
// which half of this method the time went to.

When to use it

  • A page feels slow: find whether the time is in the database, a listener or the template.
  • A JSON endpoint has no toolbar: follow X-Debug-Token-Link to its profile.
  • An import command takes minutes: run it with --profile and read the timeline.
  • Confirm a listener you registered really runs, and where in the chain it runs.

More examples

The autowired Stopwatch written out longhand

The Stopwatch type-hint in the constructor resolves to the service id debug.stopwatch through an alias Symfony registers whenever the Stopwatch class is installed. This YAML is the same wiring written out by hand. Reach for the longhand when you want the '@?' form so a missing service becomes null instead of a container error.

Example · yaml
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\Report\SalesReport:
        arguments:
            $orders: '@App\Repository\OrderRepository'
            # '@?' means: pass null if this service does not exist,
            # which is what happens in prod without symfony/stopwatch.
            $stopwatch: '@?debug.stopwatch'

Profiling a console command

The --profile option goes before the command name, because it belongs to the application rather than to your command. The profile does not print in the terminal; it lands in the profiler's storage and you open it in the browser. This is how you time an import or a long-running job panel by panel instead of adding timing echoes to the code.

Example · bash
# Debug mode and the profiler must be on, so this is the dev environment.
php bin/console --profile app:import-prices

# -v prints a clickable link to the profile; -vvv also prints time and memory.
php bin/console --profile -v app:import-prices

# A worker command needs --no-reset, or the collected data is thrown away.
# --limit keeps the profile small enough to read.
php bin/console --profile messenger:consume async --limit=5 --no-reset

Adding your own panel

Classes implementing the data collector interface are tagged automatically, so extending AbstractDataCollector and writing the template is all it takes to get a new panel. Useful when the thing you keep asking about is application state - which flags were on, which tenant was resolved - rather than framework state.

Example · php
<?php

// src/DataCollector/FeatureFlagCollector.php

declare(strict_types=1);

namespace App\DataCollector;

use App\Feature\FeatureFlags;
use Symfony\Bundle\FrameworkBundle\DataCollector\AbstractDataCollector;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class FeatureFlagCollector extends AbstractDataCollector
{
    public function __construct(private readonly FeatureFlags $flags)
    {
    }

    public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
    {
        // $this->data is serialised with the profile, so keep it to scalars.
        $this->data['enabled'] = $this->flags->enabledNames();
    }

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

Discussion

  • Be the first to comment on this lesson.