Configuration Reference

OxPHP is configured through environment variables, plus one optional file: .oxphpdeny, the path deny rules read from the top of DOCUMENT_ROOT. Every setting has a default, so a zero-configuration deployment works out of the box.

Boolean values

Variables marked as boolean accept a fixed canonical set, case-insensitive and trimmed:

  • truthy: on, true, 1, yes
  • falsy: off, false, 0, no

Any non-empty value outside that set, such as the typo ture, fails fast at startup with an error naming the variable. This catches misconfiguration before traffic instead of silently flipping a flag the wrong way.

An unset variable or empty assignment (FOO=) falls back to the documented default. Empty is treated as unset on purpose: Docker Compose / Kubernetes substitution like FOO=${FOO} produces FOO= when the host variable is missing, and that should not refuse to start the server.

Server

Variable Default Description
LISTEN_ADDR 0.0.0.0:80 Address and port for the main HTTP server
DOCUMENT_ROOT /var/www/html/public Root directory for serving files and PHP scripts
ENTRY_FILE (unset) Single canonical entry script. Unset = direct file mapping. *.php = front controller. Non-.php = static fallback (SPA). With WORKER_MODE_ENABLED=true = worker bootstrap. Resolved against DOCUMENT_ROOT (relative paths and .. allowed; absolute paths used as-is). See Routing
WORKER_MODE_ENABLED false Enable persistent worker mode. Requires ENTRY_FILE to point at a .php script. Boolean (see Boolean values)
MAX_CONNECTIONS 10000 Maximum concurrent TCP connections. Also the ceiling for the default QUEUE_MAX_WAITING (half of it), so a malformed value is a startup error rather than a silent fallback. Lowering it does not move the QUEUE_CAPACITY default, which is sized from the worker count alone. Keep it above PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING; see Keeping headroom for the accept loop
TOKIO_WORKERS CPU / 2 (min 1) Async I/O threads. 1 = single-threaded, N > 1 = fixed thread count, unset = auto (CPU / 2, min 1). See Sizing the async runtime

Sizing the async runtime

TOKIO_WORKERS defaults to half the available cores, and the half it leaves is not idle: it belongs to the PHP worker pool. The two sets of threads compete for the same cores, so the runtime cannot be widened without taking cycles from the application.

Raising it to the full core count is a loss wherever the pool is already sized near that count. Measured on a 4-core machine with a 4-worker pool serving compressed responses, going from 2 runtime threads to 4 cost 13% of the rate on a 1.6 KB JSON body and 26% on a 5 KB one, and burned more CPU doing it, because the threads were oversubscribed.

Raise it where the balance differs: a pool small relative to the machine, or work that is I/O-bound rather than PHP-bound (proxied upstreams, static files, streaming responses), where the runtime does the work and the pool mostly waits. Whenever you change it, watch the pool as well as the rate: oxphp_busy_workers against oxphp_workers_current tells you which side the cores went to. A rate that rises while the pool gets busier is a real gain; a rate that rises while the pool empties usually means the work moved rather than that more of it got done.

Tip

TOKIO_WORKERS and PHP_WORKERS both default to half the available cores, so their sum lands on the core count. Change one and the sum moves, which is why either one is worth measuring rather than guessing.

PHP workers

Variable Default Description
EXECUTOR sapi PHP executor backend. sapi for PHP execution, stub for benchmarking without PHP
PHP_WORKERS CPU / 2 (min 1) Worker pool size. N = fixed pool, MIN:MAX = dynamic scaling, 0 = auto
PHP_WORKERS_IDLE_SECONDS 30 Seconds a dynamic worker stays idle before being retired (dynamic mode only). A worker is retired at a moment when it has nothing in flight, so nothing it is serving is cut short. A worker holding a request that never ends, such as an open stream, never reaches such a moment and is not retired at all
QUEUE_CAPACITY Initial workers × 128 Maximum pending requests in the PHP queue. Requests arriving at a full queue wait for a slot (see QUEUE_WAIT_TIMEOUT_MS). For dynamic pools (MIN:MAX), initial workers = minimum count. 0 = auto
QUEUE_WAIT_TIMEOUT_MS 1000 The longest a request may spend waiting for a PHP worker before it is rejected with 529. This is a ceiling rather than a fixed value. It is one deadline, stamped on arrival, that covers both waits a request can face: for a slot in the queue, and inside the queue for a worker. Under overload the server shortens the budget below this ceiling on its own and lengthens it back once the queue drains; oxphp_admission_wait_budget_us publishes what is in force, oxphp_admission_wait_budget_ceiling_us what was configured (see The adaptive wait budget). The deadline is enforced from both ends: the request is answered with a 529 when it passes, and a worker that reaches a request past it refuses rather than executes it, so a queued request nobody picks up does not wait indefinitely. It does not bound how long the handler then runs. At most QUEUE_MAX_WAITING requests wait at once; past that, requests are rejected immediately. 0 = reject immediately whenever the queue is full; it removes the wait for a queue slot but not the wait inside the queue. Lower it when the application calls back into this same server over HTTP (the inner call needs a worker the outer one is holding, so the wait is spent for nothing), or when a load balancer in front has a shorter timeout of its own. A client that closes the connection mid-wait gives its place back immediately, on HTTP/1.1 as well as HTTP/2; one that closes while already in the queue keeps its place until a worker reaches it or the budget runs out, and is then answered 499 rather than counted as an overload refusal. What the server cannot see is a client that stops waiting without closing. Such a client holds its place until it is admitted or the budget runs out, and if a worker frees up first its script runs for nobody, so keeping the budget below the timeout of anything in front of it is still worth doing
QUEUE_MAX_WAITING Initial workers × 128, capped at MAX_CONNECTIONS / 2 Maximum requests parked waiting for a queue slot at once. Past it, requests are rejected immediately instead of waiting. Each waiter holds a connection and a fully buffered request body for as long as it waits, so this is a bound on resources held, not on waits that will pay off. The MAX_CONNECTIONS / 2 cap on the default bounds this part of the backlog only; queued and running requests hold a connection the same way, so the headroom the server actually keeps for accepting and refusing follows from PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING against MAX_CONNECTIONS (see below). Size it from your own handler latency, which is also covered below. 0 = auto, never below 1
QUEUE_MAX_WAITING_BYTES 67108864 (64 MiB) Maximum request-body bytes the parked requests may hold between them. A request whose body would push the total past it is rejected with 529 immediately instead of waiting; a request carrying no body is never rejected for this. QUEUE_MAX_WAITING bounds the same set in requests, which says nothing about their size. Bodies are buffered in full before a request reaches the queue, so with only a count to stop them the parked set can hold QUEUE_MAX_WAITING × 10 MiB. Raise it for an upload-heavy application that should absorb bursts of large bodies rather than shed them; lower it on a memory-capped container. 0 = auto

Sizing the waiting set

How many requests can usefully wait follows from how fast the pool retires them. With W workers and a handler that takes T milliseconds, the pool admits W / T requests per millisecond, so a budget of B milliseconds can let in about W × B / T waiters. Everything past that waits the whole budget and is refused anyway, holding a connection and a buffered request body the entire time.

Service rate is not known at startup, so the default cannot compute this and is deliberately generous. That fits fast handlers, where the pool drains a deep backlog well inside the budget, and is far too large for slow ones: with 8 workers, a 200 ms handler and the default 1 s ceiling (the budget in force whenever the pool is not shedding), only about 40 requests can be admitted in time while the default parks up to 1024.

bash
QUEUE_MAX_WAITING=40

Setting it near W × B / T turns the surplus into an immediate 529 rather than a 529 a second later. Shortening QUEUE_WAIT_TIMEOUT_MS achieves the same by the other term. The two trade against each other, and on a slow handler the smaller budget is usually the better lever, because it also bounds how long a client waits for the refusal. B here is the budget in force, which under overload is the one the server has shortened to rather than the configured ceiling. Read oxphp_admission_wait_budget_us before sizing the waiting set from a number in a config file.

The set is bounded a second time, in bytes. Every waiter holds its request body in memory for as long as it waits, so a cap counted in requests leaves the memory they hold to the traffic: the same 1024 waiters cost nothing on bodyless GETs and gigabytes on uploads. QUEUE_MAX_WAITING_BYTES bounds that sum directly: past it, a request carrying a body is refused at once rather than parked, while bodyless ones keep waiting normally. An upload-heavy application that should absorb bursts instead of shedding them wants a larger value; a container with a hard memory limit wants a smaller one. Refusals for either cap are counted separately in oxphp_admission_refused_total (waiting_full against the count, waiting_bytes against the memory), so the metric names the knob to reach for.

The adaptive wait budget

QUEUE_WAIT_TIMEOUT_MS is a ceiling. While the work the waiting admits is completing for clients who have already left, the server shortens the budget on its own, and steeply, which is worth knowing before choosing a ceiling:

  • Down: the budget is halved each time a quarter or more of what the pool started went to clients who had already gone, measured over a window of at least eight started requests rather than a fixed interval.
  • Floor: a sixty-fourth of the ceiling (15.6 ms at the default) or 10 ms, whichever is larger, though never above the ceiling itself.
  • Up: it walks back in eighths of the ceiling, and only while the queue is observed empty and less than a quarter of what the pool started went to departed clients. The ordinary trickle of closed tabs and client timeouts does not hold it down, and a server whose queue never empties stays on the shortened budget until the load lets up.

An operator who has set 1000 should expect about 15 to be in force during an episode, so write alerts on the wait against the pair oxphp_admission_wait_budget_us / oxphp_admission_wait_budget_ceiling_us rather than against the configured number.

The shortening is driven by clients who close. A population that stops waiting without closing produces no signal and leaves the ceiling in force.

Self-calls

An application that calls back into this same server over HTTP wastes its waits however long they are: the inner call needs a worker the outer one is holding. oxphp_admission_wait_wasted_total counts those waits, but only while the budget is at its ceiling. Where the budget has come down the counter goes quiet, so a 0 there does not by itself show that the pattern is absent. Whether the budget comes down is not a property of the self-call either: a self-caller whose own callers wait generously (an internal cron, a sidecar on a long timeout) keeps its ceiling.

QUEUE_WAIT_TIMEOUT_MS=0 does not fix this. It removes the wait for a queue slot, not the wait inside the queue, so unless QUEUE_CAPACITY is small enough for the queue to be full, the inner call is admitted to a queue nothing is consuming and is never answered at all.

Keeping headroom for the accept loop

A request holds its connection, and with it one of the MAX_CONNECTIONS permits, from the moment it arrives until it is answered, whatever it is doing in between. That covers three separate populations, because a queue slot is released the moment a worker picks the request up, before the script runs:

  • running in a worker: at least PHP_WORKERS, and more in worker mode, where one thread multiplexes fibers;
  • queued, up to QUEUE_CAPACITY;
  • parked in admission, up to QUEUE_MAX_WAITING.

Only the third has a ceiling derived from MAX_CONNECTIONS. Keep PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING below MAX_CONNECTIONS. Past it the failure mode changes for the worse: the accept loop takes a connection permit before it starts serving a connection, so once the PHP path holds them all it parks, and a client arriving then gets no response at all instead of a 529. A load balancer cannot tell that silence apart from a dead instance, while the health probe on INTERNAL_ADDR stays green because it does not go through either limit.

The stall is logged. An accept that has to wait for a permit logs a WARN naming the budget, and an INFO reporting how long the loop was parked is written the moment the permit arrives. The reports are rate-limited so a server flapping around the ceiling cannot turn its own log into the outage: a stall beginning within seconds of a reported one is held back at first, and reported when that window closes if it is still going; one that ends inside the window leaves no line at all. The metrics see every one: oxphp_accept_stalled (a gauge, readable while the stall lasts because the internal listener does not go through MAX_CONNECTIONS) and oxphp_accept_stalls_total (a counter); see the metrics reference. Arriving clients still get no response rather than an error, but the log and the metrics record the stall.

The server checks this at startup and warns when the sum reaches the budget, naming every value; oxphp config --check reports the same, without changing its verdict or exit code. Treat clearing the warning as necessary rather than sufficient: idle keep-alive connections, static-file requests and handshakes in progress hold permits too, and none of them can be counted at startup.

Lowering MAX_CONNECTIONS is the usual way into the warning, because the QUEUE_CAPACITY default is sized from the worker count and does not follow it down. With 7 workers and MAX_CONNECTIONS=1000, the sum is 7 + 896 + 500 against a budget of 1000. Note which knob to reach for: raising MAX_CONNECTIONS on its own does not clear it while QUEUE_MAX_WAITING is left at its default, because that default is half of MAX_CONNECTIONS and rises with it. At 7 workers the sum settles at 1799, and the condition only clears from MAX_CONNECTIONS=1800 up. Lower QUEUE_CAPACITY, or set QUEUE_MAX_WAITING explicitly and then raise the budget.

For a dynamic pool (MIN:MAX) the running term is the minimum, the same count the queue defaults are sized from, so a pool grown to its maximum holds more than the sum says.

The comparison counts connections while the budget is spent by requests, so an HTTP/2-heavy deployment can legitimately sit above it: one connection carries up to H2_MAX_CONCURRENT_STREAMS requests, so the same backlog is held by a fraction of the connections. The warning is expected there. A large pool on the stock budget reaches it too (an auto-sized pool of 39 workers gives 39 + 4992 + 4992 against 10 000), and that one is worth acting on rather than ignoring.

Static vs dynamic workers

Set PHP_WORKERS to a single number for a fixed pool:

bash
PHP_WORKERS=8 # Fixed 8 workers PHP_WORKERS=0 # Auto-detect: CPU / 2 (min 1)

Set PHP_WORKERS to MIN:MAX for automatic scaling:

bash
PHP_WORKERS=2:16 # Scale between 2 and 16 workers PHP_WORKERS=4:0 # 4 minimum, auto-detect maximum (CPU × 2) PHP_WORKERS=0:16 # auto-detect minimum (CPU / 4, min 1), 16 maximum

In dynamic mode, OxPHP scales workers up when all are busy and scales down when workers have been idle longer than PHP_WORKERS_IDLE_SECONDS.

How the CPU count is detected

Every default on this page written in terms of CPU (TOKIO_WORKERS, and PHP_WORKERS unset, 0, or with 0 on either side of MIN:MAX) counts the CPUs the process is allowed to run on: its affinity mask (taskset, docker run --cpuset-cpus), lowered to the container's CPU quota where one is set (docker run --cpus; cpu.max on cgroup v2, cpu.cfs_quota_us on v1). A quota is rounded down and counts as at least 1, so --cpus=1.5 counts as 1.

Two kinds of limit are not counted, and a container held back only by one of them is sized from every CPU it can see:

  • A CPU weight (docker run --cpu-shares, cpu.weight on cgroup v2, cpu.shares on v1) decides how contended CPU time is shared with neighbours. It is not a cap, and no CPU count follows from it.
  • A platform can sell a share of a machine and enforce the limit outside the container, where the container cannot see it. On DigitalOcean App Platform, for example, an instance sold as 1 vCPU was observed running in a gVisor sandbox with no quota (cpu.cfs_quota_us is -1) and 8 CPUs visible, so the defaults came to 4 PHP workers, 4 runtime threads and a queue of 512.
Plans that enforce limits out of sight

On such a plan, set PHP_WORKERS and TOKIO_WORKERS explicitly, to what the plan actually provides. In worker mode, check PHP_WORKERS × WORKER_MAX_MEMORY_MIB against the plan's memory as well. That threshold is a recycle trigger the worker checks in its loop, not a cap on what its PHP heap can allocate, so the pool can hold that much and more before any worker is recycled for it, with OPcache and the server itself on top; with the default 0 there is no threshold at all. The same sandbox can hide the memory limit too: the App Platform container above saw none.

At startup the server logs the count, what set it, and the resulting PHP_WORKERS and TOKIO_WORKERS. Here it is under docker run --cpus=1 on a 14-CPU machine:

json
{"timestamp":"2026-10-05T18:55:39.801254Z","level":"INFO","fields":{"message":"CPU count for default sizing","cpus":1,"cpu_source":"cgroup quota","host_cpus":14,"php_workers":"1 (auto)","tokio_workers":"1 (auto)"}}
  • cpu_source is host when neither a mask nor a quota lowered the count, affinity or cgroup quota when one of them did, and unknown when the server could not tell because the mask or the online count could not be read. If it could not count CPUs at all, cpus is 4.
  • host_cpus is how many CPUs the system reports online (/sys/devices/system/cpu/online on Linux), which a mask or quota does not lower. It is omitted when it cannot be read. A runtime that rewrites that file to list only the container's CPUs makes a narrowed mask read as host.
  • (auto) marks a value left entirely to the default: the variable is unset or empty, or, for TOKIO_WORKERS, not a number.

A cpu_source of host on a plan that sold you fewer CPUs than cpus is the case above.

Worker Mode

Variable Default Description
WORKER_MAX_MEMORY_MIB 0 Maximum memory in MiB per worker before recycling. 0 = unlimited

Set WORKER_MODE_ENABLED=true and point ENTRY_FILE at your worker bootstrap script (e.g. ENTRY_FILE=worker.php or ENTRY_FILE=../worker.php). PHP workers then stay alive across requests, keeping bootstrap state (autoloaders, database connections) in memory. Workers are recycled automatically when they exceed WORKER_MAX_MEMORY_MIB or take three consecutive requests that came apart, and on demand when the application calls Worker::scheduleExit(); see Recycling for what counts towards the third. The WORKER_MAX_REQUESTS knob from earlier releases is deprecated and ignored: set neither, or migrate to Worker::scheduleExit().

Deprecated: INDEX_FILE and WORKER_FILE

The legacy INDEX_FILE and WORKER_FILE variables are still parsed for backwards compatibility. When set, they emit a WARN log line at startup and map onto the new model:

Legacy Equivalent today
INDEX_FILE=index.php ENTRY_FILE=index.php
INDEX_FILE=index.html ENTRY_FILE=index.html
WORKER_FILE=/path/worker.php WORKER_MODE_ENABLED=true ENTRY_FILE=/path/worker.php

If both old and new are set, ENTRY_FILE / WORKER_MODE_ENABLED win. Migrate at your convenience; the deprecated forms will be removed in a future release.

SAPI / PHP

Variable Default Description
SUPERGLOBALS_ENABLED true Describe the request to PHP so it builds $_SERVER and $_GET before script execution. Set to a falsy value to skip that work in an application that reads neither. It reaches those two only: $_POST, $_FILES and $_COOKIE are built from the request body and the Cookie header, which are handed to PHP either way, and php://input is a stream rather than a superglobal. See SUPERGLOBALS_ENABLED for the full table

Timeouts

Variable Default Description
HEADER_TIMEOUT_SECONDS 5 Maximum seconds to receive HTTP headers after connection (Slowloris protection)
DRAIN_TIMEOUT_SECONDS 25 Maximum seconds to wait for in-flight connections during graceful shutdown. A request inside a blocking call at the deadline can hold the process past it (see Graceful Shutdown)

PHP execution time is bounded by PHP's own max_execution_time ini directive (and set_time_limit() at runtime), not an OxPHP env var. That bound does not reach an async task: a thread of the ASYNC_WORKERS pool holds no execution deadline, so neither the directive nor set_time_limit() limits what a task dispatched with oxphp_async() runs. Bound such a task with the timeout argument of oxphp_async_await() instead; see Async promises.

Rate limiting

Variable Default Description
RATE_LIMIT 0 (off) Maximum requests per IP per time window. 0 disables rate limiting
RATE_WINDOW_SECONDS 60 Rate limit window duration in seconds

Security

Variable Default Description
FRAME_OPTIONS SAMEORIGIN Clickjacking protection. SAMEORIGIN allows framing only by pages on the same origin, DENY blocks all framing, off disables (use when managing framing via your own CSP). Any other value falls back to the SAMEORIGIN default with a startup warning. Sets both X-Frame-Options and Content-Security-Policy: frame-ancestors on every response. See Clickjacking protection below for the emitted header values, how server headers defer to application-set ones, and guidance on choosing a value
TRUSTED_PROXIES (unset) Trusted reverse proxy networks: a comma-separated mix of CIDRs, bare IPs and the keywords private and cloudflare (e.g. private,cloudflare). When set, OxPHP extracts the real client IP from Forwarded (RFC 7239) or X-Forwarded-For headers using the rightmost-non-trusted algorithm; with cloudflare, a request whose forwarding chain shows it arrived from a Cloudflare edge takes it from CF-Connecting-IP instead. Also processes X-Forwarded-Proto and X-Forwarded-Host for $_SERVER['HTTPS'], REQUEST_SCHEME, SERVER_NAME, and SERVER_PORT. Unset = feature disabled
PHP_DENY_PATHS (unset) Comma-separated glob patterns whose .php files must never execute via direct URI (e.g. /uploads/**,/cache/**,/admin/legacy.php). Patterns may target whole directories or single files. Applies in the direct-mapping modes (Traditional and SPA); ignored with a startup warning in Framework and Worker modes, which never execute arbitrary .php files directly. Also covers scripts reached through directory-index resolution (/uploads/ → uploads/index.php). For direct .php URIs, matching happens before disk I/O, so denied paths produce the same response whether the file exists or not (no existence oracle). The legacy name PHP_DENY_DIRS is accepted as a deprecated alias and emits a startup WARN. See PHP Execution Deny-List
PHP_DENY_FALLBACK 404 What to return on a PHP_DENY_PATHS match or a .oxphpdeny deny rule. Either an HTTP status from 400 to 599 (pairs with ERROR_PAGES_DIR for custom HTML) or a /-prefixed URI path to a PHP fallback script inside DOCUMENT_ROOT. The fallback script receives OXPHP_DENIED_PATH and OXPHP_DENIED_PATTERN in $_SERVER. Validated at startup: the script must exist, canonicalize inside DOCUMENT_ROOT, and must not itself match PHP_DENY_PATHS (loop prevention). A .oxphpdeny rule may cover the script; in worker mode .oxphpdeny deny rules take a status code only. See Path Deny Rules
SYMLINK_ALLOW_PATHS (unset) Comma-separated list of absolute paths under which symlinks are permitted to escape DOCUMENT_ROOT. Each entry must already exist on disk; relative paths and missing paths abort startup. Unset = no symlink escapes allowed. See Symlink Allow-Paths

The special value private expands to all RFC-1918 private networks, loopback, and link-local addresses (IPv4 and IPv6): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1/128, fc00::/7, fe80::/10.

Clickjacking protection

Clickjacking is an attack where a hostile page embeds your site in an invisible <iframe> and tricks the user into clicking something they can't see: a "yes, delete my account" button, a one-click purchase, an OAuth "authorize" prompt. The defense is to tell the browser who, if anyone, is allowed to frame your pages. FRAME_OPTIONS controls this.

Two headers govern framing: the legacy X-Frame-Options, understood by all browsers, and the modern Content-Security-Policy: frame-ancestors, which supersedes it where both are present. OxPHP emits both, so the policy holds on old and new browsers alike. A single FRAME_OPTIONS value maps to a matching pair:

FRAME_OPTIONS X-Frame-Options Content-Security-Policy Who may frame your pages
SAMEORIGIN (default) SAMEORIGIN frame-ancestors 'self' Only pages on the same origin
DENY DENY frame-ancestors 'none' No one, not even your own pages
off (not sent) (not sent) Anyone (no framing restriction from the server)

SAMEORIGIN is the default: it blocks the cross-origin framing that clickjacking relies on, while still letting your own pages embed each other, which many apps legitimately do (admin previews, dashboard widgets, payment components hosted on the same origin). Choose DENY when nothing on your site is ever meant to be framed, not even by itself, for the strictest posture. Choose off only when you manage framing yourself through a full Content-Security-Policy your application sets, as described below.

Neither X-Frame-Options value can name a specific allowed origin (ALLOW-FROM was removed from the standard). To permit a named third party to frame your pages, set FRAME_OPTIONS=off and have your application emit its own Content-Security-Policy with an explicit frame-ancestors list, e.g. header("Content-Security-Policy: frame-ancestors 'self' https://partner.example.com");.

Headers the application sets take precedence. The server headers are fallbacks, applied only when the response carries no such header: an application that sets its own X-Frame-Options or Content-Security-Policy via PHP header() keeps it untouched. The two framing headers are treated as one policy, so the server never contradicts the application:

  • If your app sets X-Frame-Options, OxPHP skips its frame-ancestors fallback (a server CSP would override your app's choice in modern browsers).
  • If your app sets a Content-Security-Policy that contains a frame-ancestors directive, OxPHP skips its X-Frame-Options fallback (a stricter server X-Frame-Options would over-block in legacy browsers that ignore CSP).

The same precedence applies to X-Content-Type-Options, which OxPHP sets to nosniff on every response: an application-set value is kept verbatim. Since nosniff is the only value that does anything, an application overriding it with anything else silently disables MIME-sniffing protection.

TLS

Variable Default Description
TLS_CERT (unset) Path to PEM-encoded TLS certificate. Both TLS_CERT and TLS_KEY must be set to enable TLS
TLS_KEY (unset) Path to PEM-encoded TLS private key
TLS_MIN_VERSION 1.2 Minimum accepted TLS protocol version: 1.2 or 1.3. Validated at startup (and by oxphp config --check) even when TLS is not enabled. Any other value, including non-UTF-8 bytes, is a hard startup error. An empty value is treated as unset

HTTP/2

Variable Default Description
H2_MAX_CONCURRENT_STREAMS PHP_WORKERS_MAX × 4 (min 32) Maximum simultaneous open streams per HTTP/2 connection
H2_MAX_PENDING_RESET 20 Maximum RST_STREAM frames queued before a connection is closed (Rapid Reset protection)
H2_MAX_HEADER_LIST_BYTES 65536 Maximum total decoded header bytes per request
H2_KEEPALIVE_INTERVAL_SECS 20 Seconds between HTTP/2 PING frames; 0 disables
H2_KEEPALIVE_TIMEOUT_SECS 10 Seconds to wait for a PING reply before closing the connection

Static files

Variable Default Description
STATIC_MAX_AGE 30d Cache-Control: max-age for static files. Accepts: 30s, 5m, 2h, 30d, 1w, 1y, bare seconds (3600), or off to disable the header. Replaces deprecated STATIC_CACHE_TTL.
STATIC_REVALIDATE off Boolean (see Boolean values). Set truthy to enable mtime revalidation on the in-memory content cache: the file's modification time is re-checked at most once every 3 seconds per file (not per request) and stale entries are evicted automatically, so changes become visible within 3 seconds. Replaces deprecated STATIC_CACHE (where off had the inverse meaning).

Compression

Variable Default Description
COMPRESSION_ENCODINGS br,zstd,gzip Content codings the server offers, comma-separated: br (or brotli), zstd, gzip. An unknown name is a startup error. Set the whole value to off (or none) to switch compression off entirely. It is not a list token, so br,off is an error rather than a switch. An empty assignment restores the default. The order written here is ignored: cached static files prefer Brotli, whose cost is paid once, and everything else prefers Zstandard. See Compression
COMPRESSION_BROTLI_LEVEL 5 Brotli quality (0 to 11). Replaces deprecated COMPRESSION_LEVEL
COMPRESSION_ZSTD_LEVEL 6 Zstandard compression level (0 to 19)
COMPRESSION_GZIP_LEVEL 6 Gzip compression level (0 to 9)
COMPRESSION_LEVEL (unset) Deprecated name for COMPRESSION_BROTLI_LEVEL; emits a startup WARN. COMPRESSION_LEVEL=0 keeps its older meaning and disables every coding, not just Brotli. An explicit COMPRESSION_BROTLI_LEVEL wins over a non-zero value but not over 0, which switches compression off whatever else is set

A coding is offered when COMPRESSION_ENCODINGS lists it and its level is not 0; either one alone withdraws it. A level outside its range, or one that is not a number, aborts startup with an error naming the variable. Values are not clamped.

Logging

Variable Default Description
LOG_LEVEL info Log verbosity: trace, debug, info, warn, error
ACCESS_LOG (unset) Per-request access log: all = every request, error = 4xx/5xx only, unset = off
Note

ACCESS_LOG accepts all or error. Leave it unset to disable access logging entirely.

A client that closes its connection before the server has answered is logged at debug, not error. It is the most common event a public listener sees (a cancelled fetch, a closed tab, a proxy whose read timeout fired) and is not a failure of the server, so at the default LOG_LEVEL it is invisible. Count them with oxphp_request_cancelled_total{reason="client_abort"}, or raise LOG_LEVEL=debug to see the individual connections. Timeouts the server itself enforces (HEADER_TIMEOUT_SECONDS) stay at warn, and every other connection failure stays at error.

LOG_LEVEL does not filter the access log

The two are independent. LOG_LEVEL sets the verbosity of the server's own diagnostics, so LOG_LEVEL=warn with ACCESS_LOG=error reports warnings and errors and logs every 4xx/5xx request. Silencing the access log on its own takes a RUST_LOG directive naming its target: RUST_LOG=warn,access_log=off, with warn standing in for whatever LOG_LEVEL you were running. RUST_LOG, when set and parsable, replaces LOG_LEVEL rather than layering on it. See Access logging.

Observability

Variable Default Description
INTERNAL_ADDR (unset) Address for the internal server (/health, /metrics, /config). Internal server is not started when unset. A port-only value (:9090) binds 127.0.0.1; bind an explicit 0.0.0.0:9090 to expose it off-host
INTERNAL_ALLOW_IPS (unset) Comma-separated CIDR/IP allow-list for the internal server. A peer outside the list receives 403 on /metrics, /config, and plugin paths; health probes (/health, /healthz, /readyz, /startupz and their long forms) stay reachable. Unset/empty = allow all. Loopback is not implicit: list 127.0.0.1/32 to keep localhost access. A malformed list aborts startup
ERROR_PAGES_DIR (unset) Directory containing custom error pages named {status}.html (e.g., 404.html, 503.html)
MAX_QUERY_BODY 524288 Maximum request body size in bytes for internal query endpoints (512 KiB)
TRACE_CONTEXT false Boolean (see Boolean values). When truthy, enables W3C Trace Context propagation: reads traceparent/tracestate headers and forwards them to PHP via $_SERVER

OpenTelemetry

Variable Default Description
OTEL_ENABLED false Enable OpenTelemetry span export. Automatically sets TRACE_CONTEXT=true. Boolean (see Boolean values)
OTEL_EXPORTER_OTLP_PROTOCOL grpc Export protocol: grpc or http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT http://localhost:4317 (gRPC) or http://localhost:4318 (HTTP) OTLP collector endpoint. With http/protobuf, a base URL: spans are sent to /v1/traces under it
OTEL_EXPORTER_OTLP_TIMEOUT 10000 Export timeout in milliseconds. Over gRPC it also bounds connecting to the collector, TLS handshake included, so keep it above the time that takes; an attempt that has to connect first can take up to twice the timeout. Over gRPC on Linux it also sets how soon a connection that stopped taking data is closed; see Span export errors in the log
OTEL_EXPORTER_OTLP_HEADERS (unset) Authentication headers: key=value,key2=value2
OTEL_SERVICE_NAME oxphp Service name in exported spans
OTEL_SERVICE_VERSION (unset) Service version attribute
OTEL_RESOURCE_ATTRIBUTES (unset) Additional resource attributes: env=prod,region=us-east-1
OTEL_TRACES_SAMPLER parentbased_traceidratio Sampling strategy: always_on, always_off, traceidratio, parentbased_always_on, parentbased_always_off, parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG 1.0 Sampling ratio (0.0 to 1.0) for ratio-based samplers
Note

Invalid or out-of-range OTEL_TRACES_SAMPLER_ARG values are clamped to [0.0, 1.0] and logged at warn level. Unknown OTEL_TRACES_SAMPLER values fall back to parentbased_traceidratio and are logged.

APM

Variable Default Description
OTEL_APM_ENABLED false Enable APM: automatic instrumentation, error capture, and the PHP tracing SDK. Requires OTEL_ENABLED=true. Boolean (see Boolean values)
OTEL_APM_SLOW_QUERY_MS 100 Slow query threshold in milliseconds. Database queries exceeding this get an oxphp.db.slow=true span attribute
OTEL_APM_DB_CAPTURE_PARAMS_ENABLED false Record bind parameters in the db.params span attribute. Disable in production if parameters may contain sensitive data. Boolean (see Boolean values)
OTEL_APM_STACKTRACE_MAX_BYTES 8192 Maximum size in bytes of the exception.stacktrace attribute. Over the cap the stacktrace is truncated from the tail with a …(truncated) marker. 0 disables truncation
OTEL_APM_MESSAGE_MAX_BYTES 4096 Maximum size in bytes of the exception.message attribute (default matches New Relic's per-attribute value limit). Over the cap the message is truncated from the tail with a …(truncated) marker. 0 disables truncation

When APM is enabled, OxPHP automatically hooks 34 internal PHP functions (PDO, mysqli, cURL, Redis, Memcached, file I/O) to create child spans. The oxphp_apm_*() PHP functions are registered regardless of whether APM is enabled. When it is disabled, the span functions are no-ops returning sentinel values. oxphp_apm_trace() is the exception: it still runs its callback and returns the callback's value, so disabling APM never changes what your application computes.

Async workers

Variable Default Description
ASYNC_WORKERS 0 (disabled) Number of dedicated async worker threads. When 0, the async functions (oxphp_async, etc.) are registered but throw OxPHP\Async\AsyncException on call. Set to a positive value to enable background task execution
ASYNC_QUEUE_CAPACITY ASYNC_WORKERS × 64 Maximum pending tasks in the async queue. 0 = auto (workers × 64)
ASYNC_MAX_FIBERS 256 Per-worker cap on concurrent async task fibers. The process-global in-flight limit (queued + running) is ASYNC_MAX_FIBERS × ASYNC_WORKERS; a dispatch past it is rejected immediately with OxPHP\Async\AsyncException so fan-out composition cannot deadlock

The async worker pool handles fire-and-forget background tasks dispatched from PHP. It is separate from the PHP worker pool and is not required for standard request handling.

A malformed value in any of these three variables (e.g. ASYNC_WORKERS=8x) is a startup error, because falling back to a default would silently disable or misconfigure the pool. An exactly-empty value is treated as unset.

Runtime hooks

Variable Default Description
RUNTIME_HOOKS (off) Opt-in replacement of blocking PHP builtins with fiber-suspending implementations. 1/true/all enables every hook category; a comma-separated list enables specific categories (e.g. RUNTIME_HOOKS=sleep,streams)
Category What it hooks
sleep Native sleep() and usleep() suspend the current fiber exactly like oxphp_sleep()/oxphp_usleep(), including in the contexts where those two cannot suspend either and wait on the worker thread instead (see Where the sleep hooks do not suspend)
streams Two waits suspend the current fiber instead of pinning the worker thread: a blocking read on a tcp:// socket stream, and stream_select(). The read covers clients that block on one socket: fsockopen(), stream_socket_client(), HTTP stream wrappers, mysqlnd (PDO_MySQL, mysqli) and phpredis. stream_select() covers loops that wait on several at once. Neither needs a code change. Clients that wait some other way are unaffected (see below)
net The waits below PHP streams, where the streams read hook cannot see them, suspend the current fiber instead of pinning the worker thread: connecting through stream_socket_client(), fsockopen() and every client built on PHP streams (mysqlnd, so PDO_MySQL and mysqli, and phpredis among them); the TLS handshake; reads on ssl:///tls:// streams once crypto is active; writes that have to wait for room in the send buffer; and stream reads on unix://, udp:// and udg:// sockets, on stream_socket_pair() streams, and on the streams socket_export_stream() makes from sockets that are not TCP. Needs streams listed alongside it, and works on Linux only (see When net stays off). Not covered: waits inside libcurl, libpq (pgsql, pdo_pgsql) and libmemcached, which call poll() from their own libraries; DNS resolution; stream_socket_recvfrom(); writes on a plain (non-TLS) stream with an infinite timeout (default_socket_timeout=-1); and persistent connects (pfsockopen(), STREAM_CLIENT_PERSISTENT, and the persistent modes of the clients built on them)

Hooks take effect inside worker-mode request fibers and async task fibers. Outside a fiber (traditional/framework/SPA request context, CLI) the original native behavior is preserved, including argument validation errors. With the sleep hooks enabled, third-party code calling sleep() stops pinning the worker thread wherever the fiber can be suspended (which is not everywhere; see below), and that code needs no changes. Cancelling an async task during a hooked sleep unwinds it with OxPHP\Async\AsyncException, and a hooked sleep() that suspends returns 0; the signal-interruption return value of the native builtin arises only on the blocking path below.

Checking that the setting was picked up

The server reports the categories it installed, as a parsed set, in two places. On the internal server:

Installed hook categories
$ curl -s localhost:9090/config | jq .runtime_hooks [ "sleep", "streams", "net" ]

And from PHP, for a deployment whose internal server is not reachable or not enabled:

php
<?php echo json_encode(oxphp_server_info()['runtime_hooks']); // ["sleep","streams","net"]

Both read the same value and report the set rather than the string: RUNTIME_HOOKS=all, RUNTIME_HOOKS=1 and RUNTIME_HOOKS=sleep, streams, net all come back as ["sleep","streams","net"]. An empty list means no hook is installed. That is the answer for the variable unset, for RUNTIME_HOOKS=0, for a value naming only categories this build does not know, and for a misspelled variable name. A value mixing the two is different: RUNTIME_HOOKS=sleep,bogus reads as ["sleep"], so a non-empty list is not by itself evidence that every name was understood. Each unrecognised name gets its own line in the server log at startup, and that line is the only place they are named. Check after any change to the setting: an unrecognised environment variable is not an error anywhere, so "misspelled" otherwise shows up only as latency.

Installed is not the same as in effect

The list reports which builtins had their handlers replaced, which happens at startup regardless of routing mode. Where there are no fibers at all the replacements delegate to the handlers they replaced, so a traditional-mode server reports ["sleep","streams","net"] while behaving natively throughout. A php CLI process reports ["sleep","streams"]: the engine is part of that executable, so net cannot tell PHP's waits from the program's own and stays off. Read the list as "this process is set up for the hooks", and the routing mode as whether anything can use them.

Within streams it is narrower still: the socket-read part can fail to install on its own, on a platform where the stream ops table cannot be made writable, while stream_select() and the database entry points stay hooked. The category is reported either way; the only signal is a server-log line saying socket reads stay blocking. That line, like the unrecognised-category one and the lines net writes (below), follows PHP's error logging: with error_log unset (the default) it reaches the server's own log as a warn, and LOG_LEVEL=error hides it.

When net stays off

net is reported only when it is actually in place. Listed without streams, it stays off with a line in the server log saying so. On a platform that is not Linux, where the server's own code cannot be told apart from PHP's at startup, or where PHP's poll() is not bound to the one the server intercepts, it stays off with a line saying connects and TLS waits stay blocking. A net missing from the list after you asked for it is therefore the answer, not a gap in the report.

The one thing the list does not tell you is where OpenSSL lives. The waits OpenSSL makes (the TLS handshake, including the one at the end of an ssl:// connect, and reads and writes on a TLS stream once crypto is active) are parked only when OpenSSL is built into PHP, as it is in the official images. With OpenSSL loaded as a separate shared extension (extension=openssl), those waits are not PHP's own as far as the server can tell, so they keep the thread, while connects, unix:// and the other reads still suspend and net is still reported.

Where the sleep hooks do not suspend

Where a fiber switch is refused, the hooked sleep()/usleep() hand the call to the builtin they replaced, which waits on the worker thread and returns what it returns. A sleep() cut short by a signal there therefore gives back the seconds it did not sleep, as it does outside a fiber. (The argument check is the hook's own either way: it rejects anything above 4294967295, which the unhooked builtin accepts and truncates.) Switching is blocked wherever leaving the current frame would be unsafe, and the list below is not exhaustive:

  • inside a declare(ticks) handler and inside pcntl's signal dispatch (both upstream PHP's own doing);
  • while a request's input is being built;
  • inside a FILTER_CALLBACK reached from filter_input_array() given a per-field definitions array (see the note under $_GET in Superglobals);
  • inside the teardown walks that run destructors after a request has come apart (after a fatal, or after a write to a request that was already cancelled), so a __destruct() that sleeps to back off does it holding the thread.

The ordinary end of a request is not one of these: destructors and shutdown functions run there with switching allowed. oxphp_sleep() and oxphp_usleep() take their blocking path in every one of these places too, so reaching for them is not a way around it.

A fiber that a userland scheduler started (AMPHP, Revolt, or a bare new Fiber) is a different case: switching is not blocked there, and the scheduler suspends and resumes its own fibers normally, but OxPHP cannot resume a context it does not own, so the hook stands aside for the same reason the streams hook does. This is about a fiber started inside a request; a worker-mode request is itself a fiber OxPHP drives, so the hooks apply to it normally. Code inside a fiber the application started itself sleeps on the worker thread, and that share of the load has to be sized as if the hook were off.

What the streams hook covers

What the streams hook makes cooperative is a blocking read on a PHP socket stream and a wait inside stream_select(). Before counting on it, check that your client waits one of those two ways, or one of the ways the net category adds (below). Several common clients do not, and for them nothing changes:

  • ext/curl's curl_exec() and curl_multi_* talk to sockets themselves, below PHP streams, so the hook never sees them. This covers every HTTP client built on curl, Guzzle with its default handler among them. A curl client can be pointed at the stream wrapper handler instead, which does go through PHP streams.

  • socket_select() belongs to ext/sockets, a different API on raw descriptors, and it is not hooked. stream_select() is. The same applies to the wait inside stream_socket_accept(), which is not hooked either.

  • unix://, udp:// and udg:// streams, socket pairs, streams from socket_export_stream() on sockets that are not TCP, and ssl:///tls:// once crypto is active have ops tables private to PHP or to OpenSSL, so the streams hook does not reach them. The net category does: with it listed, a stream read (fread(), fgets() and the like) on any of them suspends the fiber like a tcp:// one. Without net they hold the thread as before (before stream_socket_enable_crypto() succeeds, an SSL stream's reads are delegated to the plain socket read and suspend either way). stream_socket_recvfrom() is not a stream read: it calls recvfrom() directly, with no wait of PHP's own to suspend, so on a udp:// or udg:// socket it holds the thread until a datagram arrives even with net. socket_export_stream() makes a stream of the transport the socket uses: a UDP, unix or datagram unix socket becomes a udp://, unix:// or udg:// stream, a TCP socket an ordinary tcp:// stream (which streams alone already covers), and a socket of any other kind the generic stream that a socket pair has.

  • A connect made through PHP's stream transports suspends the fiber when net is listed: stream_socket_client(), fsockopen() and the clients built on them. ftp_connect() and socket_connect() connect on their own, outside the stream layer, and are not covered. DNS resolution does not suspend either: a host name is resolved on the worker thread before the connect starts, so a slow resolver holds it. A client with a network library of its own (pdo_pgsql and pgsql through libpq, the memcached extension through libmemcached) connects and waits inside that library, which neither category sees. A client that holds its connection open benefits on every query regardless.

    A persistent connect is the exception, and it covers more than pfsockopen() and STREAM_CLIENT_PERSISTENT: PDO_MySQL with PDO::ATTR_PERSISTENT, mysqli with the p: host prefix and phpredis with pconnect() open their streams as persistent too, and their connects keep the thread for the wait, as they did before net existed. The reason is the persistent list. A persistent stream is entered in it under its key before it is connected, so a second fiber asking for the same key while the first waits would find a stream with no descriptor yet, take it for a dead connection and close it under the fiber still connecting. Some clients give every connection a key of its own (mysqlnd does), and nothing can find those streams that way, but the server decides by whether the stream is persistent, not by who could find it, so they hold the thread as well. The cost is the connect, which a persistent connection pays once when it is first opened, not the reads and writes on it.

  • For MySQL reached through localhost, the client reads localhost as "use the unix socket", which is not a tcp:// stream, so the streams hook does not see it. Write 127.0.0.1 in the DSN instead, or list net, which covers unix:// streams. This applies to PDO_MySQL and mysqli alike, and is easy to miss because everything still works, just without the benefit.

  • Writes do not wait under streams alone; only reads do. Read readiness holds still as long as one fiber owns the descriptor and nothing else drains it, whereas room in the send buffer is granted and withdrawn by the peer, so a fiber woken on writability can find the window closed again by the time the write runs, after which PHP blocks the thread for its full timeout regardless. A write that fills the socket buffer therefore behaves exactly as it does without the hook. In practice this costs little: waiting on a reply is what takes time, not handing a query to the kernel. (Writes are still looked at for one thing only: which fiber's exchange the connection is currently carrying, as the note on shared connections below explains. On a connection no other fiber is using, which is every connection in the ordinary case, the write runs the native path untouched.)

    With net listed, a write that has to wait for room suspends the fiber as well, and the descriptor is checked again after every wake, so a window that closed in the meantime costs another suspension rather than the thread. A plain stream whose timeout is infinite (default_socket_timeout=-1) is the exception: PHP sends on it with a blocking send() and never waits on its own, so there is nothing to suspend and the write holds the thread as it does without the hook. A TLS stream with an infinite timeout is different: OpenSSL's own loop waits in poll() with no limit, and that wait is suspended.

Within that scope the hook keeps the native contract: socket timeouts (stream_set_timeout(), default_socket_timeout) apply unchanged, a timed-out read still reports timed_out through stream_get_meta_data(), and stream identity is untouched, so socket_import_stream() and similar keep working. One limit on the timeout: the deadline is only examined once per scheduler tick, so it fires no sooner than the next tick: at best 100 µs in worker mode and 50 µs in the async pool, and longer under load, since a tick lasts as long as whatever the worker is running. The async pool pauses for longer than that when nothing is happening, but not past a deadline it is holding: a read or write deadline shortens the pause to land on itself, so waiting longer does not make the timeout later. With default_socket_timeout at 60 seconds this is not something most deployments can observe.

stream_select() is hooked by waiting on the descriptors its three arrays name and then handing the call to PHP with the timeout set to zero, so PHP still decides everything you can observe: the return count, the rewriting of the arrays down to the ready streams, the warnings, and the argument errors. The wait is skipped, and the call runs exactly as it would without the hook, whenever the arrays hold something the hook will not stand in for: a read stream with data already buffered (which stream_select() answers from the buffer, without looking at a descriptor), a stream that has no descriptor at all, an element that is not a live stream (a closed one left in the array, say, which PHP answers with an error, not a wait), a descriptor the kernel will not watch for readiness at all (a regular file is the usual case, and one counts as ready the moment you ask), or a descriptor at or past FD_SETSIZE, which PHP's own select() refuses outright. That last one is a real ceiling rather than a formality: a busy worker can hold more than 1024 open descriptors, and a stream_select() naming one of them fails the same way with or without the hook.

Shared connections between concurrent fibers

One connection shared between concurrent fibers is safe for the clients OxPHP guards, and gains nothing. This is the normal shape of a worker-mode application rather than an edge case: WordPress, Laravel and Symfony open their database and cache clients once when the worker boots and hand the same ones to every request, and short of rewriting their data-access layer they cannot be asked for one connection per fiber.

A client protocol is a sequence of exchanges (write a command, read the answer) with nothing on the connection marking where one ends. A fiber parked on a read is parked in the middle of an exchange, and a second fiber's command landing there breaks the protocol. The two clients fail at that differently, and neither well: mysqlnd tracks its connection state and refuses the command before sending anything, while phpredis has no such check and the two fibers read each other's replies, so one request's data is returned to another with no error raised.

A fiber therefore claims a connection before using it, at both of the levels this has to happen at: the socket ops, which keep the bytes in order, and the client entry points of PDO and mysqli, since mysqlnd's refusal comes before any I/O and nothing at the socket can be reached in time to prevent it. Another fiber reaching the same connection waits for it to be given up at the client level, where nothing but the connection's identity is held; at the socket level it does not wait but is refused, the way a socket timeout is refused, because a fiber suspended inside an operation on someone else's stream would be holding a pointer its owner may free. phpredis is guarded at the client level too, method by method, for exactly that reason. What the client level claims is the connection itself and not the PHP object holding it, so a persistent connection reached through several PDO objects counts as one, and a connection opened with PDO::connect(), which returns the driver's own subclass rather than a PDO, is covered like any other.

Read the hook's gain, then, as belonging to connections a fiber opens for itself: an async task making its own HTTP or database calls, a request opening its own client. What a shared connection gets is the worker thread back while it waits, so other requests can run the work that is not on that connection; its own exchanges run one after another, exactly as they do with the hook off. Four boundaries are worth knowing:

  1. A fiber that queried once keeps the connection to the end of its request, because the end of the request is the first moment certainly past the end of an exchange. It keeps it while parked on anything else as well, so a request that has queried and then waits on work of its own that needs the same connection is waiting for itself; the two come apart on the bound below instead of proceeding.

  2. Waiting is always bounded: by the smaller of max_execution_time and default_socket_timeout. Set neither and that bound is 30 seconds, since a server SAPI takes the engine's defaults: 30 for the first and 60 for the second; it is default_socket_timeout's 60 only where max_execution_time is 0. max_execution_time is read as the request currently has it, since set_time_limit() is how a request states how long it may run; default_socket_timeout is read as the process started with, because it is the default deadline of a socket operation, and a request narrowing it for a call of its own (a common thing for a library to do, and to leave behind) must not shorten this bound for the requests that follow it on the same worker. Past the bound the call falls back on unguarded behaviour and says why in the server log: for PDO and mysqli it is handed to the client, whose own refusal of a command issued mid-exchange is the error the application already handles, while for phpredis, which has no such refusal and would instead read someone else's reply, it raises a RedisException and sends nothing.

    One PDO call is the exception, both here and for code running outside any fiber (below): PDO::rollBack() on a persistent connection another fiber holds, finding a transaction open on it, sends nothing and returns false, because that transaction is the holder's and a ROLLBACK sent between its statements would end it. The error it reports, the way the error mode says, is one the connection already records, which can be the holder's. PDO's own object teardown is held to the holder as well. While a request or task holds a persistent connection, dropping a handle on it rolls back the transaction open there and runs the driver's end-of-request cleanup only if the holder was the last to use that handle (built it, or has since called one of the PDO methods OxPHP guards through it), wherever the handle is destroyed, PHP's cycle collector running in another request included. Any other handle skips both. On a connection nobody holds, every handle takes both, as before.

    A socket-level conflict has no bound of its own because it never waits: the operation fails at once the way a timeout does, so stream_get_meta_data() reports timed_out. That can cost more than the call: a client is free to read a refused operation as something worse than a failed command, and mysqlnd does so on both sides of an exchange. A write that sends nothing marks the connection gone and closes it, a read that comes away empty marks it gone where it stands, and every later command on it is answered from that state without touching the wire. The loss is the whole connection rather than the single call, for as long as anything holds the handle. The client-level claim above is what keeps a guarded client's commands from reaching that refusal at all; a hand-written protocol on a raw socket should treat a refused operation of either kind the way it treats a closed connection. Two fibers each holding what the other waits for therefore come apart on that bound instead of waiting on each other for good.

    Code running outside any fiber cannot wait on a claim at all, and only the socket level applies to it. In worker mode with RUNTIME_HOOKS=streams that is chiefly a destructor the worker loop runs between requests when it collects cycles, while other requests are parked. A socket operation it makes that would wait, on a connection whose holder is parked on it waiting for its reply, fails at once the way a timeout does and sends nothing; at any other moment it goes ahead. The client level is not applied there, because its claim lasts to the end of the holder's request: it would refuse such code a connection the holder is not using at that moment, which is most of the time. What a refusal costs is the client's, as above. phpredis fails the call and the holder keeps its reply. mysqlnd either refuses the command from its own state or, when the holder's exchange left that state ready, has its write refused and closes the connection, and the holder's request ends as described in point 4. A destructor is therefore a poor place to use a shared client at all.

  3. Some cases are deliberately left uncovered. A statement or result object kept across requests, which no claimed call precedes: PDOStatement::execute() on a statement prepared in an earlier request behaves as it does with no claim at all, and that costs more than the call: reaching the socket while any fiber is parked on the connection, it is refused there, and for mysqlnd a refusal loses the connection, as above, on the read that fetches the next row of an unbuffered result as much as on the write that sends the command. A protocol written by hand on a raw socket (write a command, suspend on something else, read the reply later), since the socket level only refuses while the holder is parked on the reply itself, and between those two points another fiber takes the connection over; the three clients above are covered here by their client-level claim, which a hand-written protocol has no equivalent of.

    Persistent connections are covered; see Persistent PDO connections below.

  4. Closing a shared connection while another request is parked reading it ends that request, with a 500 and a log line naming what happened. Claiming keeps two exchanges apart; it cannot keep a connection alive, and the parked request's reply is on a connection that no longer exists, so the request is ended rather than handed whatever the freed memory now holds. Which calls can do this depends on the client, and only one of them waits: mysqli::close() and mysqli_close() are claimed calls, so they wait for the holder and close once that wait is spent, while Redis::close() is claimed too but raises a RedisException rather than closing. Everything else closes at once: fclose() on a raw stream, and PDO. PDO is the case to watch, because it has no close() method at all: a connection is released by dropping the last reference to the handle (unset($pdo), reassigning it, or letting it fall out of scope), and that path runs inside the engine's own object teardown, where no claim is consulted before the connection is closed. So the reconnect helper that reassigns a shared PDO handle ends the requests parked on the old connection, without the bounded wait its mysqli equivalent gets. The request is told as soon as the close happens rather than at its own read deadline, which for mysqlnd would be mysqlnd.net_read_timeout, a day out of the box. Applications that close a shared connection to force a reconnect (WordPress's $wpdb->check_connection() is the usual one) should expect the requests parked on it at that moment to fail rather than to return the wrong data.

One more boundary: running the streams hook underneath a userland fiber scheduler (AMPHP, Revolt) falls back to blocking I/O. A fiber started by such a scheduler runs on its own context, which OxPHP's scheduler cannot resume, so the hook detects this and takes the native path rather than corrupting either scheduler. This is about a fiber a userland scheduler starts inside a request. The request's own fiber is one OxPHP drives, and in worker mode it is a real Fiber (Fiber::getCurrent() inside a request returns it), so libraries that only need to tell concurrent requests apart work without any fallback. The same fact is why Revolt refuses to run its event loop from inside a worker-mode request.

Persistent PDO connections

PDO::ATTR_PERSISTENT keeps the driver's connection in a pool the whole worker shares, and PDO reaches that pool in two steps that do not survive being interleaved: it looks a pooled connection up before it connects, and registers what it built afterwards. Two constructors overlapping both miss the pool, and the second registration replaces the first entry, closing that connection under whichever request is reading its reply on it. Overlap is the ordinary case, since static $pdo ??= new PDO(...) is reached by every request that arrives before the first connect finishes.

So constructors asking for a persistent connection to one data source run one at a time per worker, and the second finds what the first registered. The data source is the one PDO resolves (an alias from pdo.dsn.* in php.ini counts as the DSN it names), and constructors to different ones do not, as a rule, wait for each other, so one stuck connecting to a server that accepts the connection and never answers does not hold up constructors to a healthy one. Two cases wait more rather than less:

  • A DSN is compared only up to the first colon after its driver prefix (an IPv6 host is one way to have one), because PDO joins DSN, user and password into its pool key with colons and escapes nothing, so a DSN extended by :… can name the same pool entry as the one without. Sources that read the same up to that colon share one wait, a stuck one included: two IPv6 hosts in the same leading group of digits wait for each other the way all constructors on a worker used to.
  • uri: DSNs and DSNs that are not strings cannot be resolved without repeating what PDO does with them, so such a constructor waits until no persistent constructor to any data source is running on its worker, and they all wait while it runs. It holds nothing while it waits, so it does not hold the others up, but while a constructor to a server that never answers is running it cannot find that moment, and when its wait is spent it goes ahead unserialised. So the stuck-server case above still reaches uri: and object DSNs, and one that names the stuck server itself holds every persistent constructor on its worker up for as long as its connect lasts, exactly as before.

That wait is bounded like the ones above, but what it costs when spent is not an error the client raises: a constructor whose budget runs out goes ahead unserialised and says so in the server log, and two constructors overlapping that way replace each other's pool entry as before. That ends the request reading its reply on the replaced connection and, because a pooled handle is freed regardless of how many PDO objects still point at it, can end the worker process when one of those objects is later destroyed. Inside a userland fiber scheduler's own fiber that budget is spent at once: such a context cannot be suspended, so a constructor that finds another one running gives up immediately.

The other step is the liveness check PDO makes before handing a pooled connection over: a ping on the connection itself. Sent while the holder is parked waiting for its reply, it would be refused, PDO would read the connection as dead and open a second one, and what the application meant to share would no longer be shared. So a connection another fiber holds is reported alive without the ping, but only to a constructor that would leave it exactly as it is (where a driver supplies no liveness check, as with pdo_sqlite, the same answer stands in for PDO's own). PDO writes the error mode, the autocommit flag and the rest of its options onto a pooled handle it hands over, and on a connection in use those writes would land on the holder. The connection is therefore shared only with a constructor that would write nothing new onto it:

  • every option it passes is one PDO stores on the handle itself (PDO::ATTR_ERRMODE, PDO::ATTR_CASE, PDO::ATTR_ORACLE_NULLS, PDO::ATTR_DEFAULT_FETCH_MODE; the PDO::ATTR_PERSISTENT entry naming the pool does not count), and
  • each carries the value the connection already has.

An option the driver has to be told about is refused whatever its value, PDO::ATTR_AUTOCOMMIT and PDO::ATTR_STRINGIFY_FETCHES included, on every driver, third-party ones too. A constructor failing either condition is told the connection is dead and gets one of its own, and the server log says so at most once a second. For a pool key whose options carry PDO::ATTR_EMULATE_PREPARES or PDO::ATTR_STRINGIFY_FETCHES, as Laravel's do, that means: shared between requests, and between fibers that construct before they query, but a constructor arriving while another request holds the connection opens its own. A claim runs from the holder's first query to the end of its request.

A connection nothing is using is checked as before, including one whose holder has dropped its last handle, so one that really has died is still replaced. That check is a command like any other and is claimed like one: a query arriving through a guarded client while it is in flight waits for it, within the usual bound. The uncovered cases above stay uncovered here too.

Enabling and cost

1, true and all enable every category, streams and net included. A deployment already setting RUNTIME_HOOKS=1 for the sleep hooks therefore starts hooking sockets on upgrade without any edit of its own; list the categories explicitly (RUNTIME_HOOKS=sleep, or RUNTIME_HOOKS=sleep,streams to keep what 0.12.0 enabled) if that is not what you want. Enabling streams also patches one entry of PHP's stream ops table in memory at startup; the page is put back the way it was found, but on a platform where its original protection cannot be determined it stays writable, a small loss of hardening that is reported in the server log. Enabling net patches the same kind of entries in the ops tables of the other socket transports and of OpenSSL, on the same terms, and makes the bridge library answer poll() for the calls PHP's own code makes; every other caller, the server's own included, is passed straight through to libc.

Cost, measured: about 3 to 5 µs per socket round trip on a worker with nothing else parked, about 5 to 6 µs with 64 fibers parked on descriptors, and about 7 to 11 µs with 200. Readiness is resolved through a set the kernel keeps between waits, so what the figure tracks is how many descriptors became ready, not how many are waiting. An idle worker waits on those descriptors rather than sleeping for a fixed interval and noticing readiness on its next tick, which matters a great deal: sleeping blind cost roughly 2 ms per round trip instead.

A wide stream_select() carries an overhead the narrow case does not, because every descriptor a call names is registered before the wait and removed after it. Measured with the waiting itself taken out (every descriptor already readable, so the call returns at once and only the overhead remains), it is about 6 µs against 5 µs unhooked at one descriptor, 56 µs against 9 µs at 64, and 130 to 150 µs against 16 to 21 µs at 200: roughly 0.65 µs per descriptor.

Read that as a fixed cost per call, not as a slowdown of the same work. A stream_select() that actually waits, which is what a select loop is written for, dwarfs it: a millisecond of waiting makes even the 200-descriptor figure about a tenth of the call, and what it buys is the worker thread, which the unhooked call holds for the whole wait. Two shapes are the exception, and for them the category is better left off: a call over many descriptors that almost never waits, because there is no thread time to recover; and a request that is the event loop, where the thread has nothing else to run anyway. Clients that wait on one connection (mysqlnd, phpredis, the HTTP stream wrappers) sit at the top of the table, where the overhead is around a microsecond.

RUNTIME_HOOKS vs. oxphp_sleep()

The hook does not replace the first-party primitive. The two cover different cases:

  • Reach for oxphp_sleep() / oxphp_usleep() in code you write. They suspend the fiber in worker mode with no environment flag to set, and oxphp_sleep() accepts fractional seconds (oxphp_sleep(0.25)), a precision that native sleep() (integer seconds) cannot express. They are not exempt from the places a fiber cannot suspend: there they hold the worker thread exactly as the hooked builtins do.
  • Enable RUNTIME_HOOKS=sleep for code you cannot edit, such as a framework or vendor library that calls native sleep()/usleep() directly. It is off by default, does nothing outside a fiber, and keeps the native return contract: a hooked sleep() still takes an int, returns 0 when it suspends and whatever the builtin returns when it falls back. The argument check is its own, as above.

Relying on RUNTIME_HOOKS for your own handlers ties their cooperativeness to a deployment setting rather than the code; prefer the explicit oxphp_sleep() there.

Shared state

In-process concurrency primitives (OxPHP\Shared\Counter, Map, Channel, Mutex, Once, Pool, Atomic, Flag, Registry). See Shared State for the API tour.

Variable Default Description
SHARED_ENABLED true Boolean (see Boolean values). Master switch for the entire OxPHP\Shared\* subsystem. Set to false and none of its classes are registered: new OxPHP\Shared\Counter() throws Error: Class "OxPHP\Shared\Counter" not found, and /__ox_shared/* and the oxphp_shared_* metrics are absent. Guard with class_exists('OxPHP\Shared\Counter') in code that must run either way. Do not guard with interface_exists('OxPHP\Shared\Shareable'): it stays true because the extension registers that interface regardless
SHARED_MAX_ENTRIES 100000 Global cap on the number of live Shared objects (each Counter, Map, Channel, … counts as one). Creating an object past it fails with CapacityException. Keys inside a Map do not count toward it; bound a Map with maxEntries
SHARED_MAX_BYTES 1073741824 (1 GiB) Global cap on estimated memory across all Shared objects, checked only when an object is created (CapacityException). Objects that already exist can keep growing past it, so the total can read above the cap
SHARED_SOFT_LIMIT_RATIO — Deprecated and ignored; setting it logs a startup WARN. It never had an effect: nothing is shed as usage approaches SHARED_MAX_BYTES / SHARED_MAX_ENTRIES, which only refuse the creation of a new Shared object once reached. For early warning, alert on oxphp_shared_capacity_saturation, published while SHARED_METRICS_ENABLED is on (see Shared State Observability)
SHARED_METRICS_ENABLED true Boolean. Toggles the oxphp_shared_* Prometheus exposition
SHARED_INTROSPECTION_ENABLED true Boolean. Toggles the /__ox_shared/* introspection API on the internal server
SHARED_INTROSPECTION_PREVIEW_ENABLED true Boolean. Toggles value previews in introspection responses (disable when previews could leak sensitive data)
SHARED_CYCLE_DETECT_DEPTH 16 BFS depth during cycle check. Raise for deep legitimate graphs
SHARED_CYCLE_DETECT_EDGES 10000 Edges walked during cycle check. Raise for dense legitimate graphs
SHARED_MAX_VALUE_SIZE 1048576 (1 MiB) Per-value size cap. Inserting a larger value fails fast
SHARED_MAX_CHANNEL_BYTES 67108864 (64 MiB) Per-channel total payload cap
SHARED_POISON_STRICT — Deprecated and ignored; setting it logs a startup WARN. It never had an effect, and there is no global poisoning policy for it to set: an exception thrown inside a Shared\Mutex closure never poisons the mutex, and whether a failed Shared\Once factory disables the cell for good is chosen per instance; pass Once\FailureMode::Poison to the constructor (see Shared\Once)
SHARED_LOCK_DIAGNOSTICS warn (strict in a debug build of the binary) Cross-thread deadlock detector for Shared\Mutex: off, warn (log and count cycles) or strict (also break them with DeadlockException). Lowercase only; any other value silently gives the default. See Deadlock detector
SHARED_LOCK_POLL_INTERVAL_MS 100 How often the deadlock detector scans, in milliseconds. Unused when SHARED_LOCK_DIAGNOSTICS=off
SHARED_PREVIEW_STRING_LIMIT 256 Per-string truncation in /__ox_shared/preview previews, in bytes (cut at a char boundary)
SHARED_PREVIEW_ARRAY_LIMIT 20 Entries sampled in /entry?id=… previews

Profiling

Sampling profiler that emits xhprof / speedscope traces. See Profiling for output formats and viewer integration.

Variable Default Description
PROFILER_ENABLED false Boolean (see Boolean values). Master switch. All other PROFILER_* vars are still parsed at startup so typos surface immediately
PROFILER_SAMPLE_RATE 0.0 Probability (0.0 to 1.0) that a request is sampled. Values outside the range are clamped
PROFILER_INTERNAL false Boolean. When truthy, requests to the internal server (/health, /metrics, plugin endpoints) are also eligible for sampling
PROFILER_AUTH_TOKEN (unset) Secret the explicit triggers must carry (the X-OxPHP-Profile header, the OXPROF cookie, the __oxprof query parameter), and the bearer token for the /__profiler/* routes on the internal server. Unset or empty = no token required: any non-empty trigger value passes, and the /__profiler/* routes answer without a bearer. PROFILER_SAMPLE_RATE sampling and the OxPHP\Profile\* PHP functions never consult it: under PROFILER_ENABLED=true a script that calls OxPHP\Profile\start() profiles its own request with no token involved
PROFILER_MAX_SPANS 50000 Per-request cap on profile spans. Profiles exceeding the cap are truncated
PROFILER_MAX_DEPTH 0 (no cap) Maximum nesting depth of recorded calls; a deeper call and, except under self-recursion past 32 frames, everything it calls are absent from the profile. Their time is not; it reads as exclusive time of the call at the limit. Dropped calls still count against PROFILER_MAX_SPANS. Clamped to 32, the depth the engine observer tracks; with PROFILER_ENABLED=true a larger value is reported as clamped at startup
PROFILER_OUTPUT_DIR /tmp/oxphp-profiles Directory for on-disk profile files
PROFILER_OUTPUT_FORMATS xhprof,speedscope Comma-separated list of output formats to write to disk
PROFILER_DISK_MAX_PER_SEC 10 Rate limit on profile files written to disk per second
PROFILER_RETENTION_COUNT 100 Maximum profile files kept in PROFILER_OUTPUT_DIR. Older files are pruned
PROFILER_EXPORT_URL (unset) Remote endpoint to POST profiles to. When set, disk writes still happen unless PROFILER_OUTPUT_FORMATS is empty
PROFILER_EXPORT_FORMAT xhprof Wire format for PROFILER_EXPORT_URL posts
PROFILER_EXPORT_AUTH_TOKEN (unset) Optional bearer token sent with each export request
PROFILER_EXPORT_XHGUI (auto-detect) Boolean. Forces XHGui-compatible wrapping of the export payload. Unset = auto-detect when the PROFILER_EXPORT_URL path ends with /run/import (host/query hints are not matched)
PROFILER_EXPORT_BUGGREGATOR (auto-detect) Boolean. Forces the Buggregator envelope. Unset = auto-detect when the PROFILER_EXPORT_URL path ends with /api/profiler/store. The envelope always emits xhprof, so PROFILER_EXPORT_FORMAT is ignored for it (a non-xhprof value warns, non-fatal). Mutually exclusive with PROFILER_EXPORT_XHGUI: enabling both is a startup error
PROFILER_EXPORT_APP_NAME (unset) Buggregator app_name for project grouping
PROFILER_EXPORT_TAGS (unset) Buggregator tags as key=value,key2=value2; a malformed token, empty key, or duplicate key is a startup error

Example configurations

Development

bash
LISTEN_ADDR=127.0.0.1:8080 DOCUMENT_ROOT=./public LOG_LEVEL=debug ACCESS_LOG=all PHP_WORKERS=1 INTERNAL_ADDR=127.0.0.1:9090

Production (Framework)

bash
LISTEN_ADDR=0.0.0.0:80 DOCUMENT_ROOT=/var/www/html/public ENTRY_FILE=index.php PHP_WORKERS=8 QUEUE_CAPACITY=1024 LOG_LEVEL=warn ACCESS_LOG=error MAX_CONNECTIONS=10000 INTERNAL_ADDR=127.0.0.1:9090 RATE_LIMIT=100 RATE_WINDOW_SECONDS=60 TRUSTED_PROXIES=private HEADER_TIMEOUT_SECONDS=5 DRAIN_TIMEOUT_SECONDS=25 COMPRESSION_BROTLI_LEVEL=5 COMPRESSION_ZSTD_LEVEL=6 COMPRESSION_GZIP_LEVEL=6 STATIC_MAX_AGE=30d

Production (Worker Mode)

bash
LISTEN_ADDR=0.0.0.0:80 DOCUMENT_ROOT=/var/www/html/public WORKER_MODE_ENABLED=true ENTRY_FILE=../worker.php PHP_WORKERS=8 WORKER_MAX_MEMORY_MIB=128 QUEUE_CAPACITY=1024 LOG_LEVEL=warn ACCESS_LOG=error INTERNAL_ADDR=127.0.0.1:9090

TLS

bash
LISTEN_ADDR=0.0.0.0:443 TLS_CERT=/etc/ssl/oxphp/cert.pem TLS_KEY=/etc/ssl/oxphp/key.pem DOCUMENT_ROOT=/var/www/html/public ENTRY_FILE=index.php

Inspecting active configuration

When the internal server is running, query the /config endpoint to see the resolved configuration:

bash
curl -s http://localhost:9090/config | jq .
json
{ "access_log": "all", "async_in_flight_cap": 0, "async_max_fibers": 256, "async_queue_capacity": 0, "async_workers": 0, "brotli_level": 5, "deny_file": {"loaded": false}, "document_root": "/var/www/html/public", "drain_timeout_seconds": 25, "entry_file": "/var/www/html/public/index.php", "executor_type": "sapi", "gzip_level": 6, "header_timeout_seconds": 5, "listen_addr": "0.0.0.0:443", "log_level": "warn", "max_connections": 10000, "max_query_body": 524288, "php_workers": "8", "plugins": { "apm": { "db_capture_params": false, "enabled": true, "hooks_registered": 34, "slow_query_ms": 100 }, "otel": { "enabled": true, "protocol": "grpc", "service_name": "oxphp" } }, "queue_capacity": 1024, "queue_max_waiting": 1024, "queue_max_waiting_bytes": 67108864, "queue_wait_timeout_ms": 1000, "rate_limit": 100, "rate_window_seconds": 60, "runtime_hooks": [], "static_max_age": 2592000, "static_revalidate": false, "superglobals_enabled": true, "tls_enabled": true, "tls_min_version": "1.2", "tokio_workers": 4, "trace_context": true, "trusted_proxies": false, "worker_max_memory_mib": 0, "worker_mode_enabled": false, "zstd_level": 6 }
Note

The served /config response scrubs a few keys that the internal Config representation carries: TLS certificate and key paths are never emitted (tls_enabled indicates whether TLS is active), and internal_addr and error_pages_dir are removed. Those are deployment topology and filesystem paths, which aid an attacker and are not needed by metrics scrapers.

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
Tune OxPHP for my project. Read the `operations/configuration` page through the OxPHP documentation MCP server. Look at what this repository runs — the framework, the worker count it can afford, the request shapes it serves — and give me a production environment block with a reason for every value you pick.
prompt
Review my OxPHP configuration. Read the `operations/configuration` page through the OxPHP documentation MCP server. Compare the environment variables this repository sets against the defaults, and tell me which of my overrides are doing nothing, which are risky, and which defaults I should have overridden and did not.

See also

Found a mistake? Report it →