Worker Mode

Worker mode runs persistent PHP workers that bootstrap once and then handle many requests, so the cost of starting PHP is paid a single time instead of on every request. Rather than tearing down and rebuilding PHP state per request, your application loads its autoloader, configuration, and database connections once and reuses them for the lifetime of the worker.

How it works

  1. Enable worker mode. Set WORKER_MODE_ENABLED=true and point ENTRY_FILE at your bootstrap script. This enables worker mode for all PHP workers in the pool.
  2. Bootstrap once. PHP starts and runs the outer scope one time. Autoloader registration, configuration loading, database connections, and any other initialization code execute a single time.
  3. Enter the request loop. Call oxphp_worker(callback). OxPHP begins dispatching incoming HTTP requests to your callback.
  4. Reset between requests. Superglobals ($_GET, $_POST, $_SERVER, $_COOKIE, $_FILES, $_REQUEST, php://input), output buffers, response headers, the session a request opened, and the ini directives a request changed are reset automatically. See What resets and what persists for what that covers and where it stops. A soft reset cleans per-request state while preserving bootstrapped resources in the outer scope. $_ENV is a deliberate exception: with PHP's default auto_globals_jit=1 it is not reset (see Superglobals).
  5. Outer scope persists. Variables defined before oxphp_worker(), static properties, database connections, and autoloaders remain available across all requests handled by that worker.
Note

Worker mode changes routing behavior. All requests that do not match a static file on disk are dispatched to the worker instead of returning 404. See Routing for details.

Configuration

Variable Default Description
WORKER_MODE_ENABLED false Enable persistent worker mode. Accepts true, 1, yes. Requires ENTRY_FILE to point at a .php script
ENTRY_FILE (unset) Path to the worker bootstrap script. Resolved against DOCUMENT_ROOT when relative; .. segments and absolute paths are allowed (worker bootstraps living outside the public document root are a supported layout)
WORKER_MAX_MEMORY_MIB 0 Maximum PHP memory per worker in MiB before recycling. 0 = unlimited
Migrating from WORKER_FILE

The legacy WORKER_FILE variable is still parsed (with a startup WARN) and behaves as WORKER_MODE_ENABLED=true ENTRY_FILE=$WORKER_FILE. New deployments should use the explicit pair; the legacy form will be removed in a future release.

For application-driven recycling, call OxPHP\Server\Worker::scheduleExit() from inside a request handler. The worker exits cleanly after the current request completes.

Writing a worker script

A worker script has two parts: the outer scope that runs once at startup, and the callback passed to oxphp_worker() that runs on every request.

worker.php
<?php // Outer scope: runs once at startup require __DIR__ . '/../vendor/autoload.php'; $config = parse_ini_file(__DIR__ . '/../config/app.ini'); $db = new PDO($config['dsn'], $config['user'], $config['pass'], [ PDO::ATTR_PERSISTENT => true, ]); $app = new MyApp\Application($config, $db); // Request loop: runs for every request oxphp_worker(function () use ($app) { $app->handle(); }); // Teardown: runs when this worker's loop ends, which is not // only at server shutdown — see Recycling below $app->terminate();

What resets and what persists

OxPHP performs a soft reset between requests. Per-request state is cleaned automatically, while anything bootstrapped in the outer scope survives for the life of the worker.

Reset between requests
  • Superglobals ($_GET, $_POST, $_SERVER, $_COOKIE, $_FILES, $_REQUEST) and php://input are repopulated with the new request data.
  • All output buffers are flushed and cleaned.
  • The HTTP status code and response headers are reset to defaults.
  • The last error information (message, file, line, type) and the connection status are cleared. User-registered error handlers (set_error_handler()) and exception handlers (set_exception_handler()) persist across requests.
  • The session a request opened is closed before the worker's next request, and $_SESSION is cleared. Session state below covers the boundaries.
  • Ini directives the request changed with ini_set(), ignore_user_abort() or error_reporting() belong to the request and roll back to the bootstrap's baseline when it ends. The boundaries are under Ini rollback.
Persists across requests
  • Variables in the outer scope keep their values: anything defined before oxphp_worker() and captured via use.
  • Class static properties retain their values.
  • PDO, MySQLi, and other persistent database connections remain open.
  • Registered autoloaders (Composer, custom) remain active.
  • All previously loaded classes, interfaces, traits, and functions stay loaded.
  • Ini directives set during bootstrap stay set: an ini_set() in the outer scope holds for the life of the worker and is what per-request changes roll back to.
  • $_ENV is deliberately not reset (with PHP's default auto_globals_jit=1). It describes the worker rather than the request. That keeps a .env loader's values alive past the boot that wrote them, and keeps a write made inside a request handler alive just as long, for every later request on that worker and any request multiplexed alongside it. See Superglobals.

Session state

The session a request opened is closed before the worker takes its next one: the data is handed to the save handler, the session id is released, and $_SESSION leaves the symbol table. The next request therefore starts a session under its own cookie, reads '' from session_id() until it does, and finds $_SESSION undefined until then. Three boundaries are worth knowing:

  • A session another request is still in is closed at the worker's next idle moment, not when the request that opened it returns. A session no other request on the worker is in is closed as the request ends: after its shutdown functions and before its ini changes are put back. That is the order PHP's own request shutdown uses, so the write runs under that request's settings. A shared one is written under whatever settings the thread has when it is given back. Until then the worker still holds that session open, and the save handler still holds whatever it locked. In the default files handler that is an exclusive, blocking lock on the session file: a request for the same session id arriving anywhere else (another worker, another process) waits inside the save handler, and because that wait is a blocking system call on the worker thread, it stalls every request that worker is carrying. Call session_write_close() once the session data is final, and both the write and the unlock happen where your application put them.
  • A session belongs to the worker thread, not to the request, so requests that overlap on one share it. A worker takes new requests whenever the one it is running suspends: in oxphp_async_await(), oxphp_sleep(), oxphp_usleep(), or a socket wait under RUNTIME_HOOKS (a read, and with the net category also a connect, a TLS handshake, or a write that has to wait for room; the connect includes new PDO('mysql:…') and new mysqli(...)). It cannot give a session back while any request it is carrying could still be in it. The request taken in that window is handed it instead: session_start() answers true without looking at the new request's cookie, and $_SESSION is the paused request's array. session_write_close() does not separate the two either: it closes the session but leaves its id installed, so the request taken in the window then starts that id: it reads the paused request's data out of the store and gets the same id back in a Set-Cookie of its own. Where an application both uses sessions and lets requests suspend, the separation to rely on is not leaving a session open across a suspension point.
  • A request that was already paused before a session appeared is not treated as being in it. Sharing is counted from the moment a request is taken: the request that starts a session and every request taken while one is standing are in it, and the worker keeps the session for as long as one of them is alive. A request that suspended earlier is not; otherwise one long-lived request that never touches sessions would stop the worker from giving any session back. Such a request can still read $_SESSION after it wakes, and what it reads is whatever is standing then, which may be another client's. Do not read $_SESSION after a suspension point in a request that did not start a session of its own.
Sessions and suspension points

Close the session with session_write_close() before any oxphp_async_await(), oxphp_sleep() or hooked socket wait. An open session across a suspension point is shared with whatever request the worker takes next.

Ini rollback

Anything a request altered with ini_set(), ignore_user_abort() or error_reporting() belongs to that request, as it would under PHP-FPM: it is put back when the request ends. Most directives are also the request's own while it is paused on an await, a sleep or a socket wait: they are taken off the worker, so the requests the worker runs in the meantime see the baseline, and the paused request resumes with its own values. ini_set('default_socket_timeout', 5) around one HTTP call and ini_set('display_errors', '1') in a debugging branch apply to the request that made them, not to the next one.

The baseline they are restored to is what your bootstrap set, not the value in php.ini: an ini_set() in the outer scope is application configuration, survives every request, and every request starts with it. That includes ignore_user_abort(true): called in the bootstrap, it applies to every request the worker serves, streams included (see Server-Sent Events). Seven boundaries are worth knowing:

  • memory_limit and the session.* settings stay with the worker while it has other requests in flight. The heap belongs to the worker thread rather than to a request, and so does the session the session.* settings configure, including the save handler that session_set_save_handler() installs. These are not taken off the worker when a request pauses, and are put back only when the worker takes its next request with nothing else running on it. While a worker has work in flight (whenever a request is paused, and also while a fire-and-forget promise is still being reclaimed), a value one request set here applies to the requests the worker takes in that window.
  • set_time_limit() sets the limit of the request that calls it, but ini_get('max_execution_time') reports the worker's. Each request has a time limit of its own (see Request deadlines), and set_time_limit(), or ini_set() or ini_restore() on max_execution_time, changes that one and no other. The directive itself stays with the worker, as memory_limit does: ini_get('max_execution_time') answers with the last value any request set since the worker last had nothing in flight (or the bootstrap's, if none did). On a worker carrying several requests that need not be the caller's own limit. The directive is put back when the worker takes its next request with nothing else running on it.
  • A directive whose handler does more than store its value stays on the worker while its request is paused. Moving a directive runs its handler, and some handlers act as they store: default_charset and the encoding settings reset mbstring's encodings, zlib.output_compression starts an output handler, open_basedir resolves a relative path against the current directory, and the deprecated assert.* settings raise a deprecation each time. Run on every pause and resume, they would undo what the request did in between, so these stay on the worker: requests it runs while one is paused see the paused request's value, and the directive is put back when the last request in flight ends. The directives that travel are the ones stored by one of PHP's standard setters: ignore_user_abort, precision, display_errors, error_reporting, default_socket_timeout, user_agent and most others. A few that only validate still stay on the worker: include_path while OPcache is enabled (set_include_path() included; with OPcache off it travels), error_log, date.timezone and default_mimetype. What a request sets through a function rather than an ini directive (mb_internal_encoding(), date_default_timezone_set(), setlocale()) is not an ini change at all. It belongs to the worker and outlives the request unless the request puts it back.
  • A directive whose handler refuses to be put back stays as the request left it. Such a directive keeps the request's value until the worker next takes a request with nothing else running on it, and a paused request whose change is refused when it resumes continues without that change.
  • A request ended while one of its directives is being moved recycles the worker. A handler can run application code: putting assert.callback back drops the callback, and with it whatever the callback held, whose destructors run there. Anything that ends the request from inside one of them (a fatal error, or output written to a stream whose client has already left) leaves the handler part-way through, with the callback still set and pointing at a half-torn-down closure. Only a fresh worker puts that right, so the worker exits under oxphp_worker_recycles_by_reason_total{reason="scheduled"}, and the requests still in flight on it are ended as on any other recycle. The request itself is filed as its ending says: a fatal is answered 500 and counts toward the error breaker, a client that left is a cancellation and does not. An exception thrown by such a destructor is reported as the request's uncaught exception instead, and costs nothing more.
  • memory_limit is restored as a value before it is restored as a limit. PHP refuses to lower the allocator's ceiling while more than the new limit is still held. A worker left holding what the request allocated therefore reports the restored memory_limit from ini_get() while the allocator still enforces the raised one. The ceiling follows as soon as the worker's own footprint leaves room for it.
  • opcache.enable is not restored at all. Turning OPcache off is the only thing a request can do to it (PHP refuses to switch it back on mid-request), and what raises it again is OPcache's own per-request startup, which a worker runs once, when it boots. So a worker whose request turned OPcache off compiles every file from source for the rest of its life, and the directive is left reading 0 to say so. Restoring it would make ini_get('opcache.enable') and opcache_get_status() report an enabled cache that is not running. That is the worse option, because an application that asks so it can decide something would be told the opposite of what is happening.

Recycling

A recycled worker's loop ends and its PHP state is torn down; whether a fresh worker takes its place is up to the pool, as described below. Workers are automatically recycled when any of the following conditions are met:

  • The worker's PHP memory usage exceeds WORKER_MAX_MEMORY_MIB MiB.
  • The application requested an exit: the handler called Worker::scheduleExit(). This is useful for app-controlled hot reload, file-mtime-based reload, or per-request bootstrap re-execution.
  • The worker took 3 consecutive requests that came apart through a fatal error, an out-of-memory, a stack overflow, or one of two failures around the fiber the request runs in. Three in a row say the worker, rather than any one request, is at fault, and a worker that keeps coming apart is replaced rather than patched up again. The table below shows what does and does not count.
  • A request ended inside an internal function left a lot behind. A fatal error or a cancellation (max_execution_time elapsing, say) that ends a request inside an internal function leaves behind what that function had allocated for itself, and the worker cannot give that back: usort() sorts a copy of the array, and the copy keeps every element alive. The worker compares its memory usage between two moments when no request is in flight. If a request was ended with an internal function's call on its stack in between and the usage grew by more than 4 MiB, the worker is recycled, with a warning in the log that names the reason. A request ended just as an internal function such as curl_exec() returned counts as well, because a time limit is noticed while that call is still on the stack. The stacks of fibers the worker created in that time are not counted, since it keeps them for as long as it lives. Garbage that only the cycle collector can free is collected first, but only what the collector already knew about: a cycle that only the cleanup after the bailout released is never handed to it, stays allocated, is counted, and recycles the worker. No request is in flight at that moment, so none is ended by it, and the recycle is counted under oxphp_worker_recycles_by_reason_total{reason="scheduled"}. It does not depend on WORKER_MAX_MEMORY_MIB. What decides is the growth, not the call, so anything else that grew the worker's memory in the same stretch counts too: a cache filled for the first time, a large request body not yet released. What it does not catch: a leak under about 4 MiB, which adds up until WORKER_MAX_MEMORY_MIB is passed or, without it, until requests start failing against PHP's memory_limit; a leak in the first stretch in which a worker is busy, which is not judged because what a fresh worker builds for itself in its first requests would otherwise recycle every replacement in turn while timeouts keep coming; anything while the server is shutting down; and a worker that is never without a request in flight, where the check waits for the first such moment.
  • An out-of-memory error left more than memory_limit in use. PHP builds the Allowed memory size … exhausted report with the limit set aside, since the report needs memory of its own, and on PHP 8.5 that report includes a backtrace of every frame on the stack (fatal_error_backtraces, on by default). After a runaway recursion that backtrace can be larger than the limit itself: a recursion 400,000 calls deep under memory_limit=128M left about 290 MiB counted as in use. While the allocator counts more than the limit as in use it refuses nothing, for any request on the worker. PHP-FPM gets that memory back by shutting the request down. A worker shuts no request down, so it hands the memory back and puts the limit back in force as the failing request ends, after its shutdown functions; a request the worker serves while one of them is suspended (in oxphp_async_await(), oxphp_sleep()/oxphp_usleep(), or a sleep(), usleep() or socket wait taken over by RUNTIME_HOOKS) runs with no limit as well. Where what the worker still holds is more than the limit even then (a shutdown function of that request kept that much, say), the worker is recycled at its next moment with no request in flight, with a warning in the log that names the reason, under oxphp_worker_recycles_by_reason_total{reason="scheduled"}. The requests it serves until then run without memory_limit in force, as a rule with no limit at all. Unlike the check above, this one applies from a worker's first request, but not while the server is shutting down. A thread of the async pool hands the memory back as the failing task ends and, where that is not enough, tries again as each later task ends; it is not recycled. fatal_error_backtraces=0 leaves the backtrace out, and with it most of what the report takes.

Not every failed request counts, because not every failure says the worker is unfit to serve:

Outcome Effect on the count
Fatal error, out-of-memory or stack overflow raised in the request handler, in a shutdown function, or in a destructor that runs as those shutdown functions are released Counts
The fiber the request runs in would not start: the engine could not allocate its C stack, so the handler never ran Counts
The request would not stop suspending that fiber from userland, and the server ended it Counts
Uncaught exception (answered 500) from the request handler or from a shutdown function Neutral
Cancelled request: max_execution_time elapsed, the server is shutting down, or the client of a streaming response that has not called ignore_user_abort(true) hung up Neutral
Request completed, exit()/die() included Clears the count

"Neutral" means exactly that: one of those in the middle of a run of fatals neither adds to the count nor clears it, so fatal, exception, fatal, fatal still recycles the worker. A failure in a shutdown function is read the same way as one in the request handler. PHP runs shutdown functions under protection of its own, so the worker sees a request that failed inside one return normally. A fatal there is still a request coming apart on its own, so it counts the same, and an exception there unwinds as cleanly as one from the handler, so it is neutral the same way.

The count is decided by why a request ended rather than by what it left behind. It answers whether this worker is at fault, and a request the server itself cut short is not evidence against it. A cancellation is therefore neutral whether it lands in the handler (a deadline expiring mid-render, the server shutting down) or in a shutdown function, provided the request had not already hit a fatal. A deadline that expires in a shutdown function of a handler that already came apart leaves that failure counted. Shutting the server down is the one cancellation that is neutral unconditionally, because a worker on its way out is not being evaluated.

Outside a streaming response, a client hanging up is not an outcome of its own in worker mode. It does not end the request, which is free to finish and run its own cleanup, and the request is then read by how it ended: reaching its end clears the count, a fatal counts, an uncaught exception is neutral. This is deliberate. A request that ran its handler to the end is evidence that the worker can serve, whether or not anyone was left to read the response, and a worker that fatals on every request fatals on this one too, so a departing client cannot keep such a worker alive.

The two fiber rows count for the same reason a fatal does: the request did not reach the end of its own work, and the server did not cut it short. A fiber does not start when the engine cannot allocate the C stack it runs on (fiber.stack_size set below the platform minimum of two memory pages, or the process unable to map more memory), and the handler is never reached. The other is the application's own doing: the server drives a request's fiber itself, so a userland Fiber::suspend() on it is refused with a FiberError, and a handler that catches that and retries in a loop is ended rather than allowed to retry indefinitely. That ending cannot be caught by userland, but it still runs the request's finally blocks, destructors and shutdown functions, and still sends what the request produced under the status the handler set. The WARN line for a counted failure names which of the two it was: fiber would not start or fiber refused to park.

What none of this does is diagnose a worker that has wedged rather than failed: a request stuck in a syscall is reported through oxphp_worker_stuck_total for an operator to act on, not cancelled and not counted. Nothing in the server ends such a request automatically (no recycle, no cancellation), so a wedged worker stays wedged until the call returns or the process restarts. max_execution_time is not a backstop for it either: a time limit ends a request only where PHP code runs, which a request stuck in a syscall does not reach until the call returns (see Request deadlines).

When a worker is recycled, its loop ends and its replacement re-executes the outer scope of the worker script. Workers are OS threads inside the single OxPHP process, not separate processes, so nothing the operating system sees restarts, and getmypid() returns the same value in every worker, before and after. Whether a replacement arrives at once depends on the pool:

  • A static pool (PHP_WORKERS=N) refills to N on its next scan, so the replacement is one-for-one.
  • A dynamic pool (PHP_WORKERS=MIN:MAX) refills only to MIN: a worker recycled above the minimum leaves the pool one smaller until ordinary scale-up grows it again.

For memory-based and scheduled exit, the current request completes normally before the worker exits. For error-based recycling, the worker exits after the failed request.

A memory-based or scheduled recycle is logged at INFO. The worker writes Worker mode thread recycled with reason set to the label oxphp_worker_recycles_by_reason_total counts it under: max_memory, together with memory_bytes (the usage that passed the limit) and max_memory_bytes (the limit), or scheduled, which covers Worker::scheduleExit() and the recycles the worker schedules for itself. The pool follows with Recycled workers detected. A worker recycled after consecutive errors is logged at WARN as Worker mode thread retired after consecutive request failures, and the pool reports it, like any worker thread that ends without choosing to, as Dead workers detected, respawning.

So at LOG_LEVEL=warn a recycle on WORKER_MAX_MEMORY_MIB or on Worker::scheduleExit() leaves no line of its own. A worker that recycles on memory often is a leak worth chasing, and the place to see that is the rate of oxphp_worker_recycles_by_reason_total{reason="max_memory"}.

Retirement is not recycling

A dynamic pool retiring an idle worker (see Dynamic pool) also ends that worker's loop and also runs the code after oxphp_worker(), but it shrinks the pool instead of replacing the worker: nothing is spawned in its place, neither oxphp_worker_recycles_total nor oxphp_worker_recycles_by_reason_total moves, and oxphp_workers_retired_total counts it instead. Application teardown placed after oxphp_worker() therefore also runs on a healthy, serving pool (see oxphp_worker()).

Other requests the same worker was serving concurrently (suspended in oxphp_async_await(), oxphp_sleep(), or a socket wait under RUNTIME_HOOKS) do not get to finish. Each is cancelled where it is suspended and answered with 503 Service Unavailable and a Retry-After, after running its own shutdown functions. Recycling is therefore visible to clients whose requests happen to be in flight, which is worth knowing when choosing WORKER_MAX_MEMORY_MIB or calling scheduleExit() on a worker that serves concurrent requests. A full server shutdown is different: there, in-flight requests get the drain window to finish normally.

Request deadlines

Every request a worker serves is bounded by max_execution_time on its own, whether the worker took it with nothing else running or while it was carrying others. With fiber multiplexing a worker runs several requests on one thread, and each still gets the whole of its limit and no more:

  • A request's limit counts from the moment the worker takes it. It starts at the value of max_execution_time the worker's bootstrap left (the php.ini value, or whatever the outer scope of the worker script set). As everywhere in OxPHP's thread-safe PHP build, it counts wall-clock time, time spent suspended included (see Timeouts).
  • set_time_limit() applies to the request that calls it. It restarts that request's limit from the call, as it does under PHP-FPM, and leaves every other request on the worker as it was. Only the request's own deadline moves with it; the directive the call writes stays with the worker (see the boundaries under Ini rollback).
  • When a limit runs out, it ends the request it belongs to. That request is read as having run out of time: a 504, the timeout bit set in its own connection_status() for its shutdown functions, neutral for the recycle count. The requests running beside it carry on.
  • A suspended request is ended when it resumes, not at the moment its deadline passes. A request waiting in oxphp_async_await(), oxphp_sleep()/oxphp_usleep(), or a sleep() or a socket wait (a read, and with net a connect, a TLS handshake or a write that waits for room) taken over by RUNTIME_HOOKS runs no PHP code, and a time limit can only end code that runs. A request whose limit passed while it waited is ended as soon as it is back in PHP code: once the call it was waiting in returns, the request ends there as at any other timeout, its shutdown functions included. A call that waits once (a sleep, a single read) returns when it is resumed; one that waits again and again inside, such as stream_get_contents() reading a stream to its end, returns only when it is done as a whole. The request resumes when what it waits for arrives (the promise settles, the sleep elapses, the socket becomes readable, or writable for a write waiting for room, or connected for a connect), when the wait reaches its timeout, or when the server cancels it. For a read or a write that timeout is the stream's own (default_socket_timeout unless stream_set_timeout() set another); for a connect it is the timeout passed to stream_socket_client() or fsockopen(). A server shutdown that passes its drain deadline, and a recycle of the worker, cancel every request suspended at that moment, and one whose limit had already passed is ended by that cancellation like the others: a 503, with its shutdown functions run and not cut short by the spent limit.
  • What a worker runs between requests is under no limit. A session the worker gives back once no request is in it any more is written through its save handler outside any request, and the destructors of objects the worker's periodic garbage collection frees run outside any request too. A time limit bounds none of that.

Nothing else ends a suspended request for you. A client disconnecting does not end a non-streaming request, which runs to its end by design (see Recycling), and a request running past the supervisor's threshold is reported through oxphp_worker_stuck_total, not cancelled.

Tip

Put the limit on the wait itself: pass a timeout to oxphp_async_await() and its variants, and set timeouts on sockets and database clients.

Development reloading

Worker Mode persists bootstrap state (autoloader, DI container, DB connections) in memory, so opcache.validate_timestamps=1 alone is not enough to pick up changes to code that ran during the outer scope. For development loops there are two options:

  • Recycle every request. Call OxPHP\Server\Worker::current()->scheduleExit() at the end of every handler invocation (gated on a OXPHP_DEV env flag, for example). The current request completes normally, then the worker exits and a replacement re-executes the outer scope. On a static pool, which is what a development setup normally runs, that happens immediately. This trades the worker-mode performance win for FPM-style reload semantics. It is the simplest and most reliable approach for active development.
  • Keep the worker warm, reload request handlers. Skip scheduleExit() entirely, enable opcache.validate_timestamps=1, and keep your bootstrap minimal. Code loaded inside the request callback will be refreshed by OPcache on the next request; code loaded once in the outer scope will not. See OPcache and JIT → Development Settings for the full list of caveats.

Troubleshooting

Requests hang and never complete

If oxphp_worker() is never called in the bootstrap script, no requests are dispatched and every request waits indefinitely. Verify that your script calls oxphp_worker() unconditionally in the normal code path.

State leaks between requests

Variables defined inside the oxphp_worker() callback are cleaned up by PHP's garbage collector, but static properties and globals defined in the outer scope persist. If you see data from one request appearing in another, check for static properties or global variables that accumulate state across calls.

Fix: Reset static state explicitly at the start of each request callback, or avoid storing per-request state in statics.

Worker recycles immediately (memory limit)

The worker memory limit is checked after each request using PHP's reported memory usage. If your bootstrap phase allocates a large amount of memory (e.g. loading a large cache), the initial memory footprint may already be close to the limit.

Fix: Increase WORKER_MAX_MEMORY_MIB or defer large allocations to the first request.

Check: At LOG_LEVEL=info, each such recycle logs the usage that passed the limit as memory_bytes, next to the limit as max_memory_bytes. Every earlier check found the usage within the limit, so the difference between the two is at most what the worker grew by since the last check before it: a small one fits gradual growth, a large one a single jump. On a worker that recycles after its first request, that jump includes everything the bootstrap allocated.

bash
docker logs <container> 2>&1 | grep '"reason":"max_memory"'
Worker recycles immediately (error limit)

Three consecutive counted failures trigger a recycle. A fatal error in the request callback is the usual one, so check your application logs for those first. Uncaught exceptions do not count, and cancelled requests count only if a fatal was reported on the cancelled request as well.

A recycle with no fatal behind it is one of the two fiber failures, and the WARN line names which: fiber would not start points at fiber.stack_size or at the host (that C stack is mapped from the operating system, not PHP's heap, so neither memory_limit nor WORKER_MAX_MEMORY_MIB is the knob for it), and fiber refused to park points at a Fiber::suspend() your own code keeps retrying.

The server logs one WARN line per counted failure, naming the worker, where the failure came from and how many have run consecutively, and one more for the recycle that follows the third. The count is per worker: three lines from one worker are a recycle, three lines from three workers are not.

Check: Look for errors and warnings in the structured log output:

bash
docker logs <container> 2>&1 | grep -E '"level":"(ERROR|WARN)"'
Database connection drops after idle

If your database server closes idle connections, reconnect attempts in the next request may fail. Use a connection pool that handles reconnection, or catch the exception and reconnect manually.

Docker example

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.13.0 ports: - "8080:80" volumes: - ./src:/var/www/html environment: - DOCUMENT_ROOT=/var/www/html/public - WORKER_MODE_ENABLED=true - ENTRY_FILE=/var/www/html/worker.php - WORKER_MAX_MEMORY_MIB=128

PHP API

Worker introspection and the worker entry point are exposed through the OxPHP\Server\Worker class.

php
<?php $worker = OxPHP\Server\Worker::current(); $worker->serve(function () { handleRequest(); });

Legacy free functions (oxphp_is_worker, oxphp_worker_id, oxphp_worker) remain available and route through the same internal state. New code should prefer the class API.

The class also exposes runtime introspection useful for graceful self-recycling, observability, and health checks:

Method Returns
Worker::isWorkerMode(): bool Whether the server is running in worker mode
$worker->id(): int Stable per-thread worker ID
$worker->startTime(): float Unix timestamp of when this worker started
$worker->requestCount(): int Number of requests this worker has handled
$worker->memoryUsage(): int Current memory_get_usage(true) for this worker
$worker->rss(): int Current resident set size in bytes (Linux/macOS)
$worker->maxMemoryBytes(): int Recycle threshold: WORKER_MAX_MEMORY_MIB × 1 MiB, or 0 when unlimited
$worker->isExitScheduled(): bool Whether an exit with reason "scheduled" is pending: scheduleExit() was called, or the worker retired itself
$worker->exitReason(): ?string null while running; "scheduled", "max_memory", or "error" once the worker is going down

See OxPHP\Server\Worker for full signatures and worked examples.

PHP examples

Detecting Worker Mode

Use OxPHP\Server\Worker::isWorkerMode() to check whether the current process is running in worker mode. This is useful for writing code that works in both traditional and worker mode.

php
<?php if (OxPHP\Server\Worker::isWorkerMode()) { // Reuse a persistent connection $redis = new Redis(); $redis->pconnect('redis', 6379); } else { // Traditional mode: connect per request $redis = new Redis(); $redis->connect('redis', 6379); }

Symfony worker script

worker.php
<?php use App\Kernel; require __DIR__ . '/../vendor/autoload.php'; $kernel = new Kernel('prod', false); $kernel->boot(); oxphp_worker(function () use ($kernel) { $request = Symfony\Component\HttpFoundation\Request::createFromGlobals(); $response = $kernel->handle($request); $response->send(); $kernel->terminate($request, $response); }); $kernel->shutdown();

Best practices

  • Set WORKER_MAX_MEMORY_MIB (e.g. 128) so a leaking worker recycles automatically instead of consuming the host. Combine with Worker::scheduleExit() for application-driven recycling on top. A worker can grow even when the application leaks nothing: when a fatal error or a cancellation abandons a request inside an internal function, what that function allocated for itself is not given back. For example, a request cancelled in the middle of usort() keeps the whole array being sorted alive, and a worker that grew by more than 4 MiB that way is recycled, as described under Recycling. Smaller leaks stay until WORKER_MAX_MEMORY_MIB is passed or, without it, until requests start failing against memory_limit. A fatal error or a cancellation that ends a request inside a Fiber it runs (one it started, or the one PHP runs garbage-collection destructors in) leaves what the fiber's own frames were holding allocated for the life of the worker in the same way.
  • Close the session before a suspension point. Sessions belong to the worker thread, so one left open across an oxphp_async_await() or oxphp_sleep() is shared with the next request the worker takes. See Session state.
  • Avoid storing per-request state in static properties or globals. Since these persist across requests, leftover state from one request can leak into another.
  • Validate the soft reset early. Add Worker::current()->scheduleExit() to your handler under a development flag and exercise the application end-to-end. This catches state-leak bugs before you commit to long-lived workers.
  • Handle database idle timeouts. If your database driver disconnects after an idle period, catch the exception and reconnect, or use a connection pool that handles reconnection automatically.
  • Keep the outer scope minimal. Only bootstrap what truly needs to persist: autoloaders, configuration, and shared services. Defer request-specific setup to the callback.

Prompts for your agent

These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.

prompt
Move my application to OxPHP worker mode. Read the `features/worker-mode` page through the OxPHP documentation MCP server. Audit my bootstrap and my request handlers for state that survives between requests or is shared by requests that overlap on a worker (sessions left open across a suspension point included), then show me the changes that make this repository safe to run in a persistent worker.

See also

  • Routing: how worker mode plugs into URL routing
  • Early Response: send the response immediately and continue background processing
  • PHP Functions: full reference for oxphp_worker(), oxphp_is_worker(), and other built-in functions
  • Configuration Reference: complete list of environment variables
Found a mistake? Report it →