Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

Apache + mod_php Parity

ZealPHP targets verbatim Apache+mod_php behavior so that unmodified legacy applications (WordPress, Drupal, classic PHP) run unchanged. This document covers the parity layers: function overrides (ext-zealphp preferred, uopz fallback), public/ file-based routing, static-file handling, and the per-coroutine isolation that keeps mod_php's process-global behaviors from leaking across concurrent requests in an OpenSwoole worker.

For error handling (custom ErrorDocument, set_error_handler, register_shutdown_function, content negotiation), see error-handling.md.


Why parity matters

mod_php runs one PHP request per process (or per child); state is naturally isolated. OpenSwoole runs many coroutines per process — a single set of native PHP globals is shared. A naive port loses three properties legacy code silently depends on:

  1. Per-request global state. $_GET, $_SESSION, headers, cookies all become process-shared and leak between requests.
  2. Apache-specific built-ins. apache_request_headers(), getallheaders(), virtual(), apache_setenv() are undefined under the CLI SAPI and crash legacy code with "Call to undefined function."
  3. Per-request handler lifecycle. set_error_handler, register_shutdown_function, error_reporting() are process-global in PHP — one coroutine's call leaks into every other.

ZealPHP closes all three via function overrides backed by per-coroutine state in G.

Function overrides (ext-zealphp preferred, uopz fallback)

App::__construct() installs overrides on every built-in that legacy PHP code uses to interact with the HTTP boundary or the PHP error machinery. The private overrideBuiltin() helper at src/App.php:1195 uses ext-zealphp's zealphp_override() when available, and falls back to uopz_set_return() otherwise:

if (extension_loaded('zealphp') && function_exists('zealphp_override')) {
    (zealphp_override(...))($name, $cb);   // ext-zealphp — preferred
} elseif (function_exists('uopz_set_return')) {
    uopz_set_return($name, $cb, true);     // uopz — fallback
}

Each override delegates to a namespaced implementation in src/utils.php that mutates per-request G state instead of touching process globals.

Always present in CLI/OpenSwoole — overridden via ext-zealphp / uopz

Built-in Routes to Backing state
header() \ZealPHP\header() G->response_headers_list
header_remove() \ZealPHP\header_remove() G->response_headers_list
headers_list() \ZealPHP\headers_list() G->response_headers_list
headers_sent() \ZealPHP\headers_sent() G->openswoole_response->isWritable()
setcookie() \ZealPHP\setcookie() G->response_cookies_list
setrawcookie() \ZealPHP\setrawcookie() G->response_rawcookies_list
http_response_code() \ZealPHP\http_response_code() G->status
flush() \ZealPHP\flush() writes buffer to G->openswoole_response, flips G->_streaming = true
ob_flush() / ob_end_flush() namespaced equivalents identical to flush()
ob_implicit_flush() namespaced no-op — (ZealPHP buffers per request; the call is accepted without side effect)
set_time_limit() namespaced no-op — (OpenSwoole owns coroutine/worker timeouts)
ignore_user_abort() namespaced G->ignore_user_abort_state
connection_status() / connection_aborted() namespaced inspects G->openswoole_response->isWritable()
output_add_rewrite_var() / output_reset_rewrite_vars() namespaced no-op —
is_uploaded_file() namespaced whitelist of tmp_name paths from G->files
move_uploaded_file() namespaced is_uploaded_file() check + rename/copy+unlink fallback
session_* (18 functions) \ZealPHP\Session\zeal_session_* G->session, G->session_params
set_error_handler / restore_error_handler namespaced G->error_handlers_stack
set_exception_handler / restore_exception_handler namespaced G->exception_handlers_stack
register_shutdown_function() namespaced G->shutdown_functions queue
error_reporting() namespaced G->error_reporting_level

Apache-only built-ins — conditional global shims

apache_* functions, getallheaders(), and virtual() are defined by PHP only under mod_php. uopz cannot override what doesn't exist, so they're defined at global namespace by src/apache_shims.php — composer's files autoload loads it once per worker boot. Each shim is wrapped in if (!function_exists(...)) so it's a no-op when the host SAPI already provides the real function.

Built-in Delegates to Source
apache_request_headers() \ZealPHP\apache_request_headers() G->zealphp_request->parent->header (canonicalized)
getallheaders() alias of above —
apache_response_headers() \ZealPHP\apache_response_headers() G->response_headers_list
apache_setenv() / apache_getenv() namespaced G->apache_env
apache_note() namespaced G->apache_notes
virtual() namespaced no-op (returns false) logs "unsupported" once

The CGI worker (src/cgi_worker.php) has its own self-contained set of these for legacy files run via App::include() (formerly App::includeFile() — alias retained) in superglobals mode. The subprocess runs in a fresh PHP CLI process so native session_* works natively there — no override needed.

Boot order — process-level error dispatcher before overrides

App::__construct() installs ONE native set_error_handler and set_exception_handler before the first override is installed. After the overrides take effect, user-space set_error_handler() calls go to the namespaced override that records in G. Real PHP errors raised by the engine still flow through the bootstrap handler, which reads the current coroutine's G stack — giving per-coroutine isolation despite PHP's single global handler.

// Top of App::__construct() — captured BEFORE function overrides are installed
self::$initial_error_reporting = \error_reporting();

\set_error_handler(static function ($severity, $message, $file, $line) {
    $g = G::instance();
    $level = $g->error_reporting_level ?? App::$initial_error_reporting;
    if (!($severity & $level)) return true;          // suppressed
    $stack = $g->error_handlers_stack;
    if (!empty($stack)) {
        [$callable, $levels] = $stack[count($stack) - 1];
        if ($severity & $levels) return (bool)$callable($severity, $message, $file, $line);
    }
    return false;                                    // PHP default
});

error_reporting() is read inside this native dispatcher and is also per-coroutine — setting it in one request doesn't filter another's errors.


Public/ file-based routing

ZealPHP's implicit routes in src/App.php reproduce Apache's default DocumentRoot behavior, with safety hardening on top.

Implicit route table (registered in order)

Pattern Resolves to Apache equivalent
/\..* 403 dotfile block (allow .well-known/) <FilesMatch "^\.ht"> Require all denied </FilesMatch> + dotfile convention
/.*\.php 403 (when App::$ignore_php_ext = true) reject explicit .php URLs RewriteRule \.php$ - [F]
/ public/index.php DirectoryIndex index.php
/{file}/? public/{file}.php, then public/{file}/index.* MultiViews + DirectoryIndex
/{dir}/{uri}/? public/{dir}/{uri}.php, then directory index nested directory walk
(fallback) setFallback() handler or renderError(404) .htaccess RewriteRule . /index.php [L]

Apache parity additions

Apache directive ZealPHP implementation Flag
DirectorySlash On /foo → 301 /foo/ when foo is a directory App::$directory_slash = true
DirectoryIndex index.php index.html index.htm walks App::$directory_index array; .html/.htm go through $response->sendFile() so Range/ETag work App::$directory_index (array)
AcceptPathInfo On /script.php/extra/path exposes PATH_INFO=/extra/path, rewrites REQUEST_URI to /script.php App::$path_info = true
<FilesMatch "^\."> deny every implicit route + dedicated pattern route refuses any URL with a dotfile component App::$block_dotfiles = true
URL-decoded traversal rejection parse_url + rawurldecode checked for .., \0, backslash before route matching → 400 always on
Static handler URL whitelist OpenSwoole's enable_static_handler is constrained to App::$static_handler_locations (default applied by run() when unset: /css, /js, /img, /images, /fonts, /assets, /static, /favicon.ico, /robots.txt) App::$static_handler_locations

The static-handler whitelist is the real defense against /.env or /.git/config being served. OpenSwoole's built-in handler doesn't honor <FilesMatch> rules; by restricting it to known asset prefixes, anything outside the whitelist falls through to PHP routing, where dotfile and traversal checks fire.

serveDirectory() helper

App::serveDirectory($relDir, $urlPrefix) is the shared body of both implicit routes' is_dir() branches:

  1. Apply DirectorySlash 301 if URI has no trailing slash.
  2. Walk App::$directory_index until a file is found.
  3. .php entries run via App::include() — file return value flows through the universal return contract (supports int status, array JSON, string HTML, Generator streaming, Closure with param injection, void+echo buffered body).
  4. Non-.php entries (HTML, HTM) go via $response->sendFile() so Range and ETag still work.
  5. Return false if nothing matches — caller falls through to fallback / 404.

sendFile() conditional GET

Response::sendFile() emits weak ETag (W/"mtime-size") plus Last-Modified, and honors If-None-Match and If-Modified-Since headers:

  • Match on If-None-Match: W/"...";, If-None-Match: *, or If-None-Match stripped of weak prefix → 304.
  • Match on If-Modified-Since ≥ file mtime → 304.
  • Range request preserved (single + multi-range, RFC 7233) — but If-Range is honored only inside RangeMiddleware's buffered path, not in sendFile itself.

App::includeCheck() hardening

- abs_file must start with cwd/public  (path traversal cage)
- no path component may begin with '.' (dotfile block) unless flag disabled

Both checks are necessary: traversal cage prevents /foo/../etc/passwd-style escapes; dotfile rule prevents serving public/wp/.env even when the path is within document root.


Static file handling

By default OpenSwoole serves static files via its built-in handler — that's why /css/zealphp.css works without any PHP route. The handler emits Last-Modified only — no ETag, no Range support. Two consequences:

  • For ETag/Range on static files, disable enable_static_handler and add a wildcard PHP route that calls $response->sendFile($abs). The handler in sendFile adds all conditional GET headers and Range handling.
  • For dotfile protection, the static_handler_locations whitelist (set by ZealPHP defaults) restricts the built-in handler to safe URL prefixes. Dotfiles fall through to PHP routing where the dotfile block fires.

The trade-off: zero-copy sendfile() on a sub-process boundary is faster than PHP→OpenSwoole bytes, so for purely static CSS/JS/images the built-in handler is the right default. For asset CDNs and Range-heavy workloads, route through PHP.


CGI subprocess for legacy apps

When App::$superglobals = true, App::include($publicPath) dispatches to a subprocess that provides true global scope, native sessions, and an isolated request lifecycle. The CGI worker (src/cgi_worker.php) installs its own overrides (ext-zealphp / uopz, same preference order) for:

  • header / header_remove / headers_list / headers_sent
  • setcookie / setrawcookie / http_response_code
  • flush / ob_flush / ob_end_flush / ob_implicit_flush
  • is_uploaded_file / move_uploaded_file

Plus self-contained global shims for apache_*, getallheaders, virtual (since composer's autoloader isn't loaded in the subprocess).

Session functions are NOT overridden in CGI — each subprocess gets a real PHP process with native $_SESSION semantics. The main worker overrides 18 session_* functions because there it shares one PHP process across many coroutines; the CGI worker doesn't.

The CGI worker communicates a two-channel protocol back to the parent:

  • stderr line 1 = JSON metadata (status, headers, cookies) sent FIRST.
  • stdout = body bytes streamed (supports SSE, chunked output).

The parent reads metadata, forwards headers/cookies to its OpenSwoole response, then proxies stdout. Public entry point is App::include() (or its deprecated alias App::includeFile()); see src/App.php.

The dispatch is two-tiered:

  1. App::processIsolation() (default true in superglobals mode) gates fork-vs-in-process. false → runs in-process via App::executeFile().
  2. When process isolation is on, App::cgiMode('pool'|'proc'|'fork'|'fcgi') selects the fork strategy:
    • 'pool' (default, ~1–3 ms warm) — a per-worker pre-spawned subprocess pool (src/CGI/WorkerPool.php), mod_php-style isolation without a cold-start cost. Configure via App::cgiPoolSize() / App::cgiPoolMaxRequests().
    • 'proc' (~30–50 ms cold) — fresh proc_open subprocess per request; the recursion-safe fallback for rare full-isolation cases.
    • 'fork' (EXPERIMENTAL, ~1 ms fork cost) — Apache MPM prefork runner: a long-lived fork-master (src/fork_master.php) forks a fresh child per request at true global scope. Unmodified-WordPress correctness without the proc_open cold-start cost. Requires pcntl+posix. .php-only.
    • 'fcgi' — forward to an external FastCGI upstream (php-fpm, hhvm, roadrunner).

App::mode(App::MODE_LEGACY_CGI) sets superglobals(true) + isolation(cgi-pool) in one call, which resolves to the warm pool by default. See Lifecycle modes for the full preset table.


Lifecycle mode — choosing the right parity level

ZealPHP ships four one-call presets via App::mode(string). For Apache + mod_php parity the two relevant ones are:

Preset What it gives you
App::mode(App::MODE_LEGACY_CGI) superglobals(true) + warm CGI pool (~1–3 ms). True global scope per request. Correct home for unmodified WordPress / Drupal / apps that use pure require_once with no Composer autoloader.
App::mode(App::MODE_COROUTINE_LEGACY) superglobals(true) + coroutine concurrency + per-coroutine isolation of the request-state primitives ($_* superglobals, header()/setcookie(), $GLOBALS including object-valued, function-local static $x, ini_set, require_once re-execution). Requires ext-zealphp. (define() isolation is a separate opt-in via App::defineIsolation(true), not auto-enabled by the preset.) Statics (function-local and class) of code defined before the server starts (in app.php, or preloaded) are boot state and are shared by every request in the worker; only code loaded during a request is reset per request. Correct home for modern Composer apps (Symfony, Laravel, Slim) that need real $_SESSION and concurrent request handling.

For the full preset matrix (including 'coroutine' and 'mixed') and the orthogonal App::isolation() axis, see Lifecycle modes.


Per-coroutine state isolation

State Per-process (mod_php) Per-coroutine in ZealPHP via Override site
$_GET / $_POST / $_SERVER / $_SESSION yes G proxy (superglobals=false) App::superglobals(false)
Headers / cookies / status yes G->response_headers_list etc. overrideBuiltin() (ext-zealphp / uopz)
Error handler yes (single) G->error_handlers_stack overrideBuiltin() + native bootstrap dispatcher
Exception handler yes G->exception_handlers_stack overrideBuiltin() + dispatchRoute catch
Shutdown functions per process G->shutdown_functions queue overrideBuiltin() + on('request') drain
error_reporting() level per process G->error_reporting_level overrideBuiltin() + native dispatcher reads G
Apache notes / env per process G->apache_env, G->apache_notes namespaced impls

For details on the error/exception/shutdown flow specifically, see error-handling.md.


Verification

Two integration test suites cover this surface:

  • tests/Integration/ApacheParityTest.php — apache_request_headers, getallheaders, header_remove, headers_sent, setrawcookie, ob_flush, status-line forms, apache_setenv/note, upload-file helpers, safe stubs.
  • tests/Integration/PublicRoutingTest.php — DirectorySlash 301, DirectoryIndex .html fallback, dotfile block (root + subdir), well-known passthrough, URL-decoded traversal 400, null byte 400, PATH_INFO exposure, sendFile ETag, conditional GET (If-None-Match + If-Modified-Since), Range request 206.

Manual smoke against a running server:

curl -I http://localhost:8080/css/zealphp.css                                # ETag, Last-Modified
curl -I http://localhost:8080/parity-test/sub-dir                            # 301 → /parity-test/sub-dir/
curl -I http://localhost:8080/.env                                           # 403
curl -I 'http://localhost:8080/%2e%2e/foo'                                   # 400
curl http://localhost:8080/api.php/users/42                                  # PATH_INFO routed to api.php

Other-language CGI (mod_cgi / ScriptAlias parity)

Apache mod_cgi and ScriptAlias parity — running Python, Ruby, shell scripts, or any shebanged executable — is also supported and works in coroutine mode as well as superglobals mode:

  • App::registerCgiBackend('.py', ['mode' => 'proc', 'interpreter' => '/usr/bin/python3', 'exec_paths' => ['/cgi-bin']]) — registers a per-extension backend. Only files under exec_paths are executed (Apache Options +ExecCGI parity); others return 403.
  • App::cgiScriptAlias('/cgi-bin', ['mode' => 'proc']) — Apache ScriptAlias parity: any file under the prefix executes via its #! shebang line without a per-extension registration.

For full details, configuration options, and FastCGI upstream support, see docs/fastcgi-backends.md.

Out of scope

  • trigger_error(E_USER_ERROR) halting the script — would require uopz on trigger_error itself.
  • Async-error capture from App::tick/App::after timers — they run outside a request context, so renderError doesn't apply.
  • ini_set('display_errors', ...) per request — process-wide flag; use App::$display_errors.
  • Apache <Directory> ACL semantics — ZealPHP ships sensible defaults rather than a configurable ACL grammar.
  • apache_child_terminate(), apache_lookup_uri(), apache_reset_timeout() — exotic functions; no real app uses them.