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

  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, 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 the exception and is deliberately 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(); }); // 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.

Reset between requests
  • Superglobals$_GET, $_POST, $_SERVER, $_COOKIE, $_FILES, and php://input are 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 requestini_set(), set_time_limit() and error_reporting() roll back to the bootstrap's baseline before the worker's next request. The boundaries are below
Persists across requests
  • Variables in outer scope — anything defined before oxphp_worker() and captured via use
  • 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_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 — 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_MIB MiB
  • 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 a OXPHP_DEV env 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, 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.

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:

bash
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

compose.yaml
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=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 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
<?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.
  • 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
Found a mistake? Report it →