Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 064 / 100

PHP-FPM tuning: PHP & Laravel best practices explained

Get more from your server.

Expona Admin7 min read

Introduction

Your Laravel application is running, but users still complain about slow response times during traffic spikes. The problem often isn't your code—it's how PHP-FPM is configured. PHP-FPM tuning: PHP & Laravel best practices explained will walk you through the decisions that matter most, from process managers to worker recycling, so your team can squeeze real performance gains from your existing hardware.

We've seen production systems waste 60–70% of available resources due to misconfigured PHP-FPM settings. The good news is that tuning is straightforward once you understand the trade-offs. This guide covers the practical decisions you'll face: whether to use dynamic or static process management, how to calculate max_children without overloading your server, why slow logs are non-negotiable, and when to restart workers.

Understanding Process Managers: Dynamic vs. Static

PHP-FPM offers two main process management strategies, each with distinct trade-offs. Your choice here sets the foundation for everything else.

Static mode spawns a fixed number of worker processes at startup and keeps them running. If you allocate 20 workers, you'll always have 20 PHP processes consuming memory, whether traffic is high or low. This simplifies capacity planning—you know exactly what memory you'll use—and eliminates the overhead of spawning and destroying processes. Static is reliable for predictable, consistent load.

Dynamic mode starts with a minimum number of workers and scales up as demand increases, then scales down during quiet periods. You define pm.min_spare_servers and pm.max_spare_servers to set the idle range, and PHP-FPM manages the pool automatically. This saves memory during off-peak hours and adapts to traffic patterns.

In our experience with high-traffic SaaS platforms, dynamic mode is the safer default for most teams. It handles unexpected traffic spikes better and reduces memory waste. However, dynamic mode has a small CPU cost from process management overhead, and it can mask underlying issues if you're not monitoring properly. If you have stable, predictable traffic—like a batch processing service that runs at specific times—static mode is simpler and slightly faster.

Here's how you'd configure each in your php-fpm.conf:

php
; Static configuration
pm = static
pm.max_children = 20

; Dynamic configuration (recommended)
pm = dynamic
pm.start_servers = 5
pm.min_spare_servers = 2
pm.max_spare_servers = 10
pm.max_children = 20

In the dynamic example, PHP-FPM maintains between 2 and 10 idle workers. If all are busy, it spawns more up to the pm.max_children limit of 20. Once traffic drops, excess workers terminate after sitting idle.

Calculating pm.max_children: The Memory Per Process Equation

This is where most teams go wrong. pm.max_children is the ceiling on how many PHP-FPM workers can run simultaneously. Set it too high, and your server runs out of memory, triggering swap thrashing and OOM killer events. Set it too low, and requests queue up waiting for workers.

The calculation is straightforward: estimate memory per PHP process, then divide available server memory by that figure.

Available Memory for PHP-FPM = Total Server Memory − Operating System − Web Server (nginx) − Other Services

pm.max_children = Available Memory for PHP-FPM ÷ Memory per Process

Let's walk through a real scenario. You're running on a 2GB AWS t3.small instance with Ubuntu, nginx, and PostgreSQL on the same box:

  • Total: 2048 MB
  • OS kernel and daemons: ~200 MB
  • PostgreSQL: ~300 MB
  • nginx: ~50 MB
  • Available for PHP-FPM: ~1500 MB

Now measure your actual PHP process memory. In production, run ps aux | grep php-fpm and look at the RSS column. A typical Laravel application uses 30–50 MB per process when idle, and 60–100 MB under load due to large request data or temporary allocations. Let's assume 40 MB average for this scenario.

php
pm.max_children = 1500 / 40 = 37.5 → set to 35 (conservative)

For dynamic mode, you'd also set:

php
pm = dynamic
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 15
pm.max_children = 35

This allows the pool to grow from 5 to 15 idle workers, with a ceiling of 35 total. During a traffic spike, you can serve up to 35 concurrent requests; during quiet hours, you run 5–10 workers and reclaim memory.

The trick is to measure your own processes. Every application is different. A microservice handling simple API responses might use 20 MB; a monolith with large data models and image processing might hit 150 MB. Use watch 'ps aux | grep php-fpm' during typical load to establish a baseline.

Monitoring Slow Logs: The Diagnostic Tool You're Missing

Slow logs are your window into what's actually happening inside PHP-FPM. They capture requests that exceed a threshold, showing you which routes, database queries, or operations are dragging down response time. Many teams skip this, then wonder why their application "feels slow."

Enable slow logs in php-fpm.conf:

php
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 5s
request_timeout = 30s

The request_slowlog_timeout logs any request taking longer than 5 seconds (adjust based on your SLA). The request_timeout forcibly kills a request after 30 seconds to prevent hanging workers. Make sure the log directory is writable by the PHP-FPM user (usually www-data).

A typical slow log entry looks like this:

php
[06-Dec-2024 14:23:45] [pool www] pid 2847
script_filename = /var/www/laravel/public/index.php
{main}() /var/www/laravel/public/index.php:0
  0.2450 1234567 -> {main}() /var/www/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Application.php:0
  0.3120 2345678 -> Illuminate\Database\Connection->statement() /var/www/laravel/app/Models/User.php:45

This tells you the request hit the User model at line 45, called a database statement, and took 0.31 seconds in that call. Now you know to investigate the query.

In a production e-commerce environment we worked on, slow logs revealed that a product listing endpoint was executing one database query per product (N+1 problem). Fixing the eager loading cut response time from 8 seconds to 0.4 seconds. Without slow logs, we would've blamed PHP-FPM or the server.

Review slow logs regularly—daily in high-traffic systems, weekly in moderate ones. Parse them with a tool like pt-query-digest or write a simple script to aggregate and alert on patterns.

Worker Restart Strategy: max_requests and Graceful Recycling

PHP-FPM workers run for extended periods. Over time, they can accumulate memory leaks, database connections, or stale state. The pm.max_requests setting tells PHP-FPM to recycle (restart) a worker after it has handled a certain number of requests. This is a proven pattern for long-running PHP processes.

Configure it like this:

php
pm.max_requests = 1000
pm.max_requests_grace_timeout = 30s

After handling 1000 requests, a worker finishes its current request and exits gracefully. PHP-FPM spawns a replacement. The pm.max_requests_grace_timeout gives the worker 30 seconds to finish; after that, it's killed forcibly.

Why 1000? It depends on your application. A lightweight API server might safely run 5000 requests per worker. A memory-intensive batch processor might need 100 per worker. Start with 1000 as a baseline and adjust based on memory growth patterns you observe.

Without recycling, a busy production server can see PHP-FPM processes grow to 200–300 MB after a few days. With recycling every 1000 requests, memory stays bounded because young processes consume less than old ones.

For a Laravel application receiving 100 requests per second, 1000 max_requests means each worker recycles every 10 seconds. This is transparent to users—new requests are routed to fresh workers. The beauty is that it's zero-downtime: connections don't drop because PHP-FPM maintains a pool.

Monitor your actual request rate per worker to tune this setting. If workers average 2000 requests before recycling, set pm.max_requests = 2000. If they average 500, go lower. The goal is recycling every 10–60 seconds under normal load.

Common Mistakes

  • Setting pm.max_children too high. A frequent mistake is copying someone else's config without measuring your own process size. If you allocate 100 workers on a 2GB server, you'll trigger OOM killer and lose requests entirely. Always calculate based on actual memory usage.
  • Ignoring slow logs. Developers blame PHP-FPM or the server when the real culprit is an unoptimized query or N+1 loading. Slow logs are free diagnostics; use them.
  • Setting max_requests too high or too low. Too high (e.g., 100000), and memory leaks accumulate. Too low (e.g., 50), and you recycle workers constantly, wasting CPU. 1000–2000 is a safe default for most Laravel apps.
  • Using static mode with variable traffic. If your traffic is bursty, static mode wastes memory during quiet periods and can't scale during spikes. Dynamic mode adapts.
  • Not monitoring at all.strong> Configure PHP-FPM once and forget it, then wonder why response times degrade after a month. Set up alerts on memory usage, active worker count, and slow log growth.

Key Takeaways

  • Choose dynamic process management for most workloads; it handles traffic variance and saves memory during off-peak hours.
  • Calculate pm.max_children by measuring actual memory per process on your server, then dividing available memory by that figure. Never guess.
  • Enable and review slow logs regularly. They pinpoint real bottlenecks—often in application code, not infrastructure.
  • Set pm.max_requests to recycle workers every 10–60 seconds under normal load, preventing memory bloat in long-running processes.
  • Monitor continuously. Memory usage, worker utilization, and slow log trends reveal when your application or traffic patterns have changed and tuning needs adjustment.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles