PHP-FPM: What Actually Kills Your Long-Running Request
request_terminate_timeout and max_execution_time both kill PHP processes, but they're not the same thing — and using the wrong one will bite you in production.
I've debugged enough dead workers and zombie processes over the years to have a strong opinion on this: most developers who configure PHP-FPM don't actually know which of these two settings is doing the killing, and that ignorance eventually takes down a production queue worker or a long-running report at the worst possible moment.
Let me clear it up.
The Short Version Nobody Reads
max_execution_time is a PHP-level setting. It counts only CPU time spent executing PHP code. request_terminate_timeout is a PHP-FPM pool setting. It counts wall-clock time from when FPM handed the request to a worker. They interact, they can conflict, and under certain workloads — database waits, file I/O, external HTTP calls — they behave in ways that will surprise you.
What max_execution_time Actually Measures
This one lives in php.ini (or your pool's php_admin_value override). The PHP manual says it sets "the maximum time in seconds a script is allowed to run." What it does not say clearly enough is that this is CPU time, not wall-clock time.
If your script is blocking on a slow MySQL query, that wait time is not counted. If you're making a cURL request to some third-party API that takes 45 seconds to respond, that blocking I/O is not counted either. The timer only runs when the PHP interpreter is actually burning cycles.
This is by design. PHP uses setitimer on Linux (with SIGPROF or SIGALRM depending on compile flags) to track execution time. The timer only ticks when the process is scheduled on CPU.
Here's what that means practically:
<?php
// php.ini: max_execution_time = 30
$start = microtime(true);
// This could run for 5 minutes and NOT trigger max_execution_time
// if the DB is just sitting there making PHP wait
$result = $pdo->query('SELECT * FROM enormous_table WHERE slow_condition = 1');
$elapsed = microtime(true) - $start;
echo "Elapsed: {$elapsed}s\n"; // Could print 300s with no fatal error
I ran into this exact scenario on a reporting job for a client in industrial distribution. The report queried a decade of transaction data, the query ran four minutes, and max_execution_time = 30 did absolutely nothing to stop it. The PHP process was alive, the worker was tied up, and nobody understood why.
SQL wait time is invisible to max_execution_time. So are sleeps, stream reads, and any blocking system call that parks the process off-CPU.
What request_terminate_timeout Actually Measures
This lives in your FPM pool config — /etc/php/8.x/fpm/pool.d/www.conf or wherever you keep it:
[www]
request_terminate_timeout = 60
This is wall-clock time. FPM starts counting the moment it dispatches the request to a worker process. After the configured interval, FPM sends SIGTERM to the worker, waits briefly, then sends SIGKILL if the worker hasn't exited. The worker is gone. The child process is reaped. FPM spawns a replacement.
This one will actually kill a process stuck in a slow DB query, a blocked cURL call, or an infinite loop, because wall-clock time keeps moving regardless of what the process is doing.
Here's the pool config I use as a baseline for most NWOS projects:
[www]
user = www-data
group = www-data
pm = dynamic
pm.max_children = 20
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 10
request_terminate_timeout = 60
request_slowlog_timeout = 10
slowlog = /var/log/php-fpm/slow.log
The slowlog pairing is important. If something hits request_terminate_timeout, you probably want to know why it was slow before FPM murdered it. The slowlog gives you a stack trace at the time of the slowdown — not a fatal error, just a log entry with the call stack. Set request_slowlog_timeout to something shorter than request_terminate_timeout so you get evidence before the kill shot.
When They Interact Badly
Here's the footgun: if max_execution_time is lower than request_terminate_timeout, PHP will throw a fatal error on CPU-intensive work before FPM ever needs to step in. That's usually fine. But if you've got a job that's mostly I/O — which most web requests are — max_execution_time is basically doing nothing while request_terminate_timeout is the only real backstop.
Where it gets nasty is when you set them like this, thinking they're redundant:
; php.ini
max_execution_time = 300
; pool config
request_terminate_timeout = 30
You get a process that FPM will kill after 30 wall-clock seconds — including the 10 seconds it spent waiting on a slow API — but PHP itself would have happily let run for 300 CPU seconds. The PHP-level limit means nothing here because FPM kills the whole process first.
This is confusing when you're reading logs. You see a process terminated, no PHP fatal, no Maximum execution time exceeded error — just a dead worker. That's FPM doing the kill, not PHP.
Conversely:
; php.ini
max_execution_time = 30
; pool config
request_terminate_timeout = 0 ; disabled
Now a script stuck in a blocking DB query can run forever. The max_execution_time never fires because the process is off-CPU. FPM never intervenes because you set 0 (disabled). I've seen this kill a server when a bad query started spawning workers that piled up and exhausted memory.
The Laravel Queue Worker Problem
This is where I see the most production pain. Laravel's queue worker (php artisan queue:work) is a long-lived CLI process. It loops, picks up jobs, runs them, loops again.
max_execution_time is effectively disabled for CLI by default. Check your php-cli.ini — it's almost certainly 0. That's intentional. You don't want a CLI script dying mid-job because PHP's execution timer fired.
But if you're running queue workers through FPM (some setups do this via cron or supervisor calling PHP-FPM), request_terminate_timeout will kill your worker mid-job after the wall-clock limit. You'll get partial job execution, potentially corrupted state, and jobs that look completed but aren't.
For queue workers, I run them under Supervisor as plain CLI processes — not through FPM — and I let Laravel's own --timeout flag handle job-level limits:
php artisan queue:work --timeout=120 --tries=3
Laravel's --timeout uses pcntl_alarm to send SIGALRM to the worker process after the job timeout, which triggers a clean shutdown path. That's the right tool for the job. FPM's request_terminate_timeout should have no role here.
When Both Settings Are Wrong
Long-running HTTP endpoints that do real work — generating PDFs, processing image uploads, calling slow third-party APIs — are where both defaults fail you.
The right answer for those isn't cranking up timeouts. It's moving the work off the request cycle entirely. Accept the request, push a job to the queue, return a 202 Accepted with a job ID. Let the client poll for completion or use a webhook callback. I've done this for healthcare clients generating large lab reports, for e-commerce clients doing bulk inventory imports, for print management workflows that need to render complex documents.
If you absolutely must process synchronously in an HTTP request:
- Set
request_terminate_timeoutto something sane — the absolute maximum your client should ever wait, plus a small buffer - Set
max_execution_timeto the same or higher, knowing it only counts CPU time - Add a Nginx
fastcgi_read_timeoutthat's slightly longer thanrequest_terminate_timeoutso Nginx doesn't close the upstream connection before FPM finishes - Consider what "clean shutdown" looks like if FPM kills the worker mid-operation
That last point matters. SIGTERM gives the process a chance to catch the signal and clean up. SIGKILL does not. PHP doesn't catch signals by default unless you register a handler with pcntl_signal. If you're writing to a file or mid-transaction when FPM kills the worker, you may leave things in a bad state.
<?php
// Register a SIGTERM handler so you can clean up before FPM kills you
if (function_exists('pcntl_signal')) {
pcntl_signal(SIGTERM, function () {
// Roll back open transactions, close file handles, log the interruption
// Then exit cleanly
exit(1);
});
pcntl_async_signals(true); // Required in PHP 7.1+ for async signal handling
}
This won't save you from SIGKILL, but it gives you a window to clean up on SIGTERM, which FPM sends first.
My Actual Recommendation
For a standard web application pool:
; php.ini or php_admin_value in pool config
max_execution_time = 30
; pool config
request_terminate_timeout = 60
request_slowlog_timeout = 5
slowlog = /var/log/php-fpm/www-slow.log
And in your Nginx config for the FPM upstream:
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_read_timeout 65; # Just over request_terminate_timeout
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
For anything that legitimately needs to run long — imports, reports, exports — move it to a queue worker process managed by Supervisor, not FPM. That's not a workaround, that's the correct architecture.
The One Thing to Remember
max_execution_time counts CPU time and will surprise you when your script is mostly waiting. request_terminate_timeout counts wall-clock time and is the real kill switch in FPM. Know which one is actually protecting you, because if you assume max_execution_time is your safety net and your workload is I/O-heavy, you have no safety net at all.
I've shipped enough production systems to tell you: the timer you think is protecting you is often the one doing nothing.
Need help shipping something like this? Get in touch.