Architecture Overview

OxPHP is a single-binary HTTP server that replaces the traditional nginx + PHP-FPM stack. It handles HTTP parsing, TLS termination, routing, PHP execution, compression, and observability in one process with no external dependencies at runtime.

How OxPHP works

OxPHP combines two runtime layers, and an optional async pool, in a single process:

  1. The async HTTP layer is an event-driven network layer. It accepts TCP connections, performs TLS handshakes, parses HTTP requests, and sends responses. It handles thousands of concurrent connections using non-blocking I/O, so one slow client never blocks another.
  2. The PHP worker pool is a set of dedicated PHP workers that execute your PHP scripts. In standard mode, each worker handles one request at a time. In Worker mode with fiber multiplexing enabled, a single worker can serve multiple concurrent requests: when a script calls oxphp_sleep() or oxphp_async_await(), the fiber yields the thread and the worker switches to the next request.

The two layers communicate through a bounded queue. When an HTTP request arrives that needs PHP execution, the async layer places it in the queue. An available PHP worker picks it up, executes the script, and returns the response to the async layer for delivery to the client.

The async pool is optional. It runs tasks submitted via oxphp_async() on separate OS threads, with a task queue of its own, and is enabled by setting ASYNC_WORKERS > 0. It is isolated from the worker pool, so background tasks do not block HTTP request handling.

This separation means that network I/O (accepting connections, reading headers, compressing responses, serving static files) never competes with PHP execution for resources. Each layer scales independently.

Worker pool

The PHP worker pool determines how many PHP scripts can execute concurrently. OxPHP supports two pool modes.

Static pool

A fixed number of workers start at boot and remain running for the lifetime of the server. This is the default mode.

bash
PHP_WORKERS=8 # exactly 8 workers PHP_WORKERS=0 # auto-detect (default): half of available CPU cores, minimum 1

Dynamic pool

Workers scale up and down based on demand. Specify a minimum and maximum count separated by a colon:

bash
PHP_WORKERS=2:16 # start with 2, scale up to 16 under load

When all current workers are busy, OxPHP spawns new workers up to the maximum. When a worker has been idle for longer than PHP_WORKERS_IDLE_SECONDS (default: 30 seconds), it is retired back down toward the minimum.

Retirement happens at a moment when the worker has nothing in flight, so a request it is still serving always runs to its own response. The same rule sets the boundary: a worker holding a request that never ends (an open stream, a read from a peer that stops answering) never reaches such a moment, and is not retired while it holds it.

Queue and backpressure

Between the async HTTP layer and the worker pool sits a bounded queue. Its capacity defaults to the initial worker count multiplied by 128 and can be overridden with QUEUE_CAPACITY. For a static pool, the initial count is the configured worker count. For a dynamic pool (MIN:MAX), the initial count is the minimum.

A request that arrives at a full queue is not rejected outright. It waits for a slot, up to QUEUE_WAIT_TIMEOUT_MS (default: 1000 ms), and is admitted as soon as a worker picks up the request ahead of it. That setting is the maximum wait, not a fixed length. The server shortens the budget while work admitted from the wait is completing for clients that have already disconnected, and restores it once the queue drains, so under overload the deadline stamped on an arriving request is the shortened one. Waiting requests are admitted in arrival order. The budget is a single deadline stamped on arrival, and it follows the request into the queue: when the deadline passes, the request is answered with a 529, and a worker that picks up a request past its deadline refuses it instead of executing it. If the client has closed the connection by then, the answer is a 499 instead, because no client is left to receive the refusal, and counting it as a 529 would make the overload series measure how long clients were prepared to wait. The budget therefore bounds the whole wait: admission and the time spent in the queue. This matters because the queue is deep enough that reaching its tail on a slow pool takes far longer than any budget an operator would set, and because a queue that no worker is taking requests from has no pickup at which to enforce a deadline. Load is shed on elapsed wait time rather than on the queue depth at the instant the request arrived: a burst that drains in microseconds is served, while a pool that genuinely cannot keep up still sheds.

The budget bounds waiting only; it does not bound execution. Once a worker picks up a request before its deadline, the budget does not interrupt it however long the handler then runs, because the check applies to waiting rather than to total elapsed time. Response time under load is the wait plus execution time, and the budget caps only the wait. It caps all of the wait: a request still waiting when its deadline passes is answered at that point, whether it is waiting for a queue slot or sitting in the queue for a worker.

QUEUE_MAX_WAITING caps how many requests may be waiting at once (default: initial workers × 128, capped at half of MAX_CONNECTIONS); past it, admission goes back to rejecting immediately. Waiting is not free. A waiting request holds its connection, and its already-buffered body, until it is admitted or the budget runs out, so an uncapped waiting set would consume every connection permit under sustained overload, block the accept loop, and leave the server dropping new connections instead of answering them. The default approximates how many requests the pool can actually admit inside the budget: waiting past that only defers a rejection while holding those resources. The MAX_CONNECTIONS / 2 ceiling bounds the waiting set alone, which is not by itself headroom for the accept loop: a queued request holds its connection until a worker reaches it or its budget runs out, a running one holds it too (its queue slot was released at pickup), and QUEUE_CAPACITY has no connection-derived ceiling at all. So PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING has to stay below MAX_CONNECTIONS. Once the sum reaches the budget, the accept loop parks with every permit taken, and a client arriving then gets no response rather than a 529. The server warns about that at startup, and reports the park itself while it is happening: a WARN when the loop parks and an INFO with the duration when it resumes (rate-limited, so that a server repeatedly hitting the ceiling does not flood its own log), plus the oxphp_accept_stalled gauge and the oxphp_accept_stalls_total counter. Both are updated on every park, including parks the log rate limit suppresses, and are served by the internal listener, outside this budget; see the metrics reference. Clearing the startup warning is necessary rather than sufficient: connections that never reach PHP hold permits too, and the startup check cannot predict a park they cause. Note that the terms are sized in connections while the budget is spent by requests: over HTTP/2 one connection carries many requests, so an h2-heavy deployment reaches the cap from a fraction of its connection budget, should set QUEUE_MAX_WAITING explicitly, and can legitimately sit above the sum.

The waiting set is bounded twice, because a cap counted in requests says nothing about the memory those requests hold: the same number of waiters costs nothing on bodyless GETs and gigabytes on uploads. QUEUE_MAX_WAITING_BYTES (default 64 MiB) bounds the request-body bytes parked at once. Past it, a request carrying a body is refused immediately rather than parked, while bodyless ones keep waiting normally. Two things it does not cover, both of which predate the wait budget: bodies already handed to the queue, which QUEUE_CAPACITY bounds in requests and nothing bounds in bytes, and the memory a body occupies while it is still being read off the connection, which no aggregate limit bounds at all.

OxPHP returns a 529 Site is Overloaded response with a Retry-After header when a request cannot be served rather than merely delayed: the wait budget ran out, or one of the caps above refused it a place to wait in. Status code 529 (non-standard, used by Cloudflare and others) clearly distinguishes overload from application errors (500) and maintenance (503), so alerts and load balancers are easier to configure. Setting QUEUE_WAIT_TIMEOUT_MS=0 disables the wait and rejects the moment the queue is full.

Because waiting requests occupy a connection rather than a queue slot, the concurrency ceiling under overload is set by MAX_CONNECTIONS rather than QUEUE_CAPACITY. Over HTTP/2 it is MAX_CONNECTIONS multiplied by H2_MAX_CONCURRENT_STREAMS, since each stream carries a request of its own. Size the wait budget with that in mind: the request body is already buffered by the time a request reaches the queue, so a longer budget means a genuinely overloaded server holds proportionally more requests, and their bodies, in memory before answering.

Request flow

Every request passes through the same pipeline, regardless of whether it serves a static file or executes PHP:

graph TD
  Client(["Client"]) --> TLS["TLS termination<br/>(if configured)"]
  TLS --> Parse["HTTP parsing + Request ID"]
  Parse --> Proxy["Trusted proxy resolution<br/>(if TRUSTED_PROXIES set)"]
  Proxy --> Rate["Rate limiting check"]
  Rate --> Route{"Route resolution"}
  Route -->|Static file| Cache["File cache / disk read"]
  Cache --> Compress["Compression + Response headers"]
  Route -->|PHP request| Queue["Bounded queue<br/>(529 past the wait budget)"]
  Queue --> Worker["PHP worker executes script"]
  Worker --> Normal["Normal response"]
  Worker --> SSE["SSE streaming (chunked)"]
  Worker --> Early["Early response (finish_request)<br/>+ background work"]
  Compress --> Deliver(["Response to client"])
  Normal --> Deliver
  SSE --> Deliver
  Early --> Deliver
  1. TLS termination happens in OxPHP itself when TLS_CERT and TLS_KEY are configured, so you do not need a separate reverse proxy.
  2. HTTP parsing reads the request and generates a unique request ID, or preserves an incoming X-Request-ID header.
  3. Trusted proxy resolution applies when TRUSTED_PROXIES is set and the connecting IP is trusted. OxPHP then extracts the real client IP, protocol, and host from Forwarded (RFC 7239) or X-Forwarded-* headers. With the cloudflare keyword, a request whose forwarding chain shows it arrived from a Cloudflare edge takes its client IP from CF-Connecting-IP instead. The resolved IP is used for all subsequent steps including rate limiting and access logging. See Trusted Proxies.
  4. Rate limiting, if RATE_LIMIT is set, checks the client's IP against the per-IP request counter. Requests that exceed the limit receive a 429 Too Many Requests response immediately.
  5. Route resolution matches the URL against the configured routing mode (traditional, framework, SPA, or worker). The result is a static file, a PHP script, or a 404. Worker mode is a routing mode of its own that takes the place of the other three: it needs a .php ENTRY_FILE and sends every request not served as a static file to that script (see Routing).
  6. Static files are served directly from an in-memory cache (for frequently accessed files) or streamed from disk. OxPHP adds ETag, Last-Modified, and Cache-Control headers automatically.
  7. For PHP execution, the request is placed in the bounded queue and picked up by an available worker. If the queue is full, the request waits for a slot for up to QUEUE_WAIT_TIMEOUT_MS (default: 1000 ms). The client receives 529 when that budget runs out, or at once when the waiting set is already at one of its caps (see Queue and backpressure).
  8. Compression is applied to text-based responses before they are sent, using whichever of Brotli, Zstandard, and gzip the client's Accept-Encoding ranks highest. On a tie, cached static files get Brotli and everything else gets Zstandard (configurable via COMPRESSION_ENCODINGS and the per-coding level variables).
  9. SSE streaming starts if the script sets Content-Type: text/event-stream or calls oxphp_stream_flush(). OxPHP then switches to streaming mode, where each flush() call sends a chunk to the client immediately without buffering the entire response. In Worker mode, SSE works cooperatively with fiber multiplexing.
  10. Early response lets the script call oxphp_finish_request() to send the HTTP response to the client immediately. The script continues executing in the background (writing logs, updating caches, sending notifications) without holding the connection open.
  11. Response delivery sends the completed response back over the connection and, if access logging is enabled, writes a log entry.

Worker Mode vs Standard Mode

OxPHP supports two PHP execution models:

Standard mode (default)

Creates a fresh PHP environment for every request. Autoloaders, configuration, and database connections are initialized on each request and torn down afterward. This model is compatible with all PHP applications out of the box.

Worker mode

Keeps PHP workers alive across requests. Your application bootstraps once (loading the autoloader, configuration, and establishing database connections) and then enters a request loop. Between requests, OxPHP automatically resets superglobals, output buffers, and response headers while preserving the bootstrapped state. $_ENV is the deliberate exception: with PHP's default auto_globals_jit=1 it is not reset, so it is worker state rather than request state, and a value written there during one request is read by every later request that worker serves. See Superglobals.

Worker mode eliminates per-request startup overhead, which can reduce response times significantly for framework-based applications (Laravel, Symfony, etc.) where bootstrapping is expensive.

To enable worker mode, set WORKER_MODE_ENABLED=true and point ENTRY_FILE at a PHP script that calls oxphp_worker():

php
<?php require __DIR__ . '/../vendor/autoload.php'; $app = new MyApp\Application(); oxphp_worker(function () use ($app) { $app->handle(); });

For a detailed guide, see Worker Mode.

Internal server

If the INTERNAL_ADDR variable is set, OxPHP starts a separate HTTP server on the specified port. It serves three endpoints:

Endpoint Description
GET /health Health status in JSON (uptime, request counters, connections, worker state). Returns 200 during normal operation, 503 during degradation.
GET /metrics Prometheus-format metrics (request counters, response time, queue wait time, worker statistics, compression savings).
GET /config Snapshot of the active configuration in JSON. TLS file paths are redacted.

The internal server does not go through the PHP worker pool or the bounded queue. It responds directly from the async HTTP layer, so it remains accessible even when the PHP pool is fully loaded. That makes it suitable for Kubernetes probes, pointed at the dedicated endpoints (/health/liveness, /health/readiness and /health/startup) rather than at the aggregate /health, which answers 503 on states a restart does not fix and does not track the shutdown flag a draining server sets. See Health Checks.

For details, see Internal Server.

Safety

OxPHP provides several guarantees to keep your application running reliably in production:

  • Requests are isolated. If a PHP script crashes or triggers a fatal error, only that single request is affected. The server continues handling all other requests normally, and the crashed worker is brought back as described below.
  • Workers respawn automatically. OxPHP monitors the health of all PHP workers and replaces one that dies unexpectedly, without manual intervention. A static pool refills to its configured count, so the replacement is one-for-one; a dynamic pool refills only to its minimum, and a worker lost above that is regained by ordinary scale-up.
  • Backpressure protection comes from the bounded request queue, which prevents overload. When the server is at capacity, a new request waits for a slot only as long as the wait budget allows, then receives a 529 response with a Retry-After header rather than queueing indefinitely and causing cascading timeouts.
  • Path traversal protection sanitizes all URL paths before filesystem access. Percent-encoded traversal attempts, .. segments, and paths that escape the document root are blocked.
  • Graceful shutdown starts on SIGTERM or SIGINT (Ctrl+C): OxPHP stops taking new connections (the listening socket stays open until the process exits; see Graceful Shutdown) and waits for in-flight requests to complete (up to a configurable drain timeout) before exiting.

See also

Found a mistake? Report it →