Worker Mode
Worker mode runs persistent PHP processes 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
- Enable worker mode. Set
WORKER_MODE_ENABLED=trueand pointENTRY_FILEat your bootstrap script. This enables worker mode for all PHP workers in the pool. - 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.
- Enter the request loop. Call
oxphp_worker(callback). OxPHP begins dispatching incoming HTTP requests to your callback. - Reset between requests. Superglobals (
$_GET,$_POST,$_SERVER,$_COOKIE,$_FILES,$_REQUEST,php://input), output buffers, response headers, 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.$_ENVis the exception and is deliberately not reset — see Superglobals. - Outer scope persists. Variables defined before
oxphp_worker(), static properties, database connections, and autoloaders remain available across all requests handled by that worker.
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 |
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.
<?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();
});
// Shutdown: runs when the worker exits
$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.
- Superglobals —
$_GET,$_POST,$_SERVER,$_COOKIE,$_FILES, andphp://inputare repopulated with the new request data - Output buffers — all output buffers are flushed and cleaned
- Response headers — HTTP status code and headers are reset to defaults
- Error state — last error information (message, file, line, type) and connection status are cleared. User-registered error handlers (
set_error_handler()) and exception handlers (set_exception_handler()) persist across requests - Ini directives changed by the request —
ini_set(),set_time_limit()anderror_reporting()roll back to the bootstrap's baseline before the worker's next request. The boundaries are below
- Variables in outer scope — anything defined before
oxphp_worker()and captured viause - Static properties — class static properties retain their values
- Database connections — PDO, MySQLi, and other persistent connections remain open
- Autoloaders — registered autoloaders (Composer, custom) remain active
- Loaded classes and functions — all previously loaded classes, interfaces, traits, and functions
- Ini directives set during bootstrap — an
ini_set()in the outer scope holds for the life of the worker and is what per-request changes roll back to
Ini rollback
Anything a request altered with ini_set(), set_time_limit() or error_reporting() is put back before the next request the worker takes on its own, as it would be under PHP-FPM. ini_set('default_socket_timeout', 5) around one HTTP call and set_time_limit(0) in a background 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 and survives every request. Three boundaries are worth knowing:
- The rollback happens when the worker takes its next request with nothing else running on it. Ini directives belong to a worker thread, not to a request. A worker serving several requests at once — the case whenever a request pauses on an await, a sleep or a socket read, and also while a fire-and-forget promise is still being reclaimed — cannot put one request's changes back without taking them away from another that is still running. It does not try: while a worker has work in flight, changes made on it stay visible to the requests it takes in that window. Do not rely on the rollback to contain a directive whose leakage would matter, such as
display_errors, in an application that serves requests concurrently. memory_limitis 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 restoredmemory_limitfromini_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.enableis 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 reading0to say so. Restoring it would makeini_get('opcache.enable')andopcache_get_status()report an enabled cache that is not running — the worse option, because an application that asks in order to decide something would be told the opposite of what is happening.
Recycling
Workers are automatically recycled (restarted with a fresh PHP process) when any of the following conditions are met:
- Max memory exceeded — the worker's PHP memory usage exceeds
WORKER_MAX_MEMORY_MIBMiB - Application requested exit — the handler called
Worker::scheduleExit(). Useful for app-controlled hot reload, file-mtime-based reload, or per-request bootstrap re-execution - Consecutive errors — the worker took 3 consecutive requests that came apart: a fatal error, an out-of-memory, a stack overflow. What those leave behind is engine state the next request would inherit, which is what the recycle is for. See below for what does and does not count
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, stack overflow — wherever it is raised, a shutdown function and a destructor run at the end of the request included | Counts |
Uncaught exception (answered 500) — from the request handler or from a shutdown function |
Neutral |
Cancelled request — the client hung up, max_execution_time elapsed, the server is shutting down |
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. Where a failure was raised makes no difference to how it is read. PHP runs shutdown functions under protection of its own, so the worker sees a request that failed inside one return normally — but a fatal there leaves the same wreckage the next request on that worker would inherit from any other fatal, so it counts the same, and an exception there unwinds as cleanly as one from the handler, so it is neutral the same way. A deadline is the one thing read differently depending on when it lands: expiring while a shutdown function runs is still the server ending the request and stays neutral, unless the request had already failed on its own before that, in which case it stays counted. 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.
When a worker is recycled, the PHP process terminates and a new one starts, re-executing the outer scope of the worker script. 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.
Other requests the same worker was serving concurrently — suspended in oxphp_async_await(), oxphp_sleep(), or a socket read 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.
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 aOXPHP_DEVenv flag, for example). The current request completes normally, then the worker exits and is respawned, re-executing the outer scope. 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, enableopcache.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.
Worker recycles immediately (error limit)
Three consecutive fatal errors trigger a recycle. Check your application logs for fatals in the request callback — not for uncaught exceptions or cancelled requests, which do not count.
Check: Look for errors in the access log or structured log output:
docker logs <container> 2>&1 | grep '"level":"error"'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
services:
app:
image: ghcr.io/oxphp/oxphp:0.11.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=128PHP API
Worker introspection and the worker entry point are exposed through the OxPHP\Server\Worker class.
<?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 scheduleExit() has been called |
$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
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
<?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 withWorker::scheduleExit()for application-driven recycling on top. - 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.
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