Releases: oxphp/oxphp
Release list
0.9.0
TL;DR
An async-composition and internal-hardening release. The headline is that an async task can now fan out its own child tasks, plus a security pass over the internal /metrics · /config server.
- Nested
oxphp_async— an async task may now calloxphp_async()itself and suspend onawait_all/await_any/await_race, so a task can fan out children and await them without blocking its worker. The "no nested async" restriction is gone. Tasks run on cooperative fibers, with JIT trace state preserved across switches. → Highlights ASYNC_MAX_FIBERS(default256) bounds concurrent async tasks. A dispatch past the cap (ASYNC_MAX_FIBERS × ASYNC_WORKERS) is rejected immediately withAsyncExceptionrather than blocking, so fan-out can never deadlock waiting on a slot it won't get. Plus three new async metrics.INTERNAL_ALLOW_IPS— a CIDR allow-list for the internal server. Peers outside the list get403before any handler runs, so they can't even probe which paths exist; health endpoints stay reachable for orchestrators. → HighlightsPROFILER_EXCLUDE_PATHSkeeps framework self-traffic (Symfony's/_profiler,/_wdt) out of sampled profiles, while explicit profile triggers still apply on excluded paths./configscrubbed and internal listener hardened —internal_addranderror_pages_dirremoved from/config, a port-onlyINTERNAL_ADDRnow binds loopback, and the server warns at startup when the internal port is reachable off-host without an allow-list.- Breaking (behaviour): a timed-out
awaitnow cancels the abandoned task instead of letting it run to request end, and/configno longer reports two keys. → Upgrade from 0.8.0
Upgrading from v0.8.0? See the Upgrade checklist — the one item that can change behaviour is async timeout cancellation.
Highlights
Async task composition (nested oxphp_async)
An async task can now itself dispatch child tasks and await them:
$report = oxphp_async(function () {
// fan out — each child is its own async task
$parts = [
oxphp_async(fn () => fetch_sales()),
oxphp_async(fn () => fetch_traffic()),
oxphp_async(fn () => fetch_signups()),
];
// suspend the parent fiber until the children resolve — no worker is blocked
return array_map(fn ($r) => $r->value, await_all($parts));
});- The async executor runs each task on a cooperative fiber (a C scheduler driven from Rust) instead of blocking a worker for the task's whole duration.
- JIT trace state (
jit_trace_num,vm_stack_page_size) is saved and restored across fiber switches, so JIT-compiled tasks resume correctly. - The previous "no nested async" restriction is removed.
Bounded concurrency: ASYNC_MAX_FIBERS
ASYNC_MAX_FIBERS (default 256) bounds concurrent async tasks. The process-global in-flight cap is ASYNC_MAX_FIBERS × ASYNC_WORKERS and limits queued + running tasks via a CAS-bounded counter. A dispatch past the cap is rejected immediately with AsyncException rather than blocking — so fan-out composition cannot deadlock waiting on a slot it will never get. Both values are exposed in /config as async_max_fibers and async_in_flight_cap.
New observability:
| Metric | Type | Meaning |
|---|---|---|
oxphp_async_tasks_in_flight |
gauge | queued + running async tasks |
oxphp_async_tasks_in_flight_limit |
gauge | the in-flight cap |
oxphp_async_output_discarded_bytes_total |
counter | output written by an async task (which has no client) — discarded at worker idle |
The gauges render only when the async pool has wired its in-flight counter.
Internal-server access control: INTERNAL_ALLOW_IPS
INTERNAL_ALLOW_IPS is a CIDR allow-list for the internal server. A peer outside the list receives 403 on /metrics, /config, and other internal paths — before any handler runs, so it cannot probe which paths exist.
- Health endpoints (
/health,/healthz,/readyz,/startupzand their long forms) are always reachable, so orchestrator and load-balancer probes never break. - Loopback is not implicit — list
127.0.0.1/32to keep localhost access. - A malformed list aborts startup. There is deliberately no bearer-token option: a token invites exposing the port "because it's protected"; the controls are network isolation plus this allow-list.
- Unset/empty allows all peers (the prior behaviour), but the server now warns at startup when the internal listener is reachable off-host (
0.0.0.0,::, or a public address) and no allow-list is set.
Related hardening: a port-only INTERNAL_ADDR (e.g. :9090) now binds 127.0.0.1 instead of failing to resolve, and /config no longer reports internal_addr or error_pages_dir (deployment topology and a filesystem path that aided an attacker and weren't needed by metrics scrapers).
Keep framework noise out of profiles: PROFILER_EXCLUDE_PATHS
PROFILER_EXCLUDE_PATHS takes comma-separated glob patterns (same syntax as PHP_DENY_PATHS) whose matching request paths are kept out of PROFILER_SAMPLE_RATE sampling — so framework self-traffic such as Symfony's /_profiler and /_wdt toolbar requests no longer pollute the captured profiles. Exclusion applies to automatic sampling only: a request carrying an explicit trigger (x-oxphp-profile header, OXPROF cookie, or __oxprof query parameter) is still profiled, even on an excluded path.
Fixes
- A fiber send-waiter parked on a full
Shared\Channelis now woken whenrecv_blockingfrees a buffer slot — previously a blocked sender could strand under saturation, makingsendTimeouttrip spuriously. - A graceful-shutdown crash is fixed by tearing down PHP on the main thread.
- A fiber
awaiton a closed async promise now rejects instead of stalling. await_allcancels and strands the remaining promises when it bails out early, instead of leaving them running.
Upgrade from 0.8.0
One behaviour change can affect running code; the rest are opt-in or telemetry-only.
-
A timed-out
awaitnow cancels the abandoned task. Previously a task whoseawait/await_all/await_any/await_racetimed out kept running to the end of the request, so its side effects (DB writes, cache fills, outbound calls) still completed in the background. The task is now force-cancelled at the timeout — parked tasks are resumed into cancellation, CPU-bound tasks are interrupted at the next opcode boundary. Action: treat a timeout as a hard abort. Move work that must always finish out of the awaited task, or give it a budget under theawaittimeout. -
/configno longer reportsinternal_addrorerror_pages_dir. Action: tooling that read these from/configmust source them another way (process environment / deployment manifest). The remaining keys are unchanged. -
A port-only
INTERNAL_ADDRnow binds loopback.INTERNAL_ADDR=:9090previously failed to resolve; it now binds127.0.0.1:9090. Action: to expose the internal server off-host, set an explicitINTERNAL_ADDR=0.0.0.0:9090and pair it withINTERNAL_ALLOW_IPS.
Full changelog: CHANGELOG.md · v0.8.0...v0.9.0
0.8.0
TL;DR
A static-files and HTTP/2 hardening release, with two routing breaking changes. The headlines are byte-range support for static files and configurable, DoS-aware HTTP/2 connection limits.
- HTTP Range requests for static files (RFC 9110) —
206 Partial Content,416,If-Range, and strong ETags.<video>/<audio>seeking and resumable downloads (curl -C -,wget -c) now work. → Highlights - Configurable HTTP/2 connection limits with built-in DoS hardening — Rapid Reset (CVE-2023-44487), HPACK-bomb, window-stall, and dead-connection defences, all tunable via
H2_*env vars. → Highlights - Framework static-miss fallback — a missing asset now falls through to the front controller (
try_files $uri /index.php) instead of a hard 404, matching Laravel/Symfony defaults. - Server security headers no longer overwrite the application's — a CSP or
X-Frame-Optionsset from PHPheader()is respected; the server values are insert-if-absent fallbacks. - Streamed responses are never buffered by the compression layer —
flush()/SSE responses keep their time-to-first-byte. - Breaking (×2): worker mode is now strictly static-or-worker, and Framework-mode
PATH_INFO/PHP_SELFfollow CGI semantics. → Upgrade from 0.7.0
Upgrading from v0.7.0? Two breaking changes, both routing-only — see the Upgrade checklist.
Highlights
HTTP Range requests for static files
Static responses now advertise Accept-Ranges: bytes and honour a single-range Range header:
# Resume an interrupted download
curl -C - -O https://example.com/dist/app-installer.dmg
# Seek inside a video — the browser does this for <video>/<audio> automatically
curl -r 1048576- https://example.com/videos/intro.mp4- All three forms are supported:
bytes=N-M,bytes=N-(offset to end),bytes=-N(last N bytes). - Unsatisfiable ranges return
416 Range Not SatisfiablewithContent-Range: bytes */<size>. If-Rangeis honoured with strong ETag comparison (and exact-dateLast-Modified), so a changed file safely falls back to a full200instead of splicing a mismatched fragment.- Static ETags are now strong (
"<size>-<mtime_hex>"); compressed representations carry a weakenedW/"…"tag, so a resumed download can never mix compressed and unencoded fragments. - Ranges and brotli are mutually exclusive (matching nginx). Only in-memory-cached files ≤1 MiB are ever compressed, so ranges always apply to the content that needs them — video, archives, images, and anything streamed from disk.
Configurable HTTP/2 connection limits & DoS hardening
Per-connection resource use is now bounded and operator-tunable. Each open HTTP/2 stream maps to a queued PHP request, so the defaults are sized to the worker pool rather than to browser multiplexing.
| Variable | Default | Bounds |
|---|---|---|
H2_MAX_CONCURRENT_STREAMS |
max_workers × 4 (min 32) |
Window stall — open streams pinned with a zero receive window |
H2_MAX_PENDING_RESET |
20 |
Rapid Reset (CVE-2023-44487) — HEADERS+RST_STREAM floods → GOAWAY ENHANCE_YOUR_CALM |
H2_MAX_HEADER_LIST_BYTES |
65536 |
HPACK indexed-reference bomb — total decoded header bytes per request |
H2_KEEPALIVE_INTERVAL_SECS |
20 (0 disables) |
dead/half-open connections holding stream slots |
H2_KEEPALIVE_TIMEOUT_SECS |
10 (clamped ≥1 s) |
PONG deadline before the connection is closed |
The resolved values are logged at startup (HTTP/2 limits); unparsable values fall back to the default rather than aborting.
Framework static-miss fallback to the front controller
In Framework mode (ENTRY_FILE=*.php) a missing static asset now rewrites to the front controller with the original URI as PATH_INFO, instead of returning a fast 404 — the canonical try_files $uri /index.php behaviour Laravel and Symfony ship by default, so your application renders its own 404. The trade-off: a request to a non-existent asset now costs a PHP dispatch. SPA mode keeps hard 404s.
Server security headers respect the application
X-Content-Type-Options, X-Frame-Options, and Content-Security-Policy sent from PHP header() are no longer silently replaced by the server defaults. The server values are now insert-if-absent fallbacks (like Apache's Header setifempty), and the framing pair is linked both ways so the server never doubles up an application's X-Frame-Options and CSP frame-ancestors.
Changed
- Static ETags are strong (
"<size>-<mtime_hex>"); compressed responses use a weakW/"…"tag.If-None-Matchuses weak comparison so both forms revalidate to 304. - Static file size, mtime, and ETag are taken from the opened file handle, not a separate
stat()— closing a deploy-time race that could pair new bytes with an old validator. - Framework-mode
PATH_INFOis set only for an explicit/index.php/extrarequest;SCRIPT_NAMEalways identifies the executed front controller. - Worker-mode routing follows the documented static-or-worker contract (see Upgrade).
PHP_DENY_PATHSnow applies in SPA mode (which executes existing.phpdirectly).
Fixed
- Server security headers no longer overwrite application headers (see Highlights).
- Custom error pages (
ERROR_PAGES_DIR) no longer drop semantically required headers —Content-Rangeon a416,Retry-Afteron a529,Allowon a405survive into the custom page; only body-coupled headers (Content-Encoding/ETag/Last-Modified) are dropped with the original body. - Conditional requests on static files produce 304 only for GET and HEAD, per RFC 9110.
$_SERVER['SCRIPT_NAME'](andDOCUMENT_URI/PHP_SELF) are correct in all routing modes — the old computation produced an emptySCRIPT_NAMEin Framework mode and mis-sliced percent-encoded paths.PATH_INFOis now percent-decoded, consistent with PHP-FPM.PHP_DENY_PATHScovers scripts reached through directory-index resolution (/uploads/→uploads/index.php).- Streamed responses are no longer collected in full by the compression layer —
flush()/SSE responses keep their time-to-first-byte and bounded memory.
Security
- HTTP/2 hardened against denial-of-service amplification. HPACK indexed-reference bombs, Rapid Reset (CVE-2023-44487), and window-stall are now bounded by the limits above, and dead/half-open connections are reclaimed via PING/PONG keepalive. The Rapid Reset defence is now an explicit, operator-visible value rather than an implicit library default.
Upgrade from 0.7.0
Two breaking changes, both routing-only. Every PHP-facing API is otherwise unchanged — pull the image and you are done.
1. Worker mode no longer executes .php files from the document root per-request. The router is now strictly static or worker: static assets are served from disk, and every other request (.php URIs, extensionless paths, directory indexes, /) is dispatched to the worker ENTRY_FILE. Previously an existing /about.php ran per-request, /blog/ resolved to blog/index.php, and a root index.php absorbed every unmatched request. If you mixed per-request .php scripts with a worker in one document root, move those scripts behind the worker's router or serve them from a separate non-worker instance.
2. PATH_INFO/PHP_SELF in Framework mode follow CGI semantics. $_SERVER['PATH_INFO'] is now set only for an explicit /index.php/extra request — a bare application route (/users/42) carries no PATH_INFO, and PHP_SELF reports the front controller (/index.php), not the full path. This matches nginx + PHP-FPM. Front controllers that routed on PATH_INFO or PHP_SELF should read $_SERVER['REQUEST_URI']. Classic /index.php/route apps now receive the correct value.
docker pull ghcr.io/oxphp/oxphp:0.8.0
# or, PHP 8.4:
docker pull ghcr.io/oxphp/oxphp:0.8.0-php8.4All images are multi-arch (amd64 + arm64), ship PHP 8.5 by default (8.4 via the *-php8.4* tags), and are cosign-signed via GitHub OIDC.
→ Full changelog: CHANGELOG.md
0.7.0
TL;DR
A feature-and-fixes release. The headline is a proper command-line interface — the same oxphp binary now serves HTTP and runs one-shot PHP scripts. One breaking change, in the JSON access log only.
oxphp run <script.php>— execute a single PHP script to completion under CLI semantics (PHP_SAPI === 'cli'), in the same image that serves your app. Migrations, cron jobs, queue workers,artisan/consolecommands — no second PHP install. → Highlightsoxphp serve+ implicit run + shebang — bareoxphpstill starts the server unchanged;oxphp app.phpis shorthand foroxphp run, and#!/usr/bin/env oxphpscripts run directly.- In-binary privilege drop with
--user— bind:80as root, then permanently drop to an unprivileged user before any request is served, with no orchestrator hook. - Trusted-proxy port headers —
X-Forwarded-Portnow drives$_SERVER['SERVER_PORT']; RFC 7239for=ip:portpopulates$_SERVER['REMOTE_PORT']. - Fixes — two amd64 decorator-cache worker crashes, profiler decorators now honor their attribute arguments, APM span events reach the OTel exporter, and more. → Fixed
- Breaking: access-log field
remote_addr→remote_ip(IP-only). → Upgrade from 0.6.0
Upgrading from v0.6.0? It is otherwise drop-in — go to the Upgrade checklist.
Highlights
A PHP-style CLI: oxphp serve, oxphp run, implicit run
The oxphp binary now has three roles — serve (HTTP), run (one-shot script), and config. Bare oxphp (and the published CMD ["oxphp"]) still starts the server, so nothing breaks.
# Serve — the default role
oxphp # implicit serve (unchanged)
oxphp serve
# Run a single PHP script to completion, exit with its code
oxphp run migrate.php
oxphp run -d memory_limit=512M bin/console.php cache:clear
oxphp app.php # implicit run — shorthand for `oxphp run`oxphp run executes under CLI semantics (PHP_SAPI === 'cli', phpinfo() prints text) on the main thread — no listener, no worker pool — and exits with the script's own code. The full engine is available underneath: fibers, OxPHP\Shared\*, and async (oxphp_async() when ASYNC_WORKERS > 0). $argv / $argc and STDIN/STDOUT/STDERR are wired up, so Composer- and Symfony-Console-style entry points run unmodified.
-d key[=value]ini overrides, repeatable, applied before module startup (so they beatphp.inifor every directive type, includingPHP_INI_SYSTEM/PHP_INI_PERDIR).- Shebang — a leading
#!line is skipped, so an executable#!/usr/bin/env oxphpscript runs directly. - End-of-options
--— honored before the script path (for a path that begins with a dash); everything after the script path is passed to PHP verbatim, so you rarely need it. - An unopenable script fails fast with
Could not open input file: <path>(exit 1), matchingphp.
See the new Command-Line Interface reference.
In-binary privilege drop with --user
# Start the container as root to bind :80, then serve as www-data
oxphp serve --user=www-data--user=<name|name:group|uid|uid:gid> (on both serve and run) drops OS privileges while the process is still single-threaded, in the fixed order initgroups → setgid → setuid → setuid(0) verification probe → best-effort PR_SET_NO_NEW_PRIVS. setuid (not seteuid) drops the real, effective, and saved IDs irreversibly, and the probe aborts startup if root can be regained. Names resolve at parse time (unknown user fails before startup); starting as non-root with --user is a hard error, never a silent no-op. This lets a container own a privileged bind without CAP_NET_BIND_SERVICE or a su-exec entrypoint.
Trusted-proxy port headers
Behind a configured TRUSTED_PROXIES:
X-Forwarded-Portnow drives$_SERVER['SERVER_PORT']andRequest::port()(priority:X-Forwarded-Port→ port suffix of the forwarded/Hostheader → 443/80 by scheme), so an ALB-on-8443 orproxy_set_header X-Forwarded-Portsetup conveys its public port to PHP.- RFC 7239
Forwarded: for=ip:portpopulates$_SERVER['REMOTE_PORT']with the client source port (it stays"0"otherwise —X-Forwarded-Forcarries no port).
Fixed
- Two decorator-cache worker crashes (
SIGSEGVinzend_hash_index_find, amd64; latent on arm64): a use-after-free of the per-thread instance cache across request shutdown, and a use-after-realloc of cachedzval*acrossbefore()/after()calls. - Profiler decorators honor their arguments —
#[OxPHP\Profile\SlowThreshold(ms: N)],#[MemoryThreshold(kb: N)], and#[Mark(label: …)]were ignoring constructor args and using hardcoded defaults; each occurrence now builds its own configured instance, with named-arg support. - APM span events exported —
Slow/MemorySpike/Mark/ exception events from#[OxPHP\Apm\Trace]now reach the OpenTelemetry exporter (were silently dropped), rendering natively in Jaeger / Tempo / Grafana. - Async-closure shared values survive
await— aShared\*returned fromoxphp_async()could be freed before the awaiting fiber materialized it; the return path now pins a keepalive. - Decorator nesting overflow fails loudly — new
OxPHP\Decorator\StackOverflowException; nesting limit raised 32 → 256. OxPHP\Shared\Map\KeyCursor::key(): mixed— fixes a tentative-return-type deprecation against theIteratorcontract.Request::file()/Request::files()now return uploaded files (were stubs) across all$_FILESshapes.
Install
Docker (recommended)
# PHP 8.5 (default for this release)
docker pull ghcr.io/oxphp/oxphp:latest
docker pull ghcr.io/oxphp/oxphp:php8.5
docker pull ghcr.io/oxphp/oxphp:0.7.0-php8.5-alpine3.23
# PHP 8.4 (still supported)
docker pull ghcr.io/oxphp/oxphp:php8.4
docker pull ghcr.io/oxphp/oxphp:0.7.0-php8.4-alpine3.23Verify the cosign signature:
cosign verify \
--certificate-identity-regexp 'https://github.com/oxphp/oxphp/.github/workflows/release.yml@refs/tags/v0.7.0' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/oxphp/oxphp:0.7.0-php8.5-alpine3.23From source
git clone https://github.com/oxphp/oxphp.git
cd oxphp
git checkout v0.7.0
cargo build --releaseCompatibility
- PHP 8.5 (ZTS) — default, served by
latestand unsuffixed tags. - PHP 8.4 (ZTS) — supported via
:php8.4*tag aliases. - Alpine
3.23base image. - Linux
amd64andarm64images published.
Upgrade from 0.6.0
Drop-in apart from one breaking change in the JSON access log — every PHP-facing API is unchanged.
-
Access-log field
remote_addr→remote_ip. The client-IP field is renamed and now carries the IP without a port (behind a trusted proxy it previously emitted a syntheticIP:0). Update any log pipeline / dashboard that keys onremote_addr:- {"remote_addr": "10.0.0.1:0", …} + {"remote_ip": "10.0.0.1", …}
The client/proxy source port stays available to PHP via
$_SERVER['REMOTE_PORT']. -
Bump your image tag to
0.7.0(orlatest).
That's it — no PHP code changes required.
Full changelog
0.6.0
TL;DR
A ground-up OxPHP\Shared\* redesign with a lot of breaking changes, plus a handful of cross-cutting server changes. The big rocks:
- Shared* API overhaul — new trichotomous wait-policy API (
try*/ bare verb /*Timeout(int $ms)), value-typedRecvResult/SendResult, a genericAtomic+Ordering, a name-keyedRegistry, rebuiltFlag/Counter/Once, and a unified naming convention. → Highlights - Cancelled requests no longer all return
500— timeout →504, graceful drain →503+Retry-After: 5, client abort →499. Update any check that treats500as "timeout". - Timeout API is now
set_time_limit()/max_execution_time—oxphp_request_heartbeat()and theREQUEST_TIMEOUT_SECONDSenv var are gone. - Base exceptions renamed —
Async\Exception→Async\AsyncException,Shared\Exception→Shared\SharedException(subclasses keep their names). OxPHP\Server\Workerinstance methods dropped thegetprefix;oxphp_async_await_any()now follows JavaScriptPromise.any(the old "first settled" behaviour moved tooxphp_async_await_race()).- Operator action required — Shared* memory accounting now books per-entry overhead, so revisit
OX_SHARED_MAX_BYTES; renamePHP_DENY_DIRS→PHP_DENY_PATHS. → Deprecated - ~4.7× geomean speedup at 8 threads on the Shared* per-call hot path.
Upgrading from v0.5.0? Most changes are mechanical — go straight to the Upgrade checklist.
Headline work since v0.5.0: a comprehensive OxPHP\Shared\* redesign — new trichotomous wait-policy API on Channel and Mutex (try* / bare verb / *Timeout(int $ms)) with value-typed RecvResult / SendResult returns; a generic Shared\Atomic int64 primitive with explicit Shared\Ordering; Shared\Flag rebuilt as the atomic-bool twin of Atomic; Shared\Counter collapsed onto add(int $delta = 1); Shared\Once with explicit Status enum and a FailureMode (Reset vs Poison); a new Shared\Registry for name-keyed process-global handles; and a unified naming convention published at docs/en/shared-state/shared-naming.md.
Beyond the Shared* layer: cancelled-request HTTP status no longer collapses to 500 (timeouts → 504, graceful drain → 503 + Retry-After: 5, client abort → 499 access-log-only, supervisor/user-cancel keep 500); base exception classes renamed to drop shadowing of PHP's \Exception (Async\Exception → Async\AsyncException, Shared\Exception → Shared\SharedException); OxPHP\Server\Worker instance methods dropped the get prefix; oxphp_async_await_any() swapped to JavaScript Promise.any semantics (the old "first settled" behaviour moved to the new oxphp_async_await_race()); set_time_limit() and max_execution_time take over from oxphp_request_heartbeat() and REQUEST_TIMEOUT_SECONDS; Shared* memory accounting now books per-entry structural overhead (operator action required on OX_SHARED_MAX_BYTES); and ~4.7× geomean speedup at 8 threads on the Shared* per-call hot path.
Lots of breaking changes — read the upgrade checklist before bumping.
Highlights
Shared\Channel / Shared\Mutex — trichotomous wait-policy API
The overloaded ?float $timeout argument is gone. Every wait method now picks one of three explicit policies:
try*— non-blocking- bare verb (
send,recv,withLock) — forever *Timeout(int $ms)— bounded;$msis milliseconds, must be> 0
Channel returns moved from mixed/null/bool to value-typed OxPHP\Shared\Channel\RecvResult / SendResult (isOk / isEmpty / isFull / isTimeout / isClosed / value() / valueOr($d)). Closed / full / timeout are routine outcomes in fan-out dispatchers, so they live on the result type instead of the exception hot path.
use OxPHP\Shared\Channel;
$ch = new Channel(capacity: 16);
// Non-blocking
$r = $ch->tryRecv();
if ($r->isOk()) { handle($r->value()); }
elseif ($r->isClosed()) { /* drain done */ }
// Bounded wait
$r = $ch->recvTimeout(200); // 200 ms
$r->status(); // RecvStatus::Ok|Empty|Timeout|Closed
// Forever
$ch->send($payload); // SendResult, never times outMutex went the other way — exception-typed because lock acquisition failure is the unusual case:
use OxPHP\Shared\Mutex;
$m = new Mutex(0);
$m->withLock(function (&$v) { $v++; }); // forever
$m->withLockTimeout(function (&$v) { /*...*/ }, 50); // OperationTimeoutException
$m->tryWithLock(function (&$v) { /*...*/ }); // ContentionExceptionThe closure signature changed from function ($value): mixed (return-to-commit) to function (&$value): mixed (by-ref mutation; the return value becomes the caller's return value). Mutex::isPoisoned() / clearPoison() removed — PHP throws no longer corrupt the lock; an FFI-boundary panic raises sticky CorruptedMutexException instead.
Shared\Atomic + Shared\Ordering
A generic int64 atomic primitive with explicit memory ordering, mapped one-to-one to std::sync::atomic::Ordering:
use OxPHP\Shared\{Atomic, Ordering};
$a = new Atomic(0);
$a->fetchAdd(1); // returns prev value
$a->compareAndSet(0, 1, Ordering::AcqRel);
$a->load(Ordering::Acquire);Methods: load, store, swap, compareAndSet, fetchAdd, fetchSub, fetchAnd, fetchOr, fetchXor. Invalid orderings (e.g. store(Ordering::Acquire)) raise Shared\InvalidOrderingException.
Shared\Counter — collapsed onto add() + set(), now Relaxed
inc() / dec() / addBatch() / reset() are gone. Use add(int $delta = 1) for both directions and set(0) (atomic exchange, returns prev) for a windowed reset. All operations are now Relaxed — Counter is a statistics accumulator, not a synchronisation point. Use Shared\Atomic (with explicit Ordering) when a counter must also publish other state.
Shared\Flag — rebuilt as the atomic-bool twin of Atomic
isSet() / set() / clear() / exchange() removed. Flag now mirrors Atomic: load / store / swap / compareAndSet, each taking an optional Ordering (default SeqCst). swap returns the previous value; store returns void.
Shared\Once — explicit Status, choosable failure mode
init() is renamed to getOrInit(). isInitialized(): bool is replaced by status(): Once\Status (Uninitialized / Pending / Ready / Poisoned) so a stored null is distinguishable from "not set". get() throws UninitializedException on an unset or in-flight cell, PoisonedException on a poisoned cell. Factory failure policy is chosen at construction:
use OxPHP\Shared\{Once, Once\FailureMode};
$o = new Once(onFactoryError: FailureMode::Poison);
$v = $o->getOrInit(fn () => fetchSchema());
$o->status(); // Once\Status::Ready (or Poisoned on retry-once failure)Shared\Registry — name-keyed process-global handles
Closes the gap where new Shared\*() produced per-worker instances rather than one shared entry. Registry::map($key, $factory), ::counter($key, $factory), etc. — one method per type, plus untyped Registry::global escape hatch — bind a Shared* entry under a string key so every worker thread and every request that reaches the same key converges on the same entry:
use OxPHP\Shared\Registry;
$sessions = Registry::map('sessions', fn () => new Shared\Map());
$rps = Registry::counter('rps', fn () => new Shared\Counter());In traditional mode, named entries also give same-host APCu-style cross-request persistence. Registry::remove($key), keys(), count(), memoryUsage() for inventory and tear-down.
Unified OxPHP\Shared\* naming + \Countable
Mechanical, no shims:
| Was | Is |
|---|---|
$ch->pending() |
$ch->count() |
$pool->size() |
$pool->count() |
$flag->test() |
$flag->isSet() |
$map->setIfAbsent($k, $v) |
$map->trySet($k, $v) |
Map, Channel, and Pool now implements \Countable, so count($map), count($ch), count($pool) work without calling ->count() directly. The naming rules for any new Shared* primitive are codified in docs/en/shared-state/shared-naming.md.
Cancelled-request HTTP status no longer collapses to 500
The wire status now reflects the cancel cause:
| Cause | Status |
|---|---|
max_execution_time / set_time_limit() exhaustion |
504 Gateway Timeout |
| Graceful-drain shutdown | 503 Service Unavailable + Retry-After: 5 |
| Client closed connection mid-request | 499 (access-log + metrics only) |
Supervisor Stuck kill |
500 |
Userland-initiated cancel (UserCancel) |
500 |
Anything that pattern-matched 500 to detect timeouts must switch to 504 — or, more robustly, the oxphp_request_cancelled_total{reason} metric. Operators with a custom ERROR_PAGES_DIR should add 504.html, 503.html, and optionally 499.html next to 500.html. ClientAbort moving out of 5xx will improve generic 5xx-rate SLOs — this is honest improvement (these were never server errors), called out so the SLO drop isn't mistaken for a regression.
set_time_limit() and max_execution_time are the timeout API
oxphp_request_heartbeat($n) is gone. Use set_time_limit($n) — both reset the per-request execution timer to $n seconds from now, but set_time_limit() is the portable PHP form everyone already knows. The deployment knob switched too: REQUEST_TIMEOUT_SECONDS is removed; set max_execution_time in php.ini / oxphp.ini (or per script via set_time_limit()). SIGALRM-driven max_execution_time is now the single execution-timeout source — the tokio::time::timeout wrap of the dispatch future is gone.
oxphp_async_await_any() swapped to Promise.any semantics
The old behaviour (...
0.5.0
Headline work since v0.4.0: a new OxPHP\Server\Worker runtime class with application-driven recycling via Worker::scheduleExit(), a canonical entry-script + worker-mode model (ENTRY_FILE / WORKER_MODE_ENABLED retiring the dual INDEX_FILE / WORKER_FILE knobs), a clearer static-file cache configuration (STATIC_MAX_AGE / STATIC_REVALIDATE), and strict parsing of boolean / STATIC_MAX_AGE env vars. Two breaking changes — non-canonical bool values and garbage STATIC_MAX_AGE values now fail at startup instead of silently defaulting.
Highlights
OxPHP\Server\Worker runtime class
A unified runtime handle for worker introspection and lifecycle control. Available in both traditional and worker modes:
use OxPHP\Server\Worker;
$w = Worker::current();
$w->getId(); // OS-thread-stable worker id
$w->getStartTime(); // float, seconds since epoch
$w->getRequestCount(); // 1-based count served by this thread
$w->getMemoryUsage(); // PHP heap usage (bytes)
$w->getRss(); // process RSS (bytes)
$w->getMaxMemoryBytes(); // configured WORKER_MAX_MEMORY_MIB ceiling
Worker::isWorkerMode(); // boolPlus a new Worker::serve() entry point for worker-mode loops, which throws OxPHP\Server\Exception\InvalidServeContextException if called outside worker mode.
Application-driven recycling via Worker::scheduleExit()
Workers can now mark themselves for graceful exit after the active request completes — the supervisor respawns a fresh worker and re-runs the outer scope. Replaces the old WORKER_MAX_REQUESTS knob with something the application actually controls:
if ($leakDetector->shouldRecycle()) {
Worker::scheduleExit('memory pressure');
}
// Companion getters:
Worker::isExitScheduled();
Worker::getExitReason();The corresponding oxphp_worker_recycles_by_reason_total Prometheus label changed from reason="max_requests" to reason="scheduled" to reflect the new origin. No-op in traditional mode.
ENTRY_FILE + WORKER_MODE_ENABLED: one knob, one explicit toggle
Replaces the older two-variable model (INDEX_FILE / WORKER_FILE) with a single canonical entry script plus an explicit worker-mode flag:
| Configuration | Behavior |
|---|---|
ENTRY_FILE unset |
Direct file mapping (was: traditional mode) |
ENTRY_FILE=index.php |
Front-controller routing (was: framework mode) |
ENTRY_FILE=index.html |
SPA fallback (was: SPA mode) |
ENTRY_FILE=worker.php + WORKER_MODE_ENABLED=true |
Persistent worker mode |
ENTRY_FILE accepts both relative paths (resolved against DOCUMENT_ROOT, including ..) and absolute paths. The startup mode_decided log line records which combination was selected. /config now reports entry_file and worker_mode_enabled in place of index_file, worker_file, and the synthetic worker_mode boolean.
The legacy variables are still parsed for backwards compatibility (with a WARN log line at startup); they will be removed in a subsequent release.
STATIC_MAX_AGE / STATIC_REVALIDATE: clearer static-cache knobs
The static-file cache env vars were renamed for clarity, and the polarity of the disable-knob was flipped to match positive English:
| Old | New | Notes |
|---|---|---|
STATIC_CACHE_TTL=N |
STATIC_MAX_AGE=N |
The value is the Cache-Control: max-age it sets |
STATIC_CACHE=off |
STATIC_REVALIDATE=on |
Polarity flipped — STATIC_REVALIDATE=on enables mtime revalidation |
Defaults are unchanged: 30 days max-age, no revalidation. Legacy variables remain parsed (with a WARN); /config reports static_max_age and static_revalidate.
Strict env-var parsing (breaking)
Two env-var classes are now strictly parsed against canonical value sets and fail at startup on unknown values, rather than silently falling back to a default:
- Booleans must be one of
on/true/1/yes(truthy) oroff/false/0/no(falsy), case-insensitive and trimmed. Typos liketureor aliases likeenabledare rejected. Affected:WORKER_MODE_ENABLED,STATIC_REVALIDATE,TRACE_CONTEXT,SUPERGLOBALS_ENABLED,SHARED_ENABLED,SHARED_METRICS_ENABLED,SHARED_INTROSPECTION_ENABLED,SHARED_INTROSPECTION_PREVIEW_ENABLED,SHARED_POISON_STRICT,PROFILER_ENABLED,PROFILER_INTERNAL,PROFILER_EXPORT_XHGUI. STATIC_MAX_AGE(and the deprecatedSTATIC_CACHE_TTLshim) must parse as a number.STATIC_MAX_AGE=garbagefails at startup with an error naming the variable, where it previously silently dropped theCache-Controlheader.
Empty assignments (FOO=) and unset variables still fall back to the default — this matches Docker Compose / Kubernetes substitution like FOO=${FOO} when the host variable is missing.
The legacy STATIC_CACHE shim remains intentionally lenient (only off enables revalidation, anything else disables) so existing deployments keep working through the deprecation cycle.
Deprecated
The following environment variables are still parsed for backwards compatibility but emit a WARN log line at startup. They will be removed in a subsequent release:
WORKER_MAX_REQUESTS— parsed and ignored. Migrate toWORKER_MAX_MEMORY_MIBfor safety-net recycling, or toWorker::scheduleExit()for application-driven recycling.INDEX_FILE/WORKER_FILE—INDEX_FILE=...≡ENTRY_FILE=..., andWORKER_FILE=...≡WORKER_MODE_ENABLED=true ENTRY_FILE=.... When both legacy and new variables are set, the new ones win and the warning still fires.STATIC_CACHE_TTL/STATIC_CACHE—STATIC_CACHE_TTL=...≡STATIC_MAX_AGE=..., andSTATIC_CACHE=off≡STATIC_REVALIDATE=on. When both are set, the new ones win.
Internal
- New benchmark tooling under
scripts/:bench-wrk.sh(one-shot wrk runner) andsweep-config.sh(matrix sweep overTOKIO_WORKERS×PHP_WORKERS). Local-only, not wired into CI. - Dependency bumps:
opentelemetry/opentelemetry_sdk/opentelemetry-otlp/opentelemetry-semantic-conventions0.27 → 0.31,tonic0.12 → 0.14(now requires the explicitgrpc-tonicfeature onopentelemetry-otlp),rand0.8 → 0.10,getrandom0.2 → 0.4,reqwest0.12 → 0.13,brotli7 → 8,lru0.12 → 0.18. No user-visible behavior change; OTel migration switches toSdkTracerProviderwithwith_batch_exporter,Resource::builder(), and the newforce_flush()Resultshape.
Install
Docker (recommended)
# PHP 8.5 (default for this release)
docker pull ghcr.io/oxphp/oxphp:latest
docker pull ghcr.io/oxphp/oxphp:php8.5
docker pull ghcr.io/oxphp/oxphp:0.5.0-php8.5-alpine3.23
# PHP 8.4 (still supported)
docker pull ghcr.io/oxphp/oxphp:php8.4
docker pull ghcr.io/oxphp/oxphp:0.5.0-php8.4-alpine3.23Verify the cosign signature:
cosign verify \
--certificate-identity-regexp 'https://github.com/oxphp/oxphp/.github/workflows/release.yml@refs/tags/v0.5.0' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/oxphp/oxphp:0.5.0-php8.5-alpine3.23From source
git clone https://github.com/oxphp/oxphp.git
cd oxphp
git checkout v0.5.0
cargo build --releaseCompatibility
- PHP 8.5 (ZTS) — default, served by
latestand unsuffixed tags. - PHP 8.4 (ZTS) — supported via
:php8.4*tag aliases. - Alpine
3.23base image. - Linux
amd64andarm64images published.
Upgrade checklist
Two breaking changes around env-var parsing — audit your deployment env before upgrading.
- Audit boolean env vars. Any non-canonical value (e.g.
enabled,disabled,ture,y,n) for the variables listed under "Strict env-var parsing" will now refuse to start. Replace with one ofon/true/1/yes/off/false/0/no. - Audit
STATIC_MAX_AGE(and the deprecatedSTATIC_CACHE_TTL). Garbage values likeSTATIC_MAX_AGE=garbagepreviously silently dropped theCache-Controlheader; they now fail at startup. Either set a numeric value (seconds) or unset the variable to fall back to the default. - Migrate
WORKER_MAX_REQUESTS. It is now parsed-and-ignored with aWARN. Switch toWORKER_MAX_MEMORY_MIBfor safety-net recycling, or callWorker::scheduleExit()from your worker code for application-driven recycling. - Migrate
INDEX_FILE/WORKER_FILE→ENTRY_FILE/WORKER_MODE_ENABLED. Legacy forms still work (with aWARN) but will be removed. - Migrate
STATIC_CACHE_TTL/STATIC_CACHE→STATIC_MAX_AGE/STATIC_REVALIDATE. Note the polarity flip:STATIC_CACHE=offbecomesSTATIC_REVALIDATE=on. - If you scrape
oxphp_worker_recycles_by_reason_total, update dashboards/alerts: thereason="max_requests"label is nowreason="scheduled". - If you read
/config, the keysindex_file/worker_file/worker_modeare replaced byentry_file/worker_mode_enabled.
Full changelog
0.4.0
Headline work since v0.3.0: PHP 8.5 is now the default (latest and unsuffixed {ver} aliases resolve to 8.5; 8.4 stays available via :php8.4* tags), SAPI_HEADER_DELETE_PREFIX for prefix-based header_remove() on PHP 8.5.6+, and a chain of fixes to fiber detection, cross-worker Shared\* handles, plugin named-argument support, profiler DELETE, and traditional-mode streaming. No breaking changes — drop-in upgrade from v0.3.0.
Highlights
PHP 8.5 promoted to default
This release flips the default-PHP target from 8.4 to 8.5. The floating aliases (latest, 0.4.0, 0.4, php8.5) now resolve to PHP 8.5 builds; PHP 8.4 remains supported indefinitely via :php8.4*-suffixed tags:
docker pull ghcr.io/oxphp/oxphp:latest # → 0.4.0-php8.5-alpine3.23
docker pull ghcr.io/oxphp/oxphp:php8.5 # → 0.4.0-php8.5-alpine3.23
docker pull ghcr.io/oxphp/oxphp:php8.4 # → 0.4.0-php8.4-alpine3.23 (still supported)
docker pull ghcr.io/oxphp/oxphp:0.4.0-php8.4-alpine3.23 # version-pinned 8.4Under the hood, src/php/bindings.rs was split into per-version modules (common.rs, v8_4.rs, v8_5.rs); build.rs detects the linked PHP via php-config --vernum (or PHP_VERSION_ID env override) and selects the matching ABI. Adding the next minor is now self-contained.
SAPI_HEADER_DELETE_PREFIX (PHP 8.5.6+)
header_remove('X-Foo-') now strips every previously-set header whose "{name}: {value}" line case-insensitively starts with the given prefix, matching upstream PHP SAPI behavior. PHP < 8.5.6 keeps the existing exact-match semantics — no behavior change there.
Weekly rebuild hygiene
weekly-rebuild.yml gains skip-if-unchanged via upstream digest annotation comparison plus a focused tests/run_all.sh subset (headers, cookies, get_post_request, input, pathinfo, errors) gated on whether any matrix cell actually rebuilt. Saves both CI minutes and registry churn when upstream PHP is quiet.
Fixed
- Streaming responses on the traditional executor losing chunked
Transfer-Encodingafter the firstoxphp_stream_flushon a worker. The bridge per-request context (stream_mode/headers_sent/finished) is__thread-local and was leaking across requests; the traditional path now resets it before each request to match worker mode. oxphp_bridge_in_fiber()misreporting fiber context — both on the main thread (where PHP seedsEG(current_fiber_context)toEG(main_fiber_context)at request startup) and inside a user-levelFiber::start()body (which installs its ownzend_fiber_contextdistinct from main). The latter causedShared\Channel::recv()/send()inside a user Fiber to take the fiber-suspend path, hit rc=1 fromoxphp_bridge_fiber_await("not in oxphp fiber"), and surface asRuntimeException: recv: fiber_await rc=1. The predicate now keys off the SAPI's privateoxphp_current_fiber__thread pointer via a registered callback (oxphp_bridge_set_in_fiber_check), which is the only authoritative source for "is this thread inside an oxphp scheduler fiber". User fibers correctly fall through to the thread-blocking branch.OxPHP\Shared\Channel,Shared\Map, andShared\Poolhandles arriving asnullon the receiving worker thread when captured by anoxphp_async()closure (viause ($var)or as a variadic arg). The cross-thread wrapper rebuild in the C bridge listed onlyCounter/Flag/Once/Mutexin its tag→class switch, so the other threeSharedTypevariants fell through todefaultand producedIS_NULL. The mapping now lives only in Rust (SharedType::php_class_cstr) and the C bridge calls a weak-linkedoxphp_shared_class_name(type_tag)export, so adding aSharedTypevariant cannot drift again. Re-enables theshared/test_channel_fiber_*worker-mode suites.- Plugin-defined classes, interfaces, enums, and free functions advertised no parameter names, so PHP named-argument syntax (e.g.
$ch->send('x', timeout: 0.1)) failed with"Unknown named parameter"andReflectionParameter::getName()returned an empty list. The bridge method/function registration now carries per-parameter name/type/optional arrays, and the SAPI extension synthesizes a fullzend_internal_arg_infoarray with real names instead of an unnamed return-only stub. DELETE /__profiler/runs/{id}panicking the worker with"cannot block from within a runtime". The internal HTTP server dispatches sync handlers from inside hyper's async service, so the index-lock acquire switched fromblocking_lock()to atry_lockretry loop with a 5 s deadline that degrades to503on real contention.
Install
Docker (recommended)
# PHP 8.5 (default for this release)
docker pull ghcr.io/oxphp/oxphp:latest
docker pull ghcr.io/oxphp/oxphp:php8.5
docker pull ghcr.io/oxphp/oxphp:0.4.0-php8.5-alpine3.23
# PHP 8.4 (still supported)
docker pull ghcr.io/oxphp/oxphp:php8.4
docker pull ghcr.io/oxphp/oxphp:0.4.0-php8.4-alpine3.23Verify the cosign signature:
cosign verify \
--certificate-identity-regexp 'https://github.com/oxphp/oxphp/.github/workflows/release.yml@refs/tags/v0.4.0' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/oxphp/oxphp:0.4.0-php8.5-alpine3.23From source
git clone https://github.com/oxphp/oxphp.git
cd oxphp
git checkout v0.4.0
cargo build --releaseCompatibility
- PHP 8.5 (ZTS) — default, served by
latestand unsuffixed tags.SAPI_HEADER_DELETE_PREFIXavailable on 8.5.6+. - PHP 8.4 (ZTS) — supported via
:php8.4*tag aliases. - Alpine
3.23base image. - Linux
amd64andarm64images published.
Upgrade checklist
No breaking changes. A drop-in upgrade from v0.3.0:
- Bump your image tag to
0.4.0-php8.5-alpine3.23(or:latest/:php8.5if you track aliases). To stay on 8.4, pin to:php8.4or0.4.0-php8.4-alpine3.23. - If you previously tracked
:latestwhile expecting PHP 8.4, switch to:php8.4(or a version-pinned 8.4 tag) —latestnow resolves to 8.5. - If you build third-party Rust plugins that register PHP functions / classes / interfaces / enums and want named-argument support, no code change is required — the registration shape now carries parameter names automatically. Just rebuild against
0.4.0. - If you run
oxphp_async()workers that captureShared\Channel/Shared\Map/Shared\Poolhandles viause ($var)or variadic args, those now arrive non-null on the receiving worker. Tests previously skipped due to this can be re-enabled. - If you call
header_remove()on PHP 8.5.6+ with a trailing-dash prefix (e.g.header_remove('X-Foo-')), be aware it now does prefix-strip rather than exact-match, matching upstream PHP SAPI semantics.
Full changelog
0.3.0
Headline work since v0.2.0: shared state for PHP workers without Redis (seven OxPHP\Shared\* primitives), a per-request PHP profiler, APM auto-instrumentation, trusted-proxy and Kubernetes integrations for production deployments, security-header hardening, and a cosign-signed parametrized Docker image matrix.
Highlights
OxPHP\Shared\* — in-process shared state, no Redis required
Seven concurrency primitives that live in the worker pool's process memory and survive across requests:
Shared\Counter— atomic int64 (inc/dec/add/compareAndSet/addBatch/reset)Shared\Flag— atomic bool (test/set/clear/exchange/compareAndSet)Shared\Once— run-once container with factory, reentrantinitthrowsDeadlockExceptionShared\Mutex— poisoning mutex withwith($body, $timeout)/tryWith($body)scope guardsShared\Channel— bounded MPMC queue with fiber-awaresend/recvandsendMany/recvManyShared\Map— concurrentstring → mixedstore with batched ops and per-instancemaxEntriesShared\Pool— bounded object pool with lazy factory, strictmaxSize, per-thread affinity, idle-timeout eviction
Backed by a full observability surface: /__ox_shared/{summary,entries,entry,preview,types,graph} on the internal server, oxphp_shared_* Prometheus metrics (aggregate + per-instance for Channel/Map/Pool), and a cross-thread deadlock detector ticking oxphp_shared_deadlock_detected_total.
Docs: Shared State overview · Observability · Migrating to external stores
Per-request PHP profiler
Production-grade profiler shipped as part of the default feature set:
- Four output formats: xhprof, speedscope, pprof, collapsed
- PHP SDK:
OxPHP\Profile\{start, stop, pause, resume, mark, metric, is_active} - Seven attributes:
#[Profile],#[Exclude],#[Sample],#[Tag],#[Mark],#[SlowThreshold],#[MemoryThreshold] - Triggers: cookie (
OXPROF=<token>), header (X-OxPHP-Profile: <token>), query (?__oxprof=<token>), and statistical (PROFILER_SAMPLE_RATE) - In-memory LRU + disk retention with background trimmer, token-bucket disk write rate limiting
- HTTP push (
PROFILER_EXPORT_URL) with 3× exponential backoff, 5 s wallclock cap, bearer-token auth, xhgui envelope auto-detect - Internal routes under
/__profiler/with optional bearer auth and path-traversal revalidation - Prometheus metrics:
oxphp_profiler_{runs,spans_collected,bytes_written,disk_drops,http_push_failures,truncated,in_memory_runs}_total - Ready-to-run
xhguiDocker profile demonstrating the full push → mongo → xhgui UI flow
Docs: docs/{en,ru,zh}/features/profiling.md
APM & tracing
- APM plugin (
plugin-apm) with auto-instrumentation, PHP tracing SDK, and error capture - New plugin PHP builder API for registering Rust-backed PHP functions and classes from plugins
- Async and APM subsystems migrated out of the C extension and into Rust plugins
- Return-type support in the C builder API for plugin methods
Production HTTP & Kubernetes
TRUSTED_PROXIES— accepts a trusted-proxy CIDR list (or the keywordprivate), processes RFC 7239ForwardedandX-Forwarded-*headers, and overridesREMOTE_ADDR/HTTPS/REQUEST_SCHEME/SERVER_NAME/SERVER_PORTfor PHP using the rightmost-non-trusted algorithm- Kubernetes probes at
/readyzand/livezwith graceful-shutdown awareness PATH_INFOsplitting viaSPLIT_PATH_INFO_ENABLED— nginx/PHP-FPM-style front-controller routingPHP_DENY_DIRSto block.phpexecution in specified paths- Dot-path access blocked by default with RFC 8615
.well-knownexception
Security headers
X-Content-Type-Options: nosniffon all responses- Configurable
X-Frame-Options(FRAME_OPTIONS, defaultSAMEORIGIN) for clickjacking protection
Supply chain & packaging
- Parametrized Docker image matrix — two Dockerfiles (dev +
Dockerfile.alpine-release) sharingARG PHP_VERSION/ARG ALPINE_VERSION/ARG BASE_IMAGE - Canonical minor-floating (
{ver}-php{minor}-alpine{alpine}) and patch-pinned tags published toghcr.io/oxphp/oxphp, plus aliases (php{minor},latest, etc.) - cosign-signed release images via GitHub OIDC
- Weekly rebuild workflow re-publishes canonical tags with fresh upstream PHP patches and re-signs
- Prod image now ships
phpCLI,docker-php-ext-install,phpize, andwww-dataout of the box (was bare alpine in 0.2.0)
Testing
- PHP integration test suite — 186 tests across 21 groups and 12 Docker profiles, covering apm, async, errors, framework, pathinfo, ratelimit, TLS, timeout, worker and more
Operations
- CLI argument parsing:
--help,--version,--config --check - Startup errors emitted as structured JSON logs (previously plain text)
- Docker
HEALTHCHECKwired intocompose.yml
Breaking changes
If you upgrade from 0.2.0, read this section before deploying.
- Async namespace migration — all async-related PHP classes moved under
OxPHP\Async\:OxPHP\AsyncException→OxPHP\Async\ExceptionOxPHP\AsyncTimeoutException→OxPHP\Async\TimeoutExceptionOxPHP\AsyncBorrowException→OxPHP\Async\BorrowExceptionOxPHP\BorrowedProxy→OxPHP\Async\BorrowedProxy
- Async functions (
oxphp_async,oxphp_async_await, …) are now provided by theplugin-asyncfeature flag. Without it, the functions are not available. Function names are unchanged. - Plugin API —
Plugin::shutdownnow takes&mut self(was&self). Plugin authors must update implementations. - Plugin config —
env::set_varside-effects from plugin init no longer propagate to the core server. Plugins must publish core-relevant flags through the explicit core-flags API. RequestCompleteevent — string-serialized metadata replaced with typed fields.- Prod image
USERpolicy —Dockerfile.alpine-releaseno longer sets a finalUSER, matchingnginx:alpine/php-fpm:alpine/frankenphp:alpineconventions. Drop privileges at the orchestrator level:docker run --user www-data, Composeuser:, KubernetesrunAsUser: 82.chown www-data:www-data /var/www/htmlstill runs at build time.
Performance
Shared\Pooluncontested acquire/release hot path: ≤ 5 µs gate, ~0.9 µs observed in Docker. Per-thread affinity keeps slots hot in the acquiring thread without cross-thread handoff.Shared\Mapset/getavoids serialisation for nestedShareablerefs — the refcount-bump retain path is cycle-checked before any mutation, so rejected inserts leak nothing.- Routing refactored into per-mode modules with a performance and behavior overhaul.
- Request latency reduced across all stack layers — fewer allocations and clones across routing, response assembly, and hot-path dispatch.
oxphp_request_heartbeat($time)now also resets PHP's ownmax_execution_timetimer alongside the server-side deadline, so long-running scripts can't be killed by Zend's fatal after a heartbeat.set_time_limit(0)/max_execution_time=0scripts are left alone.
Fixed
- Pool chaos reclaim — in-flight slot counts are refunded when a SAPI worker thread panics mid-acquire, so a crashing worker no longer silently burns budget in the surviving workers' view.
- Cross-thread
Shared\*access no longer depends on theworker_livenesshook for Map / Counter / Flag / Once / Mutex — only Pool uses thread-registration. - Async worker SIGBUS from cross-thread
MAP_PTRaccess. headers_list()returning empty — the header handler now returnsSAPI_HEADER_ADD.payload()returning null for JSON body after a PDO query reused the request buffer.SecurityHeadersHandlerenv-variable race:FRAME_OPTIONSis now resolved at startup rather than read per request.- Decorator
RejectedExceptiondispatch and instance-cache collisions across requests. -Wint-to-pointer-castwarning in bridgeserver_contextassignment.- Default-feature (
php) compile/clippy errors previously masked by the--no-default-featuresCI profile. - TLS test profile now generates v3 certificates dynamically and the runner supports HTTPS.
- E2E runner
curl_argsparsing no longer strips shell quotes incorrectly. - Cancelled-task exception class corrected to
OxPHP\Async\Exception.
Install
Docker (recommended)
docker pull ghcr.io/oxphp/oxphp:0.3.0-php8.4-alpine3.23
# or the minor-floating alias
docker pull ghcr.io/oxphp/oxphp:php8.4
# or the rolling latest
docker pull ghcr.io/oxphp/oxphp:latestVerify the cosign signature:
cosign verify \
--certificate-identity-regexp 'https://github.com/oxphp/oxphp/.github/workflows/release.yml@refs/tags/v0.3.0' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/oxphp/oxphp:0.3.0-php8.4-alpine3.23From source
git clone https://github.com/oxphp/oxphp.git
cd oxphp
git checkout v0.3.0
cargo build --releaseCompatibility
- PHP 8.4 (ZTS). PHP 8.5 is not yet supported — the Rust FFI bindings and C bridge in
ext/bridge/need updating for the 8.5 ABI. Tracked as a follow-up. - Alpine
3.23base image. - Linux
amd64andarm64images published.
Upgrade checklist
- Audit PHP code for
OxPHP\Async*class references and migrate toOxPHP\Async\*. - If you consume async functions, enable the
plugin-asyncCargo feature (it is on by default in the published images). - Third-party Rust plugins: update
Plugin::shutdown(&self)→Plugin::shutdown(&mut self)and move anyenv::set_varside...
0.2.0
Concurrency, PHP Object API, and RFC compliance — 32 commits since v0.1.0.
Highlights
Fibers & Async — Workers can now handle concurrent I/O via fiber-based multiplexing, and oxphp_async() enables parallel PHP execution on a dedicated thread pool. Distributed tracing
with W3C Trace Context and OpenTelemetry export is built in.
PHP Object API — New OxPHP\Http\Request class with lazy bridge accessors, plus RequestInterface, SessionInterface, and AttributesInterface for type safety. Attribute-based
decorators via oxphp_register_decorator() and OxPHP\Decorator\AttributeInterface bring AOP-style middleware directly into PHP.
RFC Compliance — Date and Content-Type headers on all responses, correct SERVER_PROTOCOL, proper 408 on request timeout, and fixed REQUEST_TIME to return the actual request
start time.
Breaking Changes
- Default listen port changed from
8080to80(auto-switches to443whenTLS_CERTis set) - Backpressure response code changed from
503to529
What's New
Async & Concurrency
- Fiber-based request multiplexing in worker mode
oxphp_async()— parallel PHP execution via dedicated thread pool- W3C Trace Context propagation and OpenTelemetry export
PHP API
- HTTP Object API (
OxPHP\Http\Request) with lazy bridge accessors - HTTP interfaces (
RequestInterface,SessionInterface,AttributesInterface) with clone/serialize blocking on request-scoped classes - Attribute-based decorator system (
oxphp_register_decorator(),OxPHP\Decorator\AttributeInterface) with PHP observer integration
Server Variables
HTTPS,REQUEST_SCHEME,DOCUMENT_URI,REQUEST_TIME_FLOAT
Observability
- Request duration histograms, byte counters, and subsystem metrics
trace_contextfield in/configendpointworkers_idlemetric now correctly reports idle count in static pool modeworkers_spawned_totalincludes initial worker spawn
Static Files
STATIC_CACHE=offmode with mtime-based revalidation
Bug Fixes
SERVER_PROTOCOLreflects actual HTTP version (was hardcodedHTTP/1.1)REQUEST_TIMEreturns request start time (was returning current time)- IPv6 Host header parsing for
SERVER_NAME/SERVER_PORT - Request timeout returns 408 instead of 504 per RFC 9110
- Duplicate
oxphp_response_time_us_totalmetric removed - Session state leaks between worker requests fixed
- Missing fiber sources in alpine-release Dockerfile
Upgrade Notes
If you rely on LISTEN_ADDR defaults, the server now binds to port 80 instead of 8080. Set LISTEN_ADDR=0.0.0.0:8080 explicitly to keep the old behavior.
Full Changelog: v0.1.0...v0.2.0
0.1.0
First public release. OxPHP replaces nginx + PHP-FPM with a single async binary
written in Rust, providing HTTP serving, native PHP execution via custom SAPI,
and built-in observability.
Core
- Async HTTP/1.1 server built on Hyper + Tokio with graceful shutdown
- Custom PHP SAPI (
oxphp) with full superglobals ($_GET,$_POST,$_SERVER,$_COOKIE,$_FILES) - C bridge library (
liboxphp_bridge.so) for zero-copy Rust↔PHP communication via direct zval access - PHP ZTS (Zend Thread Safety) multi-threaded worker pool with bounded queue and 503 backpressure
- Three routing modes: Traditional (direct file mapping), Framework (front controller), SPA (fallback to
index.html) - Static file serving with in-memory cache, MIME detection, and HTTP caching (ETag, Last-Modified, 304 responses)
- Brotli compression with configurable quality level (0–11) and minimum size threshold
- TLS support via PEM certificate/key
- PHP 8.4 compatibility
Worker Mode
- Persistent PHP worker processes (
oxphp_worker()) that handle multiple requests with soft reset between them - Early response completion (
oxphp_finish_request()) for background processing after response is sent - SSE streaming with real-time chunked delivery (
oxphp_is_streaming(),oxphp_stream_flush()) - Cooperative timeout and cancellation via
oxphp_request_heartbeat() - Worker mode detection (
oxphp_is_worker()) and introspection (oxphp_worker_id(),oxphp_server_info()) - Resilient to
exit/diein PHP 8.4 worker mode
Worker Pool
- Static pool mode: fixed number of workers (
PHP_WORKERS=N) - Dynamic pool mode: auto-scaling between min and max workers (
PHP_WORKERS=MIN:MAX) - Auto-detect mode: defaults to CPU/2 workers (
PHP_WORKERS=0) - Per-worker memory limits (
WORKER_MAX_MEMORY_MIB) and request limits (WORKER_MAX_REQUESTS) - Dead worker respawning via health monitor (static) / scale manager (dynamic)
catch_unwindprevents panics from poisoning the channel
Observability
- Prometheus metrics endpoint (
/metrics) with request counts, durations, status codes, active connections, queue depth, and worker mode stats - Health check endpoint (
/health) - Runtime configuration endpoint (
/config) - Structured JSON access logging with configurable levels (
ACCESS_LOG: off/error/all) - Request ID generation (
oxphp_request_id()) in{timestamp:08x}{counter:08x}format - Structured PHP error logging via
zend_error_cb
Security & Limits
- Per-IP rate limiting (
RATE_LIMIT,RATE_WINDOW_SECONDS) - Header read timeout (
HEADER_TIMEOUT_SECONDS) and request timeout (REQUEST_TIMEOUT_SECONDS) - Graceful shutdown with drain timeout (
DRAIN_TIMEOUT_SECONDS) - Configurable request body limits
- Path traversal protection with canonicalization
Plugin System
- Plugin trait with lifecycle hooks (init, startup, shutdown)
- Typed event dispatcher with priority ordering at every lifecycle point
- Events: ConnectionAccepted, RequestReceived, RouteResolved, ScriptExecutionComplete, ResponseBuilding, RequestComplete
- Native PHP function registration from plugins (zero-copy zval access, no JSON serialization)
- Plugin context API for handler registration and configuration
Performance
- mimalloc global allocator for reduced per-alloc latency
- Configurable multi-threaded Tokio runtime (
TOKIO_WORKERS) - Route LRU cache for fast path resolution
- Thread-local buffer reuse for server variables
- Single Arc clone per request (reduced from 10)
- OPcache support with correct
sapi_get_request_time()initialization
Infrastructure
- Multi-stage Alpine Docker build (
php:8.4-zts-alpine) - Multi-platform Docker images (amd64/arm64) published to GHCR
- CI workflows: nightly build, PR checks (fmt, clippy, tests), release tagging
- Best-practice Dockerfile example with separate dev/prod targets
- HTTP QUERY method support (RFC 9110)
- Documentation in English, Russian, Belarusian, and Chinese
PHP Functions
| Function | Description |
|---|---|
oxphp_request_id() |
Current request identifier |
oxphp_worker_id() |
Current worker thread ID |
oxphp_server_info() |
Server runtime information |
oxphp_request_heartbeat() |
Signal liveness for cooperative timeout |
oxphp_finish_request() |
Flush response early, continue background work |
oxphp_is_worker() |
Whether running in worker mode |
oxphp_is_streaming() |
Whether SSE streaming is active |
oxphp_stream_flush() |
Flush SSE chunk to client |
oxphp_worker(callable) |
Enter persistent worker loop |
Environment Variables
| Variable | Default | Description |
|---|---|---|
LISTEN_ADDR |
0.0.0.0:8080 |
Server listen address |
DOCUMENT_ROOT |
www/public |
Document root path |
INDEX_FILE |
index.php |
Front controller file |
PHP_WORKERS |
CPU/2 | Worker pool size (N, MIN:MAX, or 0) |
PHP_WORKERS_IDLE_SECONDS |
30 |
Dynamic pool idle timeout |
TOKIO_WORKERS |
CPU/2 | Tokio runtime threads (0 = auto) |
QUEUE_CAPACITY |
workers×128 | Bounded queue size |
RATE_LIMIT |
0 (off) |
Max requests per window per IP |
RATE_WINDOW_SECONDS |
60 |
Rate limit window |
HEADER_TIMEOUT_SECONDS |
10 |
Header read timeout |
REQUEST_TIMEOUT_SECONDS |
30 |
Request execution timeout |
DRAIN_TIMEOUT_SECONDS |
30 |
Graceful shutdown drain |
COMPRESSION_LEVEL |
4 |
Brotli quality 0–11 (0 = off) |
STATIC_CACHE_TTL |
3600 |
Static file Cache-Control max-age |
ACCESS_LOG |
all |
Log level: off/error/all |
ERROR_PAGES_DIR |
— | Directory with {status}.html pages |
TLS_CERT / TLS_KEY |
— | TLS certificate/key PEM paths |
INTERNAL_ADDR |
0.0.0.0:9090 |
Health/metrics/config endpoint |
WORKER_MAX_REQUESTS |
0 (unlimited) |
Max requests per worker before restart |
WORKER_MAX_MEMORY_MIB |
0 (unlimited) |
Max worker memory before restart |
EXECUTOR |
sapi |
Executor type: sapi/stub |
Full Changelog: https://github.com/oxphp/oxphp/releases/tag/v0.1.0