Monitoring PHP-FPM in Production: A Complete Stack
How Cbox FPM Exporter and Cbox Init work together to give you full observability over PHP-FPM pools, OPcache, and Laravel queues. From FastCGI status pages to Prometheus dashboards.
PHP-FPM is the invisible workhorse behind most PHP deployments. It manages a pool of worker processes, handles connection queuing, and quietly recycles processes when they leak memory. But most teams have no visibility into what it's doing. They find out pm.max_children was too low when requests start timing out, or that OPcache is full when response times double overnight.
I built two tools to solve this: FPM Exporter for metrics collection, and Init for process management inside containers. Together, they give you a complete production monitoring stack for PHP-FPM.
FPM Exporter: FastCGI to Prometheus
FPM Exporter is a Go binary that speaks the FastCGI protocol directly to PHP-FPM sockets. No nginx status module, no PHP scripts, no HTTP requests routed through your application. It connects to the unix socket (or TCP port), requests the FPM status page, and exposes the data as Prometheus metrics on port 9114.
Automatic Pool Discovery
By default, FPM Exporter discovers pools automatically:
fpm-exporter serve
Discovery works by scanning for php-fpm: master process in the process list, then parsing the config with php-fpm -tt to extract socket paths and status page locations. No manual configuration needed for standard setups.
For environments where autodiscovery doesn't work (chroot, custom paths), you can configure pools explicitly:
phpfpm:
autodiscover: false
pools:
- socket: "unix:///var/run/php-fpm.sock"
status_path: /status
- socket: "tcp://127.0.0.1:9000"
status_path: /status
The Metrics
FPM Exporter exports three categories of metrics. Here are the ones that matter most in production:
Pool health, the metrics you alert on:
Metric | What It Tells You |
|---|---|
| How many workers are currently handling requests |
| How many workers are waiting for work |
| Requests waiting for a free worker (should be 0) |
| Counter: times all workers were busy (capacity ceiling hit) |
| Your configured pool size |
The single most important PromQL query for FPM health is pool utilization:
phpfpm_active_processes / phpfpm_pm_max_children_config * 100
When this approaches 90%, you need more workers. When phpfpm_listen_queue is non-zero, requests are already queuing and users are waiting.
OPcache metrics, the ones nobody checks until something breaks:
Metric | What It Tells You |
|---|---|
| Percentage of scripts served from cache |
| How much OPcache memory is consumed |
| Memory wasted by invalidated scripts |
| Times OPcache ran out of memory and restarted |
A healthy OPcache has a hit rate above 99% and zero OOM restarts. If phpfpm_opcache_wasted_memory_percent exceeds 5%, your opcache.max_wasted_percentage is probably too low or you have frequent deployments without a preload/warmup step.
# Alert when OPcache is running out of memory
phpfpm_opcache_wasted_memory_percent > 5
# Memory utilization
phpfpm_opcache_used_memory_bytes /
(phpfpm_opcache_used_memory_bytes + phpfpm_opcache_free_memory_bytes)
Laravel queue sizes, because your queue workers are probably backed by the same PHP-FPM you are monitoring:
laravel_queue_size{site="Production", connection="redis", queue="default"}
FPM Exporter runs php artisan to collect queue depths, application info, cache status, and driver configurations for each Laravel site.
Alerting Rules
Here is a production-ready set of Prometheus alerting rules based on the actual metrics the exporter provides:
groups:
- name: phpfpm
rules:
- alert: PHPFPMDown
expr: phpfpm_up == 0
for: 1m
labels:
severity: critical
- alert: PHPFPMHighUtilization
expr: phpfpm_active_processes / phpfpm_pm_max_children_config > 0.9
for: 5m
labels:
severity: warning
- alert: PHPFPMMaxChildrenReached
expr: increase(phpfpm_max_children_reached[5m]) > 0
labels:
severity: warning
- alert: PHPFPMQueueBacklog
expr: phpfpm_listen_queue > 10
for: 2m
labels:
severity: warning
Init: PID 1 for PHP Containers
Running PHP-FPM inside a Docker container introduces a problem: containers expect a single PID 1 process, but a PHP application usually needs FPM, nginx, queue workers, and a scheduler. Most teams use Supervisor, but Supervisor wasn't designed for containers. It doesn't handle signals properly, doesn't reap zombie processes, and doesn't expose metrics.
Cbox Init is a Go binary purpose-built for PID 1 in Docker. It manages multiple processes with proper signal handling, health checks, and a built-in cron scheduler.
A typical Laravel container configuration:
version: "1.0"
global:
shutdown_timeout: 30
log_level: info
metrics_enabled: true
processes:
php-fpm:
enabled: true
command: ["php-fpm", "-F", "-R"]
restart: always
health_check:
type: tcp
address: "127.0.0.1:9000"
nginx:
enabled: true
command: ["nginx", "-g", "daemon off;"]
depends_on: [php-fpm]
restart: always
queue-worker:
enabled: true
command: ["php", "artisan", "queue:work", "--sleep=3", "--tries=3"]
depends_on: [php-fpm]
scale: 3
restart: always
Init uses DAG-based dependency resolution (depends_on: [php-fpm]), so nginx only starts after PHP-FPM's TCP health check passes. Queue workers scale to 3 instances. All processes get graceful shutdown with SIGTERM, a configurable timeout, and SIGKILL as a last resort.
The Dockerfile is straightforward:
FROM php:8.3-fpm-alpine
COPY --from=builder /app/cbox-init /usr/local/bin/cbox-init
COPY cbox-init.yaml /etc/cbox-init/cbox-init.yaml
ENTRYPOINT ["/usr/local/bin/cbox-init"]
Putting It Together: The Sidecar Pattern
In Kubernetes, the recommended deployment runs FPM Exporter as a sidecar container alongside your PHP-FPM container, sharing a unix socket volume:
apiVersion: apps/v1
kind: Deployment
spec:
template:
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9114"
spec:
containers:
- name: php-fpm
image: php:8.3-fpm
volumeMounts:
- name: fpm-socket
mountPath: /var/run
- name: fpm-exporter
image: cboxdk/fpm-exporter:latest
args: ["serve", "--laravel", "name=App,path=/var/www/html"]
ports:
- containerPort: 9114
name: metrics
volumeMounts:
- name: fpm-socket
mountPath: /var/run
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
volumes:
- name: fpm-socket
emptyDir: {}
The exporter needs almost no resources. 10m CPU and 32Mi memory is sufficient for most workloads. Prometheus scrapes the /metrics endpoint on port 9114 via annotations.
For the Prometheus Operator, add a ServiceMonitor:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: fpm-exporter
spec:
selector:
matchLabels:
app: php-app
endpoints:
- port: metrics
interval: 30s
Grafana Dashboard Queries
FPM Exporter ships with a Grafana dashboard JSON you can import directly. The key panels:
FPM process utilization gauge (the single number to watch):
phpfpm_active_processes{pool="$pool"} / phpfpm_pm_max_children_config{pool="$pool"} * 100
Active vs. idle workers over time (stacked area chart):
phpfpm_active_processes{pool="$pool"}
phpfpm_idle_processes{pool="$pool"}
Queue growth rate for Laravel queues:
rate(laravel_queue_size{site="$site"}[5m])
OPcache hit rate (should be >99%):
phpfpm_opcache_hit_rate{pool="$pool"}
The Full Stack
Here is how it all fits together in production:
Cbox Init runs as PID 1 in your Docker container, managing PHP-FPM, nginx, and queue workers with health checks and graceful shutdown
FPM Exporter runs as a sidecar (or in the same container), collecting metrics via FastCGI from the FPM socket
Prometheus scrapes the exporter's
/metricsendpoint every 30 secondsGrafana visualizes pool utilization, OPcache health, and queue depths
Alertmanager fires alerts when utilization exceeds thresholds
No PHP extensions to install. No agent running inside your FPM processes. No application code changes. Just a Go binary talking FastCGI to a unix socket.
Full setup guides: FPM Exporter docs and Init docs.
// Sylvester Damgaard