Symfony Performance Monitoring: The Metrics That Predict Problems Before They Become Incidents
A performance audit is a snapshot. You profile the application, fix the worst offenders, and move on. Symfony performance monitoring is different: it is the ongoing practice of watching a small set of metrics so a regression shows up as a graph trending the wrong way, not as a support ticket. Most teams we work with have Blackfire or the Symfony Profiler for one-off investigations but nothing running continuously, which means the first sign of a problem is a customer complaint or a 2am page.
This post covers what to measure continuously, how to wire it up with Prometheus and OpenTelemetry, and the alerting rule that separates a real regression from ordinary day-to-day noise.
Symfony performance monitoring versus a one-time audit
An audit answers "why is this route slow right now." Monitoring answers "is anything getting slower, and since when." Our Symfony performance audit guide covers the first question in detail: profiling with Blackfire, reading the timeline, fixing N+1 queries. This post picks up where that one leaves off. You can run the best audit in the world, ship a clean fix, and still get paged three months later because a new feature quietly added 40 queries to a hot path and nobody noticed until the response time crept past what users would tolerate.
The six categories below are what we instrument on every Symfony application we take ongoing responsibility for. None of them require exotic tooling. Prometheus, the symfony/postgresql-adapter or doctrine/doctrine-bundle event listeners, and OpenTelemetry's PHP SDK cover all of it.
Request duration histogram, by route and method
A single "average response time" number hides more than it shows. A route that serves 95% of requests in 80ms and 5% in 4 seconds has an average that looks fine and a p99 that tells the real story. Record request duration as a histogram, labeled by route and HTTP method, and read percentiles rather than the mean.
With promphp/prometheus_client_php, a middleware or event subscriber on kernel.terminate records the observation:
$histogram = $registry->getOrRegisterHistogram(
'symfony',
'http_request_duration_seconds',
'Request duration',
['route', 'method'],
[0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
);
$histogram->observe($duration, [$route, $method]);
The PromQL for p95 and p99 per route:
histogram_quantile(0.95, sum(rate(symfony_http_request_duration_seconds_bucket[5m])) by (le, route))
histogram_quantile(0.99, sum(rate(symfony_http_request_duration_seconds_bucket[5m])) by (le, route))
Track both. A p95 that stays flat while p99 climbs usually means a specific slow path (one tenant with a large dataset, one query plan that occasionally goes wrong) rather than a general degradation.
Database query count per request
Query count per request is the earliest and cheapest signal for N+1 regressions in production. A route that shipped at 8 queries and creeps to 30 over a few weeks did not get 4x slower overnight. Someone added a relationship, a template started looping over it, and nobody re-ran the audit.
Doctrine exposes this through Doctrine\DBAL\Logging\SQLLogger or, in newer versions, a middleware that wraps the connection. Count queries per request and record them as a histogram the same way as duration, labeled by route:
histogram_quantile(0.95, sum(rate(symfony_doctrine_query_count_bucket[10m])) by (le, route))
Set this up before you need it. When query count climbs alongside request duration, that is the fastest way to confirm the cause without opening Blackfire.
Memory usage per request
Symfony workers running under PHP-FPM or FrankenPHP get killed by the OOM killer or hit memory_limit well before most teams realize memory is the constraint, because CPU and response time dashboards look normal right up until the process dies. memory_get_peak_usage(true) recorded per request, again as a histogram, gives you the trend.
The threshold worth alerting on is not an absolute number, since that varies by application and worker configuration. What predicts an incident is peak memory usage climbing toward the configured memory_limit. If workers are set to 256MB and p99 peak usage is sitting at 220MB, that gap is closing and worth a proactive fix rather than a wait-and-see approach. A batch job or an export endpoint that loads too much into memory at once is the usual cause, and it is far cheaper to catch as a trend than as a cascade of worker restarts under load.
Queue depth and processing latency per message type
If the application uses Symfony Messenger with Doctrine, AMQP, or Redis transports, queue depth is a leading indicator that dashboards focused on HTTP requests will completely miss. A queue that grows faster than it drains means either a consumer is stuck, a downstream dependency slowed down, or message volume exceeded what the current worker count can handle. All three eventually surface as user-visible delay (a welcome email that arrives an hour late, a report that never generates), but queue depth catches it while it is still an internal metric rather than a support ticket.
Track two things per message type: queue depth (a gauge, sampled periodically) and processing duration (a histogram, per handler). Symfony Messenger's middleware stack is the right place to record the second one:
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$start = microtime(true);
$envelope = $stack->next()->handle($envelope, $stack);
$this->histogram->observe(
microtime(true) - $start,
[get_class($envelope->getMessage())]
);
return $envelope;
}
Alert on queue depth relative to its own recent baseline, not a fixed number, since normal depth varies by time of day and message type.
Cache hit rate per pool
A dropping cache hit rate is usually the quiet cause behind a rising database load that otherwise looks unexplained. Symfony's cache component exposes pool-level stats if you wrap the adapter or use the TraceableAdapter in production (with the overhead that implies, so sample rather than trace every request at scale). Track hits and misses as counters per pool and compute the ratio:
sum(rate(symfony_cache_hits_total[15m])) by (pool)
/
sum(rate(symfony_cache_hits_total[15m]) + rate(symfony_cache_misses_total[15m])) by (pool)
A pool that historically sits at 92% and drops to 60% is worth investigating before it shows up as database CPU. Common causes are a deploy that changed a cache key (invalidating everything), a Redis eviction under memory pressure, or a TTL that was set too short for how the pool is actually used.
External API call duration, per provider
Every outbound call to a payment processor, an email service, or a third-party API is a dependency the application does not control, and it is also a common source of incidents that look like your own code is slow when it is not. Wrap Symfony\Contracts\HttpClient\HttpClientInterface calls, or use OpenTelemetry's auto-instrumentation for symfony/http-client, and record duration per provider and status code.
This matters for two reasons. First, it tells you where to look during an incident without guessing. Second, tracked over weeks, it tells you which providers are getting slower or less reliable before their status page admits it.
Alerting on relative change, not absolute thresholds
The fastest way to make a monitoring setup useless is to alert on fixed thresholds like "page if p99 exceeds 2 seconds." Some routes are legitimately slower than others (a report generation endpoint versus a health check), and a fixed threshold either pages constantly on the slow-but-normal route or misses a genuine 3x regression on a route that is usually fast.
The rule that works better in practice: alert when p99 for a route increases by more than 20% relative to its own 7-day baseline, sustained over a window long enough to rule out a brief spike.
groups:
- name: symfony-performance
rules:
- alert: SymfonyP99Regression
expr: |
histogram_quantile(0.99, sum(rate(symfony_http_request_duration_seconds_bucket[10m])) by (le, route))
>
1.2 * avg_over_time(
histogram_quantile(0.99, sum(rate(symfony_http_request_duration_seconds_bucket[10m])) by (le, route))[7d:1h]
)
for: 15m
labels:
severity: warning
annotations:
summary: 'p99 for {{ $labels.route }} is 20% above its 7-day baseline'
Pair this with a Grafana dashboard that puts request duration, query count, memory, queue depth, and cache hit rate on one screen per route or worker pool. When an alert fires, the on-call person should be able to tell within a minute whether the cause is a query regression, a memory issue, or an external dependency, rather than starting from Blackfire and working backward.
Where this fits with a one-time audit
None of this replaces a periodic audit. Monitoring tells you something changed; an audit tells you why and how to fix it properly. The two work together: monitoring shortens the time between a regression appearing and someone noticing it, and an audit is still the right tool for the deeper investigation once you know where to look.
If your Symfony application does not have either in place yet, or you inherited a monitoring setup that pages too often to be useful, our code quality consulting work covers both: setting up the metrics that matter and tuning the alerting so it catches real problems without burning out whoever is on call. For applications that need broader work beyond monitoring, from architecture review to a full rebuild, custom software development is the wider service that covers it.
Questions about wiring this into an existing Symfony deployment, or about specific metric thresholds for your setup, are welcome at hello@wolf-tech.io. More on how we work is at wolf-tech.io.

