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 in a single process:

  1. Async HTTP layer. An event-driven network layer 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. PHP worker pool. A pool of dedicated PHP workers executes 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.
  3. Async pool (optional). Separate OS threads for tasks submitted via oxphp_async(). Enabled by setting ASYNC_WORKERS > 0. Isolated from the worker pool so background tasks do not block HTTP request handling.

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.

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. Waiting requests are admitted in arrival order. The budget is a single deadline stamped on arrival, and it follows the request into the queue: a request that a worker reaches after the deadline has passed is refused at pickup instead of executed. So the budget bounds the whole wait, not just its admission half, and that 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. 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.

What the budget does not bound is execution. Response time under load is the wait (for admission, then in the queue) plus however long the handler runs, and the budget says nothing about the second part.

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, 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. Clearing the warning is necessary rather than sufficient: connections that never reach PHP hold permits too. 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. If TLS_CERT and TLS_KEY are configured, OxPHP handles TLS directly. No separate reverse proxy is needed.
  2. HTTP parsing and request ID. The request is parsed and a unique request ID is generated (or an incoming X-Request-ID header is preserved).
  3. Trusted proxy resolution. If TRUSTED_PROXIES is set and the connecting IP is trusted, OxPHP extracts the real client IP, protocol, and host from Forwarded (RFC 7239) or X-Forwarded-* headers. 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, the client's IP is checked against the per-IP request counter. Requests that exceed the limit receive a 429 Too Many Requests response immediately.
  5. Route resolution. The URL is matched against the configured routing mode (traditional, framework, or SPA). The result is either a static file, a PHP script, or a 404. Worker mode, if enabled, changes how PHP executes the resolved script but does not change route resolution itself.
  6. Static files. 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. PHP execution. The request is placed in the bounded queue and picked up by an available worker. If the queue is full, the client receives 529 immediately.
  8. Compression. Text-based responses are compressed with Brotli before being sent when the client sends Accept-Encoding: br (configurable via COMPRESSION_LEVEL).
  9. SSE streaming. If the script sets Content-Type: text/event-stream or calls oxphp_stream_flush(), OxPHP switches to streaming mode: 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. Calling oxphp_finish_request() sends 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. The completed response is sent back over the connection, and if access logging is enabled, a log entry is written.

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 processes 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.

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. This makes /health suitable for Kubernetes liveness/readiness probes.

For details, see Internal Server.

Safety

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

  • Request isolation. If a PHP script crashes or triggers a fatal error, only that single request is affected. The server continues handling all other requests normally. The crashed worker is automatically replaced with a fresh one.
  • Automatic worker respawn. OxPHP monitors the health of all PHP workers. If a worker dies unexpectedly, a new worker is started in its place without manual intervention.
  • Backpressure protection. The bounded request queue prevents overload. When the server is at capacity, new requests receive a 529 response with a Retry-After header rather than queueing indefinitely and causing cascading timeouts.
  • Path traversal protection. All URL paths are sanitized before filesystem access. Percent-encoded traversal attempts, .. segments, and paths that escape the document root are blocked.
  • Graceful shutdown. On SIGTERM or SIGINT (Ctrl+C), OxPHP stops accepting new connections and waits for in-flight requests to complete (up to a configurable drain timeout) before exiting.

See also

Found a mistake? Report it →