Skip to content

Releases: oxphp/oxphp

0.9.0

Choose a tag to compare

@diolektor diolektor released this 25 Jun 18:32

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 call oxphp_async() itself and suspend on await_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 (default 256) bounds concurrent async tasks. A dispatch past the cap (ASYNC_MAX_FIBERS × ASYNC_WORKERS) is rejected immediately with AsyncException rather 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 get 403 before any handler runs, so they can't even probe which paths exist; health endpoints stay reachable for orchestrators. → Highlights
  • PROFILER_EXCLUDE_PATHS keeps framework self-traffic (Symfony's /_profiler, /_wdt) out of sampled profiles, while explicit profile triggers still apply on excluded paths.
  • /config scrubbed and internal listener hardenedinternal_addr and error_pages_dir removed from /config, a port-only INTERNAL_ADDR now 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 await now cancels the abandoned task instead of letting it run to request end, and /config no 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.

Async Promises docs

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, /startupz and their long forms) are always reachable, so orchestrator and load-balancer probes never break.
  • Loopback is not implicit — list 127.0.0.1/32 to 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).

Internal Server docs

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.

Profiling docs

Fixes

  • A fiber send-waiter parked on a full Shared\Channel is now woken when recv_blocking frees a buffer slot — previously a blocked sender could strand under saturation, making sendTimeout trip spuriously.
  • A graceful-shutdown crash is fixed by tearing down PHP on the main thread.
  • A fiber await on a closed async promise now rejects instead of stalling.
  • await_all cancels 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.

  1. A timed-out await now cancels the abandoned task. Previously a task whose await / await_all / await_any / await_race timed 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 the await timeout.

  2. /config no longer reports internal_addr or error_pages_dir. Action: tooling that read these from /config must source them another way (process environment / deployment manifest). The remaining keys are unchanged.

  3. A port-only INTERNAL_ADDR now binds loopback. INTERNAL_ADDR=:9090 previously failed to resolve; it now binds 127.0.0.1:9090. Action: to expose the internal server off-host, set an explicit INTERNAL_ADDR=0.0.0.0:9090 and pair it with INTERNAL_ALLOW_IPS.


Full changelog: CHANGELOG.md · v0.8.0...v0.9.0

0.8.0

Choose a tag to compare

@diolektor diolektor released this 13 Jun 10:36

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-Options set from PHP header() 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_SELF follow 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 Satisfiable with Content-Range: bytes */<size>.
  • If-Range is honoured with strong ETag comparison (and exact-date Last-Modified), so a changed file safely falls back to a full 200 instead of splicing a mismatched fragment.
  • Static ETags are now strong ("<size>-<mtime_hex>"); compressed representations carry a weakened W/"…" 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.

Static Files docs

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.

TLS / HTTP/2 docs

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 weak W/"…" tag. If-None-Match uses 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_INFO is set only for an explicit /index.php/extra request; SCRIPT_NAME always identifies the executed front controller.
  • Worker-mode routing follows the documented static-or-worker contract (see Upgrade).
  • PHP_DENY_PATHS now applies in SPA mode (which executes existing .php directly).

Fixed

  • Server security headers no longer overwrite application headers (see Highlights).
  • Custom error pages (ERROR_PAGES_DIR) no longer drop semantically required headers — Content-Range on a 416, Retry-After on a 529, Allow on a 405 survive 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'] (and DOCUMENT_URI/PHP_SELF) are correct in all routing modes — the old computation produced an empty SCRIPT_NAME in Framework mode and mis-sliced percent-encoded paths. PATH_INFO is now percent-decoded, consistent with PHP-FPM.
  • PHP_DENY_PATHS covers 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.4

All 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

Choose a tag to compare

@diolektor diolektor released this 04 Jun 22:46

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/console commands — no second PHP install. → Highlights
  • oxphp serve + implicit run + shebang — bare oxphp still starts the server unchanged; oxphp app.php is shorthand for oxphp run, and #!/usr/bin/env oxphp scripts run directly.
  • In-binary privilege drop with --user — bind :80 as root, then permanently drop to an unprivileged user before any request is served, with no orchestrator hook.
  • Trusted-proxy port headersX-Forwarded-Port now drives $_SERVER['SERVER_PORT']; RFC 7239 for=ip:port populates $_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_addrremote_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 beat php.ini for every directive type, including PHP_INI_SYSTEM / PHP_INI_PERDIR).
  • Shebang — a leading #! line is skipped, so an executable #!/usr/bin/env oxphp script 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), matching php.

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-Port now drives $_SERVER['SERVER_PORT'] and Request::port() (priority: X-Forwarded-Port → port suffix of the forwarded/Host header → 443/80 by scheme), so an ALB-on-8443 or proxy_set_header X-Forwarded-Port setup conveys its public port to PHP.
  • RFC 7239 Forwarded: for=ip:port populates $_SERVER['REMOTE_PORT'] with the client source port (it stays "0" otherwise — X-Forwarded-For carries no port).

Fixed

  • Two decorator-cache worker crashes (SIGSEGV in zend_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 cached zval* across before()/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 exportedSlow / 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 — a Shared\* returned from oxphp_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 the Iterator contract.
  • Request::file() / Request::files() now return uploaded files (were stubs) across all $_FILES shapes.

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

Verify 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.23

From source

git clone https://github.com/oxphp/oxphp.git
cd oxphp
git checkout v0.7.0
cargo build --release

Compatibility

  • PHP 8.5 (ZTS) — default, served by latest and unsuffixed tags.
  • PHP 8.4 (ZTS) — supported via :php8.4* tag aliases.
  • Alpine 3.23 base image.
  • Linux amd64 and arm64 images published.

Upgrade from 0.6.0

Drop-in apart from one breaking change in the JSON access log — every PHP-facing API is unchanged.

  1. Access-log field remote_addrremote_ip. The client-IP field is renamed and now carries the IP without a port (behind a trusted proxy it previously emitted a synthetic IP:0). Update any log pipeline / dashboard that keys on remote_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'].

  2. Bump your image tag to 0.7.0 (or latest).

That's it — no PHP code changes required.

Full changelog

See CHANGELOG.md · v0.6.0…v0.7.0 diff

0.6.0

Choose a tag to compare

@diolektor diolektor released this 27 May 23:41

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-typed RecvResult / SendResult, a generic Atomic + Ordering, a name-keyed Registry, rebuilt Flag / 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 treats 500 as "timeout".
  • Timeout API is now set_time_limit() / max_execution_timeoxphp_request_heartbeat() and the REQUEST_TIMEOUT_SECONDS env var are gone.
  • Base exceptions renamedAsync\ExceptionAsync\AsyncException, Shared\ExceptionShared\SharedException (subclasses keep their names).
  • OxPHP\Server\Worker instance methods dropped the get prefix; oxphp_async_await_any() now follows JavaScript Promise.any (the old "first settled" behaviour moved to oxphp_async_await_race()).
  • Operator action required — Shared* memory accounting now books per-entry overhead, so revisit OX_SHARED_MAX_BYTES; rename PHP_DENY_DIRSPHP_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\ExceptionAsync\AsyncException, Shared\ExceptionShared\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; $ms is 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 out

Mutex 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) { /*...*/ });           // ContentionException

The 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 (...

Read more

0.5.0

Choose a tag to compare

@diolektor diolektor released this 04 May 23:13

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();  // bool

Plus 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) or off / false / 0 / no (falsy), case-insensitive and trimmed. Typos like ture or aliases like enabled are 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 deprecated STATIC_CACHE_TTL shim) must parse as a number. STATIC_MAX_AGE=garbage fails at startup with an error naming the variable, where it previously silently dropped the Cache-Control header.

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 to WORKER_MAX_MEMORY_MIB for safety-net recycling, or to Worker::scheduleExit() for application-driven recycling.
  • INDEX_FILE / WORKER_FILEINDEX_FILE=...ENTRY_FILE=..., and WORKER_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_CACHESTATIC_CACHE_TTL=...STATIC_MAX_AGE=..., and STATIC_CACHE=offSTATIC_REVALIDATE=on. When both are set, the new ones win.

Internal

  • New benchmark tooling under scripts/: bench-wrk.sh (one-shot wrk runner) and sweep-config.sh (matrix sweep over TOKIO_WORKERS × PHP_WORKERS). Local-only, not wired into CI.
  • Dependency bumps: opentelemetry / opentelemetry_sdk / opentelemetry-otlp / opentelemetry-semantic-conventions 0.27 → 0.31, tonic 0.12 → 0.14 (now requires the explicit grpc-tonic feature on opentelemetry-otlp), rand 0.8 → 0.10, getrandom 0.2 → 0.4, reqwest 0.12 → 0.13, brotli 7 → 8, lru 0.12 → 0.18. No user-visible behavior change; OTel migration switches to SdkTracerProvider with with_batch_exporter, Resource::builder(), and the new force_flush() Result shape.

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

Verify 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.23

From source

git clone https://github.com/oxphp/oxphp.git
cd oxphp
git checkout v0.5.0
cargo build --release

Compatibility

  • PHP 8.5 (ZTS) — default, served by latest and unsuffixed tags.
  • PHP 8.4 (ZTS) — supported via :php8.4* tag aliases.
  • Alpine 3.23 base image.
  • Linux amd64 and arm64 images published.

Upgrade checklist

Two breaking changes around env-var parsing — audit your deployment env before upgrading.

  1. 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 of on / true / 1 / yes / off / false / 0 / no.
  2. Audit STATIC_MAX_AGE (and the deprecated STATIC_CACHE_TTL). Garbage values like STATIC_MAX_AGE=garbage previously silently dropped the Cache-Control header; they now fail at startup. Either set a numeric value (seconds) or unset the variable to fall back to the default.
  3. Migrate WORKER_MAX_REQUESTS. It is now parsed-and-ignored with a WARN. Switch to WORKER_MAX_MEMORY_MIB for safety-net recycling, or call Worker::scheduleExit() from your worker code for application-driven recycling.
  4. Migrate INDEX_FILE / WORKER_FILEENTRY_FILE / WORKER_MODE_ENABLED. Legacy forms still work (with a WARN) but will be removed.
  5. Migrate STATIC_CACHE_TTL / STATIC_CACHESTATIC_MAX_AGE / STATIC_REVALIDATE. Note the polarity flip: STATIC_CACHE=off becomes STATIC_REVALIDATE=on.
  6. If you scrape oxphp_worker_recycles_by_reason_total, update dashboards/alerts: the reason="max_requests" label is now reason="scheduled".
  7. If you read /config, the keys index_file / worker_file / worker_mode are replaced by entry_file / worker_mode_enabled.

Full changelog

See CHANGELOG.md · v0.4.0…v0.5.0 diff

0.4.0

Choose a tag to compare

@diolektor diolektor released this 02 May 19:09

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

Under 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-Encoding after the first oxphp_stream_flush on 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 seeds EG(current_fiber_context) to EG(main_fiber_context) at request startup) and inside a user-level Fiber::start() body (which installs its own zend_fiber_context distinct from main). The latter caused Shared\Channel::recv() / send() inside a user Fiber to take the fiber-suspend path, hit rc=1 from oxphp_bridge_fiber_await ("not in oxphp fiber"), and surface as RuntimeException: recv: fiber_await rc=1. The predicate now keys off the SAPI's private oxphp_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, and Shared\Pool handles arriving as null on the receiving worker thread when captured by an oxphp_async() closure (via use ($var) or as a variadic arg). The cross-thread wrapper rebuild in the C bridge listed only Counter / Flag / Once / Mutex in its tag→class switch, so the other three SharedType variants fell through to default and produced IS_NULL. The mapping now lives only in Rust (SharedType::php_class_cstr) and the C bridge calls a weak-linked oxphp_shared_class_name(type_tag) export, so adding a SharedType variant cannot drift again. Re-enables the shared/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" and ReflectionParameter::getName() returned an empty list. The bridge method/function registration now carries per-parameter name/type/optional arrays, and the SAPI extension synthesizes a full zend_internal_arg_info array 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 from blocking_lock() to a try_lock retry loop with a 5 s deadline that degrades to 503 on 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.23

Verify 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.23

From source

git clone https://github.com/oxphp/oxphp.git
cd oxphp
git checkout v0.4.0
cargo build --release

Compatibility

  • PHP 8.5 (ZTS) — default, served by latest and unsuffixed tags. SAPI_HEADER_DELETE_PREFIX available on 8.5.6+.
  • PHP 8.4 (ZTS) — supported via :php8.4* tag aliases.
  • Alpine 3.23 base image.
  • Linux amd64 and arm64 images published.

Upgrade checklist

No breaking changes. A drop-in upgrade from v0.3.0:

  1. Bump your image tag to 0.4.0-php8.5-alpine3.23 (or :latest / :php8.5 if you track aliases). To stay on 8.4, pin to :php8.4 or 0.4.0-php8.4-alpine3.23.
  2. If you previously tracked :latest while expecting PHP 8.4, switch to :php8.4 (or a version-pinned 8.4 tag) — latest now resolves to 8.5.
  3. 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.
  4. If you run oxphp_async() workers that capture Shared\Channel / Shared\Map / Shared\Pool handles via use ($var) or variadic args, those now arrive non-null on the receiving worker. Tests previously skipped due to this can be re-enabled.
  5. 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

See CHANGELOG.md · v0.3.0…v0.4.0 diff

0.3.0

Choose a tag to compare

@diolektor diolektor released this 21 Apr 23:02

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, reentrant init throws DeadlockException
  • Shared\Mutex — poisoning mutex with with($body, $timeout) / tryWith($body) scope guards
  • Shared\Channel — bounded MPMC queue with fiber-aware send / recv and sendMany / recvMany
  • Shared\Map — concurrent string → mixed store with batched ops and per-instance maxEntries
  • Shared\Pool — bounded object pool with lazy factory, strict maxSize, 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 xhgui Docker 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 keyword private), processes RFC 7239 Forwarded and X-Forwarded-* headers, and overrides REMOTE_ADDR / HTTPS / REQUEST_SCHEME / SERVER_NAME / SERVER_PORT for PHP using the rightmost-non-trusted algorithm
  • Kubernetes probes at /readyz and /livez with graceful-shutdown awareness
  • PATH_INFO splitting via SPLIT_PATH_INFO_ENABLED — nginx/PHP-FPM-style front-controller routing
  • PHP_DENY_DIRS to block .php execution in specified paths
  • Dot-path access blocked by default with RFC 8615 .well-known exception

Security headers

  • X-Content-Type-Options: nosniff on all responses
  • Configurable X-Frame-Options (FRAME_OPTIONS, default SAMEORIGIN) for clickjacking protection

Supply chain & packaging

  • Parametrized Docker image matrix — two Dockerfiles (dev + Dockerfile.alpine-release) sharing ARG PHP_VERSION / ARG ALPINE_VERSION / ARG BASE_IMAGE
  • Canonical minor-floating ({ver}-php{minor}-alpine{alpine}) and patch-pinned tags published to ghcr.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 php CLI, docker-php-ext-install, phpize, and www-data out 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 HEALTHCHECK wired into compose.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\AsyncExceptionOxPHP\Async\Exception
    • OxPHP\AsyncTimeoutExceptionOxPHP\Async\TimeoutException
    • OxPHP\AsyncBorrowExceptionOxPHP\Async\BorrowException
    • OxPHP\BorrowedProxyOxPHP\Async\BorrowedProxy
  • Async functions (oxphp_async, oxphp_async_await, …) are now provided by the plugin-async feature flag. Without it, the functions are not available. Function names are unchanged.
  • Plugin APIPlugin::shutdown now takes &mut self (was &self). Plugin authors must update implementations.
  • Plugin configenv::set_var side-effects from plugin init no longer propagate to the core server. Plugins must publish core-relevant flags through the explicit core-flags API.
  • RequestComplete event — string-serialized metadata replaced with typed fields.
  • Prod image USER policyDockerfile.alpine-release no longer sets a final USER, matching nginx:alpine / php-fpm:alpine / frankenphp:alpine conventions. Drop privileges at the orchestrator level: docker run --user www-data, Compose user:, Kubernetes runAsUser: 82. chown www-data:www-data /var/www/html still runs at build time.

Performance

  • Shared\Pool uncontested 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\Map set / get avoids serialisation for nested Shareable refs — 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 own max_execution_time timer 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=0 scripts 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 the worker_liveness hook for Map / Counter / Flag / Once / Mutex — only Pool uses thread-registration.
  • Async worker SIGBUS from cross-thread MAP_PTR access.
  • headers_list() returning empty — the header handler now returns SAPI_HEADER_ADD.
  • payload() returning null for JSON body after a PDO query reused the request buffer.
  • SecurityHeadersHandler env-variable race: FRAME_OPTIONS is now resolved at startup rather than read per request.
  • Decorator RejectedException dispatch and instance-cache collisions across requests.
  • -Wint-to-pointer-cast warning in bridge server_context assignment.
  • Default-feature (php) compile/clippy errors previously masked by the --no-default-features CI profile.
  • TLS test profile now generates v3 certificates dynamically and the runner supports HTTPS.
  • E2E runner curl_args parsing 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:latest

Verify 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.23

From source

git clone https://github.com/oxphp/oxphp.git
cd oxphp
git checkout v0.3.0
cargo build --release

Compatibility

  • 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.23 base image.
  • Linux amd64 and arm64 images published.

Upgrade checklist

  1. Audit PHP code for OxPHP\Async* class references and migrate to OxPHP\Async\*.
  2. If you consume async functions, enable the plugin-async Cargo feature (it is on by default in the published images).
  3. Third-party Rust plugins: update Plugin::shutdown(&self)Plugin::shutdown(&mut self) and move any env::set_var side...
Read more

0.2.0

Choose a tag to compare

@diolektor diolektor released this 27 Mar 16:56

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 ComplianceDate 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 8080 to 80 (auto-switches to 443 when TLS_CERT is set)
  • Backpressure response code changed from 503 to 529

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_context field in /config endpoint
  • workers_idle metric now correctly reports idle count in static pool mode
  • workers_spawned_total includes initial worker spawn

Static Files

  • STATIC_CACHE=off mode with mtime-based revalidation

Bug Fixes

  • SERVER_PROTOCOL reflects actual HTTP version (was hardcoded HTTP/1.1)
  • REQUEST_TIME returns 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_total metric 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

Choose a tag to compare

@diolektor diolektor released this 08 Mar 19:04

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/die in 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_unwind prevents 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