In a PHP worker-mode runtime, why do exit(), register_shutdown_function(), ini_set() and setlocale() behave differently than they do under PHP-FPM?
answer
- one PHP request, many HTTP requests
- exit ends the worker, not the request
- shutdown functions pile up
- ini and locale persist
- destructors of long-lived services never run
basics
~20 sThese built-ins are tied to the end of a PHP request, and in worker mode the PHP request lasts the whole worker lifetime. exit ends the worker, shutdown functions run only when it exits, and ini_set() or setlocale() changes persist into later requests.
solid answer
~40 sPHP's built-ins assume one PHP request per HTTP request, and at request end the engine runs shutdown functions, destructs objects, flushes output and restores `ini_set()` values. In worker mode that end comes only when the worker stops. So `exit` or `die` inside request code ends the whole worker instead of the response, forcing the runtime to start and boot a new one. A callback registered with `register_shutdown_function()` per request is not run after that request; callbacks pile up and run together when the worker exits. `ini_set()`, `set_error_handler()`, `error_reporting()`, `date_default_timezone_set()` and `setlocale()` change state that persists into the next request on that worker. And `__destruct()` on long-lived services never runs between requests. Code must use the runtime's request/response API and restore anything it changes.
code
php · 21 lines<?php
declare(strict_types=1);
// Worker-unsafe: the locale leaks into every later request on this worker.
function formatInvoiceTotal(float $amount): string
{
setlocale(LC_NUMERIC, 'de_DE.UTF-8');
return sprintf('%.2f', $amount);
}
// Worker-safe: restore what you changed, even on failure.
function formatInvoiceTotalSafe(float $amount): string
{
$previous = setlocale(LC_NUMERIC, '0'); // '0' reads the current value
setlocale(LC_NUMERIC, 'de_DE.UTF-8');
try {
return sprintf('%.2f', $amount);
} finally {
setlocale(LC_NUMERIC, $previous);
}
}go deeper
Recall that in worker mode the PHP request lasts the whole worker lifetime, so exit ends the worker and settings persist between requests.
Walk through which built-ins are tied to request end, and what each one does instead in a worker: exit, shutdown functions, destructors, ini, locale.
Audit a code base for request-end assumptions, replace exit and shutdown hooks with explicit after-request hooks, and restore global settings in finally.
Decide how to handle dependencies that assume request end: patch, replace, or keep them on a per-request pool beside the worker-mode service.
## The root cause: one PHP request per worker PHP's engine has a notion of a **request**: it begins, runs your script and ends with a fixed shutdown sequence. Under PHP-FPM that PHP request and the HTTP request are the same thing. In **worker mode** (FrankenPHP, RoadRunner, Swoole) the worker script is started once and loops over incoming HTTP requests, so as far as the engine is concerned the **whole worker lifetime is one PHP request**. Every built-in whose behaviour is defined by "the end of the request" now acts at the end of the worker. ## Built-ins that change meaning | Built-in | Under PHP-FPM | In a worker | |---|---|---| | `exit` / `die` | ends this response | ends the whole worker | | `register_shutdown_function()` | runs after this request | runs when the worker exits; per-request calls pile up | | `__destruct()` of services | runs at request end | never, while the service is alive in the container | | `ini_set()`, `error_reporting()` | restored at request end | persists into later requests | | `set_error_handler()`, `set_exception_handler()` | cleared at request end | persists until replaced | | `setlocale()`, `date_default_timezone_set()` | reset per request | persists into later requests | | `ob_start()` left open | flushed at request end | stays open, wrapping later output | ## exit and die Older code, and some libraries, end a response with `exit` after sending a redirect or a file. In a worker that call ends the **worker script**: the loop never reaches the next request, the runtime has to start a replacement and pay the boot again, and depending on the runtime the current response may be cut short or reported as an error. Worker-safe code returns a response object up the stack instead of exiting. ## Shutdown functions and destructors `register_shutdown_function()` is often used for per-request work such as flushing logs or metrics. In a worker nothing calls it after the HTTP request; worse, if the registration runs per request, each call adds another callback to a list that only grows (a memory leak) and that fires all at once, much later, when the worker stops. The same goes for relying on `__destruct()` to flush a buffer: a service held by the container is never destroyed between requests. Move such work into an explicit **after-request hook** of the worker loop. ## Process-level settings Several built-ins change settings that the engine normally restores at request end: - `ini_set()` changes a directive; `ini_restore()` puts one back, but only if you call it. - `set_error_handler()` stacks a handler; `restore_error_handler()` pops it. - `setlocale()` changes the locale used by locale-aware functions for everything that follows. - `date_default_timezone_set()` changes the default time zone for every later date operation. A request that switches locale to format a German invoice, or raises `error_reporting()` for one legacy call, now affects every later request served by that worker. The fix is either to restore the setting in a `finally` block or to avoid the global setting entirely, for example by passing an explicit locale or time zone to the formatting API. ## Output and headers How the response reaches the client also differs by runtime: 1. **FrankenPHP** exposes the familiar SAPI model inside its handler, so `echo`, `header()` and the superglobals work per request. 2. **RoadRunner** and **Swoole** run PHP as a CLI process and hand you a request object; the response must be built through their API, and `header()` from the CLI SAPI does not reach the client. 3. On any runtime, an output buffer opened with `ob_start()` and never closed stays open across requests; check `ob_get_level()` is back at its starting value after each request. ## What to take away Worker mode does not change what these functions do; it changes **when the request ends**. Audit code for request-end assumptions; a plain text search for these calls finds most of them: - `exit` and `die` in request-handling code; - `register_shutdown_function()` outside boot code; - `__destruct()` methods that flush, send or commit; - `ini_set()`, `error_reporting()`, `set_error_handler()` without a matching restore; - `setlocale()` and `date_default_timezone_set()` outside boot code; - `ob_start()` without a guaranteed `ob_end_clean()` or flush. Each hit either moves to boot, gets a restore in `finally`, or is replaced by an explicit after-request hook.
- A library you depend on calls exit after streaming a file download. What are your options in worker mode?Prefer a version or alternative that returns a response or stream instead of exiting. If you cannot change it, isolate that endpoint: serve it from a classic per-request pool, or accept that each call restarts a worker and keep it off hot paths. Wrapping it does not help, because `exit` is not an exception you can catch.
- How can you detect that a request left the worker's global settings changed?Snapshot the relevant values at boot, such as `ini_get()` for key directives, `setlocale(LC_ALL, '0')`, `date_default_timezone_get()` and `ob_get_level()`, and compare after each request in development or tests. Any difference names the request that changed global state without restoring it.
saying these in an interview costs you the question
- exit in a worker ends only the current HTTP response
- register_shutdown_function() runs after every HTTP request in a worker
- The engine restores ini_set() changes after every HTTP request
- setlocale() only affects the current request
- Service destructors run at the end of each request