PHP monitoring in production: what to measure in PHP-FPM, OPcache, Laravel, Symfony and WordPress
Which signals actually tell you a PHP application is healthy, how to read PHP-FPM and OPcache metrics, how to instrument PHP with OpenTelemetry and Monolog, and which alerts are worth waking up for.
Summary
- PHP monitoring in production rests on four layers: per-endpoint latency and error rates, PHP-FPM pool saturation, OPcache and runtime limits, and the database, HTTP and queue calls your code makes.
- PHP-FPM exposes its pool state through pm.status_path, and the metrics that matter most are active versus idle processes, max children reached and the listen queue.
- The OpenTelemetry PHP extension plus auto-instrumentation packages for Laravel, Symfony, WordPress, PDO and HTTP clients give you traces without code changes, and Monolog JSON logs with trace IDs connect them to your logs.
PHP monitoring in production is different from monitoring a long-running JVM or Go service. Every request runs in a short-lived worker managed by PHP-FPM, state is rebuilt on each request, and many performance problems are not visible in the PHP code at all: they show up as a full worker pool, a cold OPcache or a database that suddenly takes 300 ms instead of 3 ms.
This guide walks through what to monitor in a PHP application, layer by layer, covers Laravel, Symfony and WordPress, shows how to instrument PHP with OpenTelemetry and Monolog, and ends with a metrics table, alert suggestions and a checklist you can copy.
Start with requests: latency and error rate per endpoint
The most useful PHP application performance monitoring signal is also the simplest: for every route or controller action, how many requests arrive, how many fail and how long they take.
- Measure per endpoint, not per host. An average across all routes hides the checkout endpoint that is three times slower than last week. Group by route template (
/orders/{id}), not by raw URL, to keep cardinality under control. - Use percentiles, not averages. Track p50, p95 and p99 latency. A healthy average can hide a tail where one request in a hundred waits for a lock. Our glossary entry on percentiles explains why p99 is usually the number that matters.
- Separate error classes. HTTP 5xx responses, uncaught exceptions, fatal errors and timeouts at the web server are different failure modes. A 502 or 504 from Nginx often means PHP-FPM never answered, which your PHP error log will not show.
- Consider Apdex for a single satisfaction score. If stakeholders want one number per service, an Apdex score with a sensible threshold per endpoint type is easier to communicate than three percentiles.
Correlate request metrics with your web server. Nginx access logs with $request_time and $upstream_response_time show how much of the latency is spent waiting for PHP-FPM; the Nginx integration collects these alongside the PHP signals.
PHP-FPM monitoring: pool metrics that predict outages
PHP-FPM runs a pool of worker processes. Each worker handles one request at a time. When all workers are busy, new requests wait in the listen queue; when the queue fills, the web server returns errors. That makes the pool state the most important leading indicator in PHP-FPM monitoring.
Enable the status page per pool with pm.status_path. It reports, among other fields:
- active processes / idle processes / total processes: how many workers are busy right now. Sustained active close to
pm.max_childrenmeans you are at capacity. - max children reached: a counter of how often the pool hit
pm.max_childrenand could not spawn another worker. Any increase is worth investigating. - listen queue / max listen queue / listen queue len: requests waiting for a free worker. In a healthy pool the listen queue is zero almost all the time.
- accepted conn and slow requests: request throughput and the number of requests that exceeded
request_slowlog_timeout.
The slow log deserves special attention. With request_slowlog_timeout and slowlog set, PHP-FPM writes a stack trace of every request that runs longer than the timeout. Separately, request_terminate_timeout kills workers after a wall-clock limit, which protects the pool from requests that hang forever.
A minimal pool configuration for monitoring looks like this:
; /etc/php/8.3/fpm/pool.d/www.conf (example)
pm = dynamic
pm.max_children = 40
pm.status_path = /fpm-status
ping.path = /fpm-ping
request_slowlog_timeout = 2s
slowlog = /var/log/php-fpm/www-slow.log
request_terminate_timeout = 60s
Expose the status path only on an internal interface or restrict it in your web server configuration; it reveals details about your workload. Append ?json or ?full to the status URL for machine-readable or per-process output.
OPcache, memory_limit and max_execution_time
OPcache
OPcache stores compiled PHP bytecode in shared memory. When it works, PHP skips parsing and compiling on every request. When it is too small, it silently evicts scripts or restarts, and latency rises across all endpoints at once. opcache_get_status() returns the numbers you need:
- Hit rate (
opcache_hit_rate): should be very close to 100 % after warm-up. A falling hit rate usually means the cache is full or being reset. - Memory usage: used, free and wasted memory relative to
opcache.memory_consumption. - Cached keys versus
max_cached_keys(controlled byopcache.max_accelerated_files) and the out-of-memory restart counter (oom_restarts).
Because OPcache lives in the FPM master's shared memory, read these values through an HTTP endpoint served by FPM, not from the CLI, which has its own separate cache.
Runtime limits
Two fatal errors deserve their own counters, because they look like ordinary 500s otherwise:
- "Allowed memory size of … bytes exhausted": a request hit
memory_limit. Typical causes are loading large result sets into arrays, unbounded collections or image processing in the request path. - "Maximum execution time of … seconds exceeded": a request hit
max_execution_time. On Linux this limit counts script execution time only, not time spent waiting in database queries or stream operations, so a request blocked on a slow API can run far longer. That is whyrequest_terminate_timeoutin FPM is the real wall-clock safety net.
Count both by endpoint.
Database calls, external HTTP calls and queue workers
In most PHP applications, the majority of request time is spent outside PHP: in MySQL or PostgreSQL, Redis, and calls to payment providers, search services or internal APIs. Monitor them from the application side, per request:
- Query count and time per request. A sudden jump from 15 to 400 queries on one endpoint is almost always an N+1 query introduced by an ORM relationship that is loaded lazily inside a loop.
- Slowest statements. Combine application-side spans with the server view from the MySQL integration (slow query log, connections, lock waits) to see whether a query is slow everywhere or only under contention.
- External HTTP latency and errors per host. Track timeouts and 5xx per dependency. Without explicit timeouts on your HTTP client, one slow dependency can block FPM workers and drain the pool.
- Queue workers. Queue depth, job wait time, job duration, failure and retry counts, and worker restarts. Long-running PHP CLI workers also leak memory over time, so track their memory and restart them regularly.
Framework specifics: Laravel, Symfony and WordPress
Laravel
Laravel queues are usually processed by php artisan queue:work; failed jobs land in the failed_jobs table, which makes a simple counter. Horizon provides a dashboard and metrics for Redis queues, and php artisan queue:monitor dispatches an event when a queue exceeds a job count you define. Watch scheduled tasks too: a schedule:run that silently stops is a common blind spot. Eloquent's lazy loading is the usual source of N+1 queries; Model::preventLazyLoading() in non-production environments helps catch them early.
Symfony
Symfony applications typically log through MonologBundle and process asynchronous work with Messenger (messenger:consume). Monitor the failure transport for messages that exhausted their retries, and the time messages spend waiting in each transport. Doctrine query counts per request are the Symfony equivalent of Eloquent N+1 checks.
WordPress
WordPress performance is dominated by plugins, database queries through $wpdb and uncached pages. Monitor admin-ajax.php and /wp-json/ separately from page views, since they behave very differently. WP-Cron runs on page requests by default; on busy sites it is common to disable that and trigger it from a system cron, which you should then monitor like any other scheduled job.
Instrumenting PHP with OpenTelemetry
OpenTelemetry is the vendor-neutral way to get traces, metrics and logs out of PHP. Zero-code instrumentation for PHP has two parts, according to the official documentation on opentelemetry.io:
- The
opentelemetryPHP extension (PHP 8.0 or higher), installed via PECL (pecl install opentelemetry), PIE, the Dockerinstall-php-extensionshelper or OS packages. It provides the hooks that let libraries be instrumented without code changes. On its own it produces no telemetry. - Composer packages:
open-telemetry/sdk, an exporter such asopen-telemetry/exporter-otlp, and one or more auto-instrumentation packages from the opentelemetry-php-contrib repository, for exampleopen-telemetry/opentelemetry-auto-laravel,open-telemetry/opentelemetry-auto-symfony,open-telemetry/opentelemetry-auto-wordpress,open-telemetry/opentelemetry-auto-pdo,open-telemetry/opentelemetry-auto-guzzleandopen-telemetry/opentelemetry-auto-curl.
Configuration happens through standard OpenTelemetry environment variables. In PHP-FPM, set them in the pool configuration with env[...] or in php.ini; remember that FPM clears the environment by default (clear_env = yes):
# environment for PHP-FPM workers (example)
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=shop-api
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production,service.version=2026.10.3
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_PROPAGATORS=baggage,tracecontext
Send data to a local OpenTelemetry Collector rather than directly to a backend. Because PHP exports at the end of each request, a nearby collector keeps export latency low and lets you batch, filter and sample centrally. With distributed tracing in place, a slow request shows exactly which query, HTTP call or cache lookup took the time, and the trace continues into downstream services via the W3C traceparent header.
Check the current status and the full list of instrumentation packages in the OpenTelemetry registry before you roll out; the PHP ecosystem moves quickly and packages differ in maturity.
Structured logs with Monolog and trace IDs
Most PHP frameworks log through Monolog. For production, switch from line-formatted text to structured logging in JSON with JsonFormatter, write to stdout or a local file, and add the active trace and span ID to every record. Then you can jump from a log line to the trace and back.
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\LogRecord;
use OpenTelemetry\API\Trace\Span;
$handler = new StreamHandler('php://stdout');
$handler->setFormatter(new JsonFormatter());
$logger = new Logger('app');
$logger->pushHandler($handler);
$logger->pushProcessor(function (LogRecord $record): LogRecord {
$ctx = Span::getCurrent()->getContext();
if (!$ctx->isValid()) {
return $record;
}
return $record->with(extra: $record->extra + [
'trace_id' => $ctx->getTraceId(),
'span_id' => $ctx->getSpanId(),
]);
});
Alternatively, the open-telemetry/opentelemetry-logger-monolog package provides a Monolog handler that sends records through the OpenTelemetry logs pipeline with trace context attached. Either way, keep personal data out of log messages and log context; mask it at the source or in your telemetry pipeline before storage. Pair logs with error tracking so that uncaught exceptions are grouped by fingerprint instead of buried in log volume.
Metrics table, alert suggestions and a PHP monitoring checklist
The thresholds below are examples to start from, not recommendations for every workload. Calibrate them against a few weeks of your own baseline.
| Metric | Why it matters | Typical threshold / alert (example) |
|---|---|---|
| p95 latency per endpoint | What users actually feel; catches regressions per route | p95 above 2x the 7-day baseline for 10 min |
| 5xx / error rate per endpoint | Direct user impact, including FPM-caused 502/504 | Above 2 % for 5 min on a critical route |
| FPM active processes / max_children | Pool saturation is the leading indicator of outages | Above 85 % for 10 min |
| FPM max children reached | Requests could not get a new worker | Any increase in 15 min (warning) |
| FPM listen queue | Requests waiting; latency is about to spike | Greater than 0 for 2 min |
| FPM slow requests | Points to blocking code paths | Rate above baseline; review slow log |
| OPcache hit rate | A cold or full cache slows every request | Below 99 % after warm-up |
| OPcache free memory / oom_restarts | Evictions and restarts cause latency waves | Free below 10 % or any OOM restart |
| memory_limit / max_execution_time errors | Hidden behind generic 500s otherwise | Any new occurrence after a deploy |
| Queries per request | Detects N+1 patterns | 2x baseline on an endpoint |
| External call latency / errors per host | Slow dependencies block workers | Timeouts above 1 % for 5 min |
| Queue depth and job wait time | Backlogs delay emails, orders, exports | Wait time above 5 min for 10 min |
Page only on symptoms that hurt users (error rate, latency SLOs, saturated pool with a growing queue) and route the rest to tickets or chat. Our article on reducing alert fatigue covers how to structure that.
PHP monitoring checklist
- Request rate, error rate and p50/p95/p99 latency per route template
- Nginx or Apache upstream time logged next to PHP request time
pm.status_pathandping.pathenabled per pool, restricted to internal access- Active/idle processes, max children reached and listen queue collected every 10–30 s
request_slowlog_timeout,slowlogandrequest_terminate_timeoutset- OPcache hit rate, free memory, cached keys and OOM restarts read from FPM, not CLI
- Separate counters for memory_limit and max_execution_time fatals
- Query count, query time and slowest statements per request
- Explicit timeouts and per-host metrics on every HTTP client
- Queue depth, wait time, failures and worker memory for every queue
- Scheduled jobs (Laravel scheduler, WP-Cron, system cron) monitored for missed runs
- OpenTelemetry extension, SDK, OTLP exporter and framework auto-instrumentation installed
service.name, environment and version set as resource attributes- Monolog writing JSON with trace_id and span_id; no personal data in logs
- Alerts on user-facing symptoms; deploy markers on all dashboards
How Obseria helps with PHP monitoring
Obseria is an OpenTelemetry-native observability platform, so the PHP extension, auto-instrumentation packages and Monolog JSON logs described above work without a proprietary agent. The PHP integration brings PHP-FPM and OPcache metrics together with traces and logs from the same request, and database monitoring connects slow endpoints to the queries behind them.
- Obseria Pipeline filters, samples and masks personal data in PHP telemetry before it is stored in the EU.
- Obseria TopoGraph maps PHP services to their databases, caches, queues and external APIs.
- Obseria Intelligence correlates a saturated FPM pool, a slow query and a recent deploy into one root cause with evidence.
- SLOs with burn-rate alerts replace static thresholds for your most important endpoints.
You can connect your first PHP service in minutes with a free Obseria trial.