Running Legacy PHP Apps
WordPress compatibility showcase: front page, login, posts, and REST API working (the WP-admin dashboard has documented limits under coroutine concurrency) through the ZealPHP CGI worker bridge in compatibility mode — with documented limits. The bridge exists so traditional PHP code that assumes a fresh process per request (WordPress, Drupal, define()-heavy plugins) can run on OpenSwoole's long-lived workers via a warm, pre-spawned PHP worker pool (App::cgiMode('pool')) — the same FPM-style model, with the interpreter resident in memory between requests. The same app.php works for Drupal, Laravel-on-FPM-shape, and other traditional PHP applications.
Compatibility-mode story. In the showcase deploy, WordPress runs without source patches: login, sessions, cookies, redirects, file uploads, REST API, and pretty permalinks all work through the CGI worker bridge (App::mode(App::MODE_LEGACY_CGI) or the equivalent App::superglobals(true)). The default isolation backend is the warm CGI pool (cgiMode('pool'), ~1–3 ms warm — a pre-spawned, FPM-style PHP worker pool that stays resident in memory, not a per-request fork). For apps like WordPress that cache bootstrap files via require_once, pair the pool with App::cgiPoolMaxRequests(1) so each pool worker recycles after one request — fresh-interpreter semantics with the pool's pre-spawn warm-up still in front. The alternative backend is cgiMode('fcgi'), which forwards every public/*.php to an external FastCGI / php-fpm pool. See known limits before betting your production WordPress on it.
50-app compatibility sweep — May 2026
We ran a structured compatibility sweep against 50 popular PHP applications on Docker lab (PHP 8.4.21 + OpenSwoole 26.2 + ext-zealphp 0.3.25), testing each across all four production modes. Full results in docs/compatibility-database.md.
Apps that PASS in ALL 4 modes (no modifications needed)
Five apps were tested 5× per mode (20 requests total, sequential + concurrent) and passed every request:
- Kanboard — project management — zealphp-kanboard
- Joomla — major CMS (Symfony-based) — zealphp-joomla
- Roundcube — webmail — zealphp-roundcube
- OpenCart — e-commerce — zealphp-opencart
- Adminer (with
functionIsolation(true)) — DB admin — zealphp-adminer
App-by-app summary (Mode 1 = CGI Pool, Mode 3 = Sync + functionIsolation)
| App | Category | Mode 1 (CGI) | Mode 3 (Sync+FI) | Best mode |
|---|---|---|---|---|
| WordPress | CMS | ✅ 3/3 | partial | Mode 1 |
| Joomla | CMS | ✅ 3/3 | ✅ 5/5 | Any |
| Kanboard | Project mgmt | ✅ 3/3 | ✅ 5/5 | Any |
| OpenCart | E-commerce | ✅ 3/3 | ✅ 5/5 | Any |
| Roundcube | Webmail | ✅ 3/3 | ✅ 5/5 | Any |
| Adminer | DB admin | ✅ 3/3 | ✅ 10/10* | Any (Mode 3 needs functionIsolation) |
| TinyFileManager | File mgr | ✅ 3/3 | ✅ 10/10* | Any (Mode 3 needs functionIsolation) |
| FreshRSS | RSS reader | ✅ 3/3 | ✅ 5/5* | Any (Mode 3 needs functionIsolation) |
| Cacti | Net monitoring | ✅ 3/3 | — | Mode 1 (relative includes) |
| Matomo | Analytics | ✅ 3/3 | partial | Mode 1 |
| DokuWiki | Wiki | partial | ✅ 5/5 | Mode 3 |
| phpMyAdmin | DB admin | — | partial | Mode 5 (coroutine, async MySQL) |
* with App::functionIsolation(true) — ext-zealphp 0.3.5+ feature that snapshots CG(function_table) + CG(class_table) at worker boot and cleans request-scoped additions at request end. Auto-skipped when the app registers its own Composer autoloader from documentRoot.
The two breakthroughs the sweep drove
App::functionIsolation(true)— fixes the classic "Cannot redeclare function" crash that breaks single-file legacy PHP apps (Adminer, TinyFileManager, FreshRSS) on long-running servers. Each request gets fresh function/class state via ext-zealphp's C-level cleanup. ~3ms overhead.- CGI Pool
chdir()fix — Apache mod_php sets the working directory to the script's directory before each include. ZealPHP's pool worker now does the same. Fixes Cacti, OpenCart, and other apps using relative includes (require './include/auth.php').
Known limitations — things ZealPHP won't do
Before going deep, do the 30-second dealbreaker scan. If your app depends on any of the categories marked ❌ below and you can't put a front proxy in front of ZealPHP, this isn't the right runtime. If you pass this gate, the porting story is clean — keep reading.
Apache-side features not supported
| Feature | Why not | Workaround if any |
|---|---|---|
Server-Side Includes (SSI) — Options +Includes, XBitHack, .shtml parsing, <!--#include --> | SSI was Apache's pre-PHP templating system. Anyone porting an SSI site is replacing it with PHP anyway. | Use App::render() / App::include() — that's what they do. |
mod_speling — CheckSpelling, CheckCaseOnly, fuzzy URL matching for typos | Security-questionable (cache pollution, info disclosure), low-value, low-usage. | Send a real 404 and let users retype. |
mod_imagemap — server-side <map> files | Dead since ~1995. Browsers do client-side imagemaps. | Use HTML <map> / <area> in templates. |
| mod_dav — WebDAV (PROPFIND, MKCOL, etc.) | Different protocol scope; ZealPHP is an HTTP framework, not a file server. | Use a dedicated WebDAV server (Nextcloud, Apache mod_dav). |
| mod_perl, mod_python, mod_ruby | ZealPHP is a PHP framework. | Run those languages in their own runtimes. |
| mod_isapi (Windows IIS extensions) | Windows-IIS-only API; OpenSwoole is Linux-first. | N/A — port the underlying logic to PHP. |
mod_lua hooks — LuaHook*, LuaMapHandler, etc. | Apache's scriptable hook layer. PSR-15 middleware is the native equivalent. | Write a PSR-15 middleware. |
CERN meta files — MetaDir, MetaFiles, MetaSuffix | Dead since ~1996. | Use the built-in HeaderMiddleware to attach response headers. |
| mod_status, mod_info (server-info / server-status pages) | Built-in observability lands in v0.3. | Roll your own /metrics route in the meantime. |
| mod_proxy_balancer (load balancing) | Out of scope. | Put HAProxy / Nginx / Caddy in front. |
| AuthLDAP* (LDAP authentication) | Niche in PHP apps; the standard PHP LDAP extension is the integration path. | Custom middleware using PHP's ldap_* functions. |
| AuthDigest* (HTTP Digest Auth) | Largely replaced by Bearer/Cookie auth over TLS. Browser support is patchy. | HTTPS + Basic Auth, or token-based auth. |
Full mod_autoindex customisation — AddIcon, AddAlt, IndexStyleSheet, HeaderName, ReadmeName | Basic directory listing is on the roadmap (opt-in only). Apache's icon/description customisation surface is niche and design-heavy. | Override template/_autoindex.php in your project for custom rendering when basic autoindex ships. |
nginx-side features not supported (or partial)
| Feature | Why / Status | Workaround |
|---|---|---|
Name-based virtual hosts — multiple server { server_name a.com b.com; } blocks | ⚠ Partial. One ZealPHP instance serves all Host values. | Host-routing middleware that dispatches on $g->server['HTTP_HOST'], OR run one ZealPHP instance per host behind Caddy/Traefik. |
proxy_pass (reverse proxy) | ⚠ Not built-in. ZealPHP is an origin server, not a proxy. | Put Caddy/Traefik/Nginx in front, OR write a small handler that uses OpenSwoole's HTTP client to forward. |
X-Accel-Redirect / X-Sendfile | Different model — ZealPHP IS the origin. | Return $response->sendFile($protectedPath) directly from the authorised handler (uses kernel sendfile). |
limit_rate (response bandwidth throttle) | ⚠ Not built-in. limit_req and limit_conn ARE shipped — see RateLimitMiddleware + ConcurrencyLimitMiddleware. | 5-line response wrapper: $response->write($chunk); OpenSwoole\Coroutine::sleep($delay); between chunks. |
early_hints (HTTP 103) | ⚠ Not implemented. Niche browser feature. | Defer; revisit if demand emerges. |
directio (O_DIRECT) | ⚠ OpenSwoole doesn't expose O_DIRECT. | Rely on filesystem cache; for huge files use $response->sendFile(). |
stream { … } block (L4 TCP/UDP proxy) | Different protocol scope. | Use HAProxy or sniproxy. |
mail { … } block (SMTP/IMAP proxy) | Different protocol scope. | Postfix / Dovecot. |
grpc_pass (gRPC proxy) | Out of scope. | Envoy or a real gRPC server. |
ZealPHP internal limitations
| Feature | Why | Workaround |
|---|---|---|
coprocess() in coroutine mode | Process-spawning isn't safe inside a coroutine; the API throws Exception (src/utils.php:312). Intentional. | Use native coroutines (go()) for parallelism in coroutine mode. |
App::include() Closure return with param injection in subprocess mode (superglobals=true) | Reflection-driven param injection doesn't survive the process boundary. | Use coroutine mode for Closure returns, or have the closure invoke itself within the included file. |
Multi-port HTTP/HTTPS on a single instance (listen 80; listen 443 ssl;) | OpenSwoole binds one listening socket per server. | Run two instances behind a proxy that does TLS termination + HTTP→HTTPS redirect, OR investigate Server::addListener(). |
| HTTP/3 (QUIC) | Not yet supported by OpenSwoole. | Use a front proxy (Caddy supports HTTP/3); ZealPHP serves over HTTP/1.1 or HTTP/2 internally. |
Headline: the dealbreakers list is short and the items on it are either (a) dead tech nobody uses anymore (SSI, imagemaps, CERN meta files), (b) protocol-scope mismatches that belong to dedicated servers (WebDAV, SMTP, L4 proxy), or (c) features intentionally delegated to a front proxy (multi-host TLS, load balancing, HTTP/3, reverse proxy). For the ~95% of PHP apps that don't depend on these, the migration is clean.
Migration ergonomics — one-liner Apache parity
Earlier ZealPHP releases needed a 5-line boot preamble to set $_SERVER globals before serving a legacy file. App::include() owns that preamble now: leading slash optional, Apache-document-root convention (paths are relative to public/), and the framework auto-populates $_SERVER['PHP_SELF'] / SCRIPT_NAME / SCRIPT_FILENAME exactly as Apache's mod_php does.
$app->setFallback(function () {
$g = RequestContext::instance();
$g->server['PHP_SELF'] = '/index.php';
$g->server['SCRIPT_NAME'] = '/index.php';
$g->server['SCRIPT_FILENAME'] =
App::$cwd . '/public/index.php';
App::includeFile(
App::$cwd . '/public/index.php'
);
});
$app->setFallback(fn() => App::include('/index.php'));
// Paths are relative to public/ (Apache DocumentRoot
// convention) — leading slash optional. The framework
// auto-populates $_SERVER preamble, blocks traversal
// outside public/, and honours the universal return
// contract — see /responses#return-contract.
How It Works
Three framework features enable legacy app compatibility:
| Feature | What it does | Apache equivalent |
|---|---|---|
App::superglobals(true) |
$_GET, $_POST, $_SERVER, $_SESSION, $_COOKIE work as expected. See the $g vs $_* parity rule for the cross-mode story. |
mod_php (default behavior) |
App::$ignore_php_ext = false |
Allows .php extensions in URLs (/wp-login.php, /admin/edit.php) |
AddHandler php-script .php |
App::include()(was App::includeFile() — deprecated alias) |
Runs a PHP file from public/ through the framework. With processIsolation(true), dispatches through the cgiMode() backend (default 'pool' — warm FPM-style worker pool; see below for measured cost); with processIsolation(false), runs in-process via executeFile(). Either way, the file's return value flows through the universal return contract. |
mod_prefork MPM + CGI / PHP-FPM |
Auto-$_SERVER preamble |
App::include() populates $_SERVER['PHP_SELF'], SCRIPT_NAME, SCRIPT_FILENAME for the included file before invoking it. |
mod_php's automatic CGI environment |
<?php
require 'vendor/autoload.php';
use ZealPHP\App;
// One-call preset: superglobals(true) + isolation(CgiPool) + cgiMode('pool')
// Use this for unmodified WordPress/Drupal and other require_once-bootstrap apps.
App::mode(App::MODE_LEGACY_CGI);
App::$ignore_php_ext = false;
$app = App::init('0.0.0.0', 8080);
$app->run(['task_worker_num' => 0]);
// PHP files in public/ are served automatically via the warm CGI pool
Lifecycle presets — App::mode(). The framework exposes two orthogonal axes (App::superglobals(bool) and App::isolation()) plus a one-call preset that sets both at once. For legacy-app hosting the two most relevant presets are:
| Preset | What it sets | Use for |
|---|---|---|
App::mode(App::MODE_LEGACY_CGI) |
superglobals(true) + isolation(CgiPool) — warm pre-spawned subprocess pool, ~1–3 ms |
Unmodified WordPress/Drupal — pure require_once apps needing true global-scope isolation per request |
App::mode(App::MODE_COROUTINE_LEGACY) |
superglobals(true) (S1) + isolation(Coroutine) + the per-coroutine isolation stack: silentRedeclare (S3) + includeIsolation (S7) + coroutineGlobalsIsolation (S2) + coroutineStaticsIsolation (S5a), plus the process-setting stages (S9) and per-request resets (S11) |
Modern Composer apps (Symfony/Laravel/Slim) or require_once-bootstrap apps run concurrently; requires ext-zealphp. Stage names (S1–S12) follow the canonical taxonomy in docs/architecture/isolation-stages.md. |
The older knob-by-knob form (App::superglobals(true) + App::processIsolation(true) + App::cgiMode(...)) still works, but App::mode() is the recommended one-liner shorthand on top. The examples below show the App::mode() syntax. Full lifecycle matrix: coroutines › lifecycle modes.
WordPress — tested end-to-end
The companion repo sibidharan/zealphp-wordpress ships an unmodified WordPress 6.7.1 install bound to ZealPHP via a ~30-line app.php (full WP frontend, wp-admin, REST API, plugin system, htaccess-equivalent rewrites). Tested live against this branch in Chrome:
| Configuration | Result | Notes |
|---|---|---|
cgiMode('pool') + App::cgiPoolMaxRequests(1)(recommended for WordPress) |
200 + 50,719 B on every request, ~175 ms warm | 5 / 5 requests render the full WP homepage (Blog title, posts, Sample Page link). Each pool worker recycles after 1 request → WordPress's require_once bootstrap re-runs clean, while the parent keeps a queue of pre-spawned workers so first-request latency stays bounded. |
cgiMode('pool') + App::cgiSubprocessAutoload(true) |
First request OK, 2nd+ deadlock | Loading vendor/autoload.php in every pool worker adds ~30 ms at spawn, which makes WordPress's wp_cron() 10 ms non-blocking POST queue up at the parent faster than workers can drain. Only opt in if your public/*.php needs \ZealPHP\App inside the subprocess. |
cgiMode('pool') (default cgiPoolMaxRequests(500)) |
First request OK (50 KB), 2nd+ return 500 fatal ("Call to a member function main() on null") | WordPress holds $wp (the framework instance) as a global; the pool worker's FPM-style cleanup correctly clears request-scope globals between requests, BUT WordPress's require_once chain caches the bootstrap files (wp-load.php, wp-settings.php) so they don't re-execute on the 2nd request → $wp never gets re-instantiated → null. PHP's require_once is not un-resettable in standard PHP. Drop cgiPoolMaxRequests(1) (the row above) to recycle every request. |
cgiMode('fork') + App::cgiMode('fork')(experimental — Apache MPM prefork runner) |
200 on every request | A long-lived fork-master (src/fork_master.php) binds a UNIX socket and forks a fresh child per request that runs the target .php at true global scope and hard-exits. Gives fresh-process correctness (no "Cannot redeclare class") without the ~30–50 ms proc_open overhead of cgiMode('proc'); fork cost is ~1 ms. No App::mode() preset — set via App::cgiMode('fork') directly. Requires pcntl + posix in the PHP build. Concurrency capped by App::$cgi_fork_max_concurrent (default 16); returns 503 when full. |
| cgiMode('fcgi') → external php-fpm pool | 200 + 50,719 B on every request | Forward every public/*.php to a php-fpm pool you already run; FPM keeps interpreters warm and recycles them per its own pm.max_requests. ZealPHP becomes the HTTP/WebSocket/coroutine layer in front. See Framework-wide cgiMode('fcgi') below. |
One-line WP config — drop this in your app.php and unmodified WordPress works:
<?php
require 'vendor/autoload.php';
use ZealPHP\App;
App::mode(App::MODE_LEGACY_CGI); // mod_php-style superglobals + warm FPM-style worker pool
App::cgiPoolMaxRequests(1); // recycle each request — WP needs a fresh boot
// App::cgiSubprocessAutoload(false); // default — DO NOT enable for WP
App::$ignore_php_ext = false; // allow /wp-login.php in URLs
$app = App::init('0.0.0.0', 9501);
$app->setFallback(function() { // WP front controller
App::include('/index.php');
});
$app->run(['task_worker_num' => 0]);
Pool mode for WordPress — set cgiPoolMaxRequests(1)
WordPress needs a fresh interpreter per request because its require_once bootstrap chain can't be re-run inside an already-warm worker. Pair the default pool with App::cgiPoolMaxRequests(1) to get exactly that: every pool subprocess exits after one request and the parent respawns it from the pre-spawned queue — fresh-process semantics, but the parent always has a warm worker ready to receive the next request (so you keep the pool's bounded first-request latency):
<?php
require 'vendor/autoload.php';
use ZealPHP\App;
App::mode(App::MODE_LEGACY_CGI);
App::cgiPoolSize(8); // 8 subprocesses pre-spawned per HTTP worker
App::cgiPoolMaxRequests(1); // recycle after EVERY request — WP needs fresh boot
App::$ignore_php_ext = false;
$app = App::init('0.0.0.0', 9501);
$app->setFallback(function() { App::include('/index.php'); });
$app->run();
Experimental: Concurrent WordPress via App::MODE_COROUTINE_LEGACY
If you want to run WordPress concurrently using coroutines instead of a worker pool, you can use the experimental App::MODE_COROUTINE_LEGACY mode. This requires ext-zealphp to isolate superglobals, statics, and require_once state per coroutine natively.
<?php
require 'vendor/autoload.php';
use ZealPHP\App;
// Requires ext-zealphp to be installed and loaded!
App::mode(App::MODE_COROUTINE_LEGACY);
App::$ignore_php_ext = false;
$app = App::init('0.0.0.0', 9501);
$app->setFallback(function() { App::include('/index.php'); });
$app->run();
The framework's pool_worker.php already does FPM-style $GLOBALS snapshot/restore between requests — clears $wp_did_header, $wpdb, $wp_query, etc. — but WordPress's require_once chain caches the bootstrap (wp-load.php → wp-settings.php → new WP() assignments) and PHP can't un-cache require_once entries in standard PHP. Recycling the subprocess every request bypasses this by getting a fresh interpreter where require_once is uncached.
Why cgiSubprocessAutoload(false) is the default
Issue #18 (the v0.2.41 WP autoload regression). Between v0.2.0 and v0.2.20, cgi_worker.php gained a require_once vendor/autoload.php at startup so apps that needed \ZealPHP\App inside the subprocess could use it (issue #17). That autoload load measures ~30 ms per worker spawn on a Ryzen 9 7900X.
For modern apps that don't make 10 ms-timeout self-calls, 30 ms is invisible. But WordPress's wp_cron() fires a non-blocking POST /wp-cron.php with timeout = 0.01. When a recycled pool worker (under cgiPoolMaxRequests(1)) takes longer to warm up than the wp-cron client's timeout window allows, the POST connection arrives at the parent OpenSwoole worker before any pool worker is free to accept it, accumulating as half-closed sockets. By the 2nd request, the pool is fully blocked.
The fix: gate the autoload on App::cgiSubprocessAutoload(true) — default off — keeping the pool worker's startup path lean. Apps that need ZealPHP classes inside CGI dispatch (rare — most legacy apps ship their own bootstrap) opt in. Pinned by tests/Unit/CgiSubprocessAutoloadTest.php (5 tests including a source-level canary against future regressions).
What cgiMode('pool') buys you — measured
cgiMode('pool') is the default since v0.2.41 — the FPM-style warm worker pool: a fixed set of PHP interpreters that stay resident in memory and are reused across requests. The numbers below are measured on this machine (Ryzen 9 7900X, OpenSwoole 26.2, PHP 8.3) so you can compare them directly to the bench results elsewhere in the docs.
| Path | Throughput | Per-request latency | What we measured |
|---|---|---|---|
| Coroutine mode HTTP /json route, no CGI dispatch |
13,210 req/s | 0.76 ms (mean) | Full HTTP stack: middleware + route handler + JSON encode. ab -n 500 -c 10 against the live demo server. This is the "modern route" baseline. |
| cgiMode('pool') direct 4 workers, fixture echo "ok"; |
10,983 req/s | 0.091 ms avg p50 0.028 ms · p99 0.047 ms |
scripts/bench-fcgi-pool.php — drives WorkerPool::dispatch() directly with 1000 requests, no HTTP overhead. Measures pure pool round-trip cost (write frame to a warm worker, it executes, read response). |
Reading the numbers: the pool itself adds ~90 microseconds of dispatch overhead per request (p50 = 28 μs, p99 = 47 μs) — the cost of handing the request to an already-warm interpreter and reading its response back. That overhead vanishes inside any non-trivial PHP file — by the time WordPress's autoloader has run, the ~90 μs IPC cost is invisible. The whole point of the pool is that the PHP interpreter never leaves memory: there is no per-request interpreter spin-up, so legacy public/*.php dispatch lands within a small constant of the modern coroutine route.
Reproduce locally:
# Pool — warm FPM-style workers, IPC-only dispatch
php scripts/bench-fcgi-pool.php 1000 4 500
# Args: <requests> <pool-workers> <recycle-after-N-requests>
# Fixture is `echo "ok";` so you measure pool round-trip, not app code.
vs PHP-FPM: same semantic model (warm worker pool, recycled after N requests), so per-request cost is in the same ballpark as your FPM pool — minus the FastCGI socket hop and the separate web-server bridge, since ZealPHP's HTTP server is built in. /vs-fpm covers the apples-to-apples measurement story. The honest claim is "FPM-equivalent semantics + ZealPHP-managed (one less daemon to install, no HTTP↔FastCGI bridge)."
What you actually get from pool mode (beyond the bench numbers)
- True global-scope isolation per request. Each pool subprocess is a fresh exec'd PHP process (NOT a fork of the HTTP worker), so it doesn't inherit ZealPHP's autoloader, classes, or memory.
define()-heavy plugins, classes that re-declare in plugin updates, function tables that grow per request — none of it leaks. - FPM-style recycling.
cgiPoolMaxRequests(N)(default 500) makes each pool worker exit cleanly after N requests; the parent respawns it. Defense against slow memory leaks in user PHP that would otherwise compound over millions of requests. - Per-HTTP-worker private pools. No global IPC bottleneck — each OpenSwoole HTTP worker owns its own
cgiPoolSizesubprocesses with aCoroutine\Channelqueue. Scales with worker count. - Real
$_GET/$_POST/$_SERVER/$_COOKIEinside the subprocess. Pinned by 5 unit tests intests/Unit/CGI/CgiPoolDispatchTest.php(testPostSuperglobalReachesSubprocess, etc.). - Auto-respawn on crash.
proc_get_statuspolled before each dispatch; dead workers are replaced transparently.
The hybrid: parent runs coroutines, public/*.php gets isolation
The mode-table on the coroutines page documents this as Mode 6 — Coroutine + Process Isolation. It's the "best of both worlds" combo you can opt into when you want the modern app to be concurrent AND have occasional legacy isolated PHP:
<?php
require 'vendor/autoload.php';
use ZealPHP\App;
// Modern app — per-coroutine $g in the parent worker (no race)
App::superglobals(false);
// public/*.php → CGI pool subprocess (true global-scope isolation per request)
App::processIsolation(true);
// Parent runs coroutines. With HOOK_ALL, the pipe read inside cgiPool
// YIELDS while the subprocess executes — other coroutines run in parallel.
// Both default to null → resolve to !sg → these values. Explicit for clarity:
App::enableCoroutine(true);
App::hookAll(\OpenSwoole\Runtime::HOOK_ALL);
// cgiMode('pool') is the default since v0.2.41. Tune pool size to your needs:
App::cgiPoolSize(8); // 8 isolated PHP subprocesses per HTTP worker
App::cgiPoolMaxRequests(1000); // recycle after 1000 requests (FPM parity)
$app = App::init('0.0.0.0', 8080);
$app->run();
// Route handlers in route/*.php run at full coroutine speed.
// public/wp-login.php dispatches to the isolated pool.
// Parent yields on the pipe read → multiple coroutines dispatch in parallel.
With ext-zealphp (v0.3.0+), superglobals(true) + enableCoroutine(true) is now fully supported — the extension saves/restores $_GET/$_SESSION per coroutine on every yield/resume. Without ext-zealphp, the validator still refuses this combination. Mode 6 is pinned by tests/Unit/LifecycleModesMatrixTest.php#testMode6CoroutineIsolatedHybrid.
Dual-runtime — one codebase, Apache AND ZealPHP at once
The strongest migration story isn't "rewrite for ZealPHP." It's "run the same source tree on both servers simultaneously" — Apache+mod_php for the battle-tested path, ZealPHP for speed and coroutines — and cut over gradually with zero risk. This is exactly how Selfmade Ninja Labs migrated: one volume, two servers, same files (running on dev today, production cutover next).
The mechanism is a tiny compat shim that gives application code a single accessor — $g->get, $g->session, etc. — that resolves correctly in whichever runtime is loading the file:
<?php
// Include this ONCE at the top of every entry point — on BOTH servers.
if (!isset($GLOBALS['g'])) {
if (class_exists('\ZealPHP\RequestContext', false)) {
// ZealPHP is loaded → use the framework's per-request context.
// Coroutine mode: per-coroutine, concurrency-safe. The ONLY safe
// accessor there ($_GET/$_SESSION are intentionally empty).
$GLOBALS['g'] = \ZealPHP\RequestContext::instance();
} else {
// Apache + mod_php → ZealPHP isn't here at all. Build $g from
// references to PHP's real superglobals so $g->get IS $_GET.
$GLOBALS['g'] = (object) [
'get' => &$_GET, 'post' => &$_POST,
'server' => &$_SERVER, 'cookie' => &$_COOKIE,
'files' => &$_FILES, 'request' => &$_REQUEST,
'session' => &$_SESSION,
];
}
}
$g = $GLOBALS['g'];
Then application code uses only $g->X — never $_GET/$_SESSION directly:
<?php
require_once __DIR__ . '/../vendor/zealphp/zealphp/compat/g.php';
session_start();
$g->session['hits'] = ($g->session['hits'] ?? 0) + 1;
$filter = $g->get['filter'] ?? 'all';
echo "Filter: {$filter}, hits: {$g->session['hits']}";
The Apache branch of the shim runs precisely when ZealPHP is not loaded. Under Apache+mod_php there's no OpenSwoole, no Composer autoloader bootstrapped, no ZealPHP\ namespace — so nothing in the framework's autoloaded src/ can execute. The bridge therefore HAS to be a standalone, dependency-free file the app includes unconditionally. ZealPHP ships the canonical copy at compat/g.php (and a test guards it against drift), but it is included by your app, not loaded by the framework. That's not a limitation — it's the only design that can possibly work across the "with ZealPHP / without ZealPHP" boundary.
| Runtime | class_exists(RequestContext) | $g->get resolves to | $_GET |
|---|---|---|---|
| ZealPHP — coroutine mode | true | per-coroutine RequestContext::$get | empty (by design) |
| ZealPHP — superglobals mode | true | RequestContext::$get → bridged to $_GET | populated (v0.2.27+) |
| Apache + mod_php | false | shim's &$_GET reference | populated natively by PHP |
With ext-zealphp (v0.3.0+): App::mode(App::MODE_COROUTINE_LEGACY) is safe — $_GET/$_SESSION are per-coroutine. Legacy code using $_GET works unchanged with full coroutine concurrency. $g->X also works — both accessors are valid in all modes. Without ext-zealphp, coroutine-mode apps must use $g->X exclusively (superglobals stay empty to prevent races).
Apache rewrite recipes — the 12 patterns
Real .htaccess files are full of RewriteRule destinations that end in .php. Each recipe below shows the Apache directive and its ZealPHP equivalent. Every example uses the $g form for query-string injection — works in both modes, no per-coroutine leak in coroutine mode. The legacy $_GET equivalent appears as a comment for readers porting older code.
Recipe A — Strip .php extension (clean URLs)
# URL /about → public/about.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}\.php -f
RewriteRule ^(.+)$ $1.php [L]
// The implicit /{file} route already matches both
// /about and /about.php to public/about.php when
// $ignore_php_ext is true.
App::$ignore_php_ext = true;
Recipe B — Pretty URL → real .php file (with route param)
RewriteRule ^my-page$ /pages/my-page.php [L]
RewriteRule ^article/([0-9]+)\.html$ /article.php?id=$1 [L,QSA]
RewriteRule ^user/([a-z0-9-]+)$ /user/profile.php?slug=$1 [L,QSA]
use ZealPHP\RequestContext;
$app->route('/my-page',
fn() => App::include('/pages/my-page.php'));
$app->patternRoute('/article/([0-9]+)\.html', function ($id) {
$g = RequestContext::instance();
$g->get['id'] = $id; // legacy: $_GET['id'] = $id
return App::include('/article.php');
});
$app->patternRoute('/user/([a-z0-9-]+)', function ($slug) {
$g = RequestContext::instance();
$g->get['slug'] = $slug; // legacy: $_GET['slug'] = $slug
return App::include('/user/profile.php');
});
Recipe C — Front controller (WordPress / Drupal / Laravel)
# WordPress / Laravel — try real file, else hand to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
# Drupal 7 / older CMSes — pass the path as ?q=
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?q=$1 [L,QSA]
use ZealPHP\RequestContext;
// WordPress / Laravel — fallback handler.
// Implicit router already tried public/{file}.php;
// setFallback() catches everything else.
$app->setFallback(fn() => App::include('/index.php'));
// Drupal — populate $g->get['q'] (legacy: $_GET['q']):
$app->setFallback(function () {
$g = RequestContext::instance();
$g->get['q'] = ltrim(parse_url(
$g->server['REQUEST_URI'], PHP_URL_PATH
) ?? '', '/');
return App::include('/index.php');
});
Recipe D — API prefix → single front controller
# /api/v1/users/42 → /api/index.php
# with REQUEST_URI preserved
RewriteRule ^api/(.*)$ /api/index.php [L,QSA]
use ZealPHP\RequestContext;
$app->nsPathRoute('api', '{path}', function (string $path) {
$g = RequestContext::instance();
$g->get['path'] = $path; // legacy: $_GET['path'] = $path
return App::include('/api/index.php');
});
Recipe E — Specific .php file in subdirectory
RewriteRule ^admin/?$ /admin/login.php [L]
RewriteRule ^admin/users$ /admin/users/index.php [L]
RewriteRule ^checkout/done$ /shop/thankyou.php [L]
$app->route('/admin', fn() => App::include('/admin/login.php'));
$app->route('/admin/users', fn() => App::include('/admin/users/index.php'));
$app->route('/checkout/done', fn() => App::include('/shop/thankyou.php'));
Recipe F — Block direct access to internal .php files
# WordPress: prevent direct hits on wp-includes/*.php
RewriteRule ^wp-includes/(.+\.php)$ - [F,L]
// Refuse with 403 (universal return contract — int = status).
$app->nsPathRoute('wp-includes', '{rest}(\.php)?', fn() => 403);
Recipe G — HTTPS canonical scheme
RewriteCond %{HTTPS} off
RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
use Psr\Http\{Server\MiddlewareInterface,
Message\ResponseInterface};
use OpenSwoole\Core\Psr\Response;
$app->addMiddleware(new class implements MiddlewareInterface {
public function process($request, $handler): ResponseInterface {
if (($request->getServerParams()['HTTPS'] ?? '') !== 'on') {
$url = 'https://' . $request->getUri()->getHost()
. $request->getUri()->getPath();
return (new Response(''))->withStatus(301)
->withHeader('Location', $url);
}
return $handler->handle($request);
}
});
Recipe H — Canonical host (www vs apex)
RewriteCond %{HTTP_HOST} !^www\. [NC]
RewriteRule (.*) https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
// Inspect Host header; if missing www., 301 to the www form.
// See Recipe G for the full middleware skeleton.
Recipe I — Maintenance mode
RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.42$
RewriteRule .* /maintenance.html [R=503,L]
// Check $g->server['REMOTE_ADDR']; non-allow-listed IPs get
// a 503 response served via App::include('/maintenance.php').
// Universal return contract handles status + body.
Recipe J — Custom error pages (Apache ErrorDocument)
ErrorDocument 404 /custom-404.php
ErrorDocument 500 /custom-500.php
$app->setErrorHandler(404, fn() => App::include('/custom-404.php'));
$app->setErrorHandler(500, fn($exception) =>
App::include('/custom-500.php', ['exception' => $exception]));
// Args passed to App::include() are extracted into the file's
// scope — custom-500.php sees $exception as a local variable.
Recipe K — SEO redirect (old paths to new)
RedirectMatch 301 ^/old-section/(.*)$ /new-section/$1
RewriteRule ^blog/(.*)$ /articles/$1 [R=301,L]
$app->patternRoute('/old-section/(.*)',
fn($rest, $response) => $response->redirect("/new-section/{$rest}", 301));
$app->patternRoute('/blog/(.*)',
fn($rest, $response) => $response->redirect("/articles/{$rest}", 301));
Recipe L — Trailing-slash enforcement
RewriteCond %{REQUEST_URI} !(\.[a-zA-Z]+|/)$
RewriteRule (.*) /$1/ [R=301,L]
// 301 to the trailing-slash form for directories under public/.
App::$directory_slash = true;
Recipe summary
| Pattern | ZealPHP equivalent |
|---|---|
A — Strip .php | Built-in: App::$ignore_php_ext = true |
B — Pretty URL → .php + param | patternRoute + App::include + $g->get |
| C — Front controller | setFallback(fn() => App::include('/index.php')) |
| D — API prefix | nsPathRoute('api', '{path}', ...) + App::include |
| E — Specific file mapping | $app->route('/url', fn() => App::include('/file.php')) |
| F — Block direct access | nsPathRoute(...) => 403 (int = status) |
| G/H — HTTPS / www canonical | PSR-15 middleware |
| I — Maintenance mode | PSR-15 middleware + App::include |
| J — Error pages | setErrorHandler(N, fn() => App::include('/...')) |
| K — SEO 301 | patternRoute + $response->redirect(..., 301) |
| L — Trailing slash | Built-in: App::$directory_slash = true |
Real-world full-.htaccess migration — worked example
A production-style Q&A platform .htaccess with ~30 rewrite rules, headers, charsets, and caching. Each row maps directly to a ZealPHP construct. ✅ = built-in. ⚠ = small custom middleware (on the roadmap). 💡 = PHP-level idiom.
| Apache directive | ZealPHP equivalent | Support |
|---|---|---|
php_value upload_max_filesize 512M | ini_set('upload_max_filesize', '512M'); in app.php boot, or php.ini | ✅ |
ServerSignature Off | No-op — OpenSwoole sends no server-signature footer | ✅ |
Options -Indexes | No-op — ZealPHP never lists directories | ✅ |
AddDefaultCharset utf-8 | App::addMiddleware(new CharsetMiddleware()) (reads App::$default_charset) | ✅ |
AddCharset utf-8 .css .js … | Same CharsetMiddleware | ✅ |
AddType font/woff2 .woff2 (and friends) | MimeTypeMiddleware for non-static responses; mime_type option on App::run() for static-handler files | ✅ |
Header set Access-Control-Allow-Origin "*" | App::addMiddleware(new CorsMiddleware([...])) | ✅ |
<FilesMatch ".(css|jpg|…)$"> Header set Cache-Control "max-age=2628000" | CacheControlMiddleware — extension-keyed map | ✅ |
RewriteEngine on / RewriteBase / | N/A — native routing | ✅ |
RewriteRule ^/?qn/([^/]+)?$ "qn.php?id=$1" | patternRoute('/qn/([^/]+)?', fn($id) => { $g->get['id'] = $id; return App::include('/qn.php'); }) | ✅ Recipe B |
RewriteRule ^/?watch/([^/]+)?$ "watch.php?v=$1" | Same Recipe B pattern | ✅ |
RewriteRule ^/?_/([^/]+)/([^/]+)?$ "_data.php?switch=$1&query=$2" | patternRoute('/_/([^/]+)/([^/]+)?', fn($switch, $query) => { $g->get['switch']=$switch; $g->get['query']=$query; return App::include('/_data.php'); }) | ✅ |
RewriteRule ^/?account/([^/]+)?$ + ^/?account/([^/]+)/([^/]+)?$ (overloaded) | Two patternRoute calls, more specific first | ✅ |
RewriteRule ^/?api/([^/]+)/([^/]+)?$ "api.php?rquest=$2&ns=$1" (swapped captures) | Capture order in closure params matches regex; assign by name: fn($ns, $rquest) => { $g->get['ns']=$ns; ... } | ✅ |
RewriteRule ^/?contents/(.+)?$ "contents.php?path=$1" (greedy .+) | patternRoute('/contents/(.+)?', ...) | ✅ |
RewriteRule ^/?help/(.+)/$ "http://%{HTTP_HOST}/help/$1" [R=301] (strip trailing slash) | patternRoute('/help/(.+)/', fn($p, $r) => $r->redirect("/help/{$p}", 301)) | ✅ |
RewriteRule ^/?help?$ "http://%{HTTP_HOST}/help/" [R=301] | $app->route('/help', fn($r) => $r->redirect('/help/', 301)) | ✅ |
RewriteRule ^/?help/(.+)?$ "help.php?topic=$1" | Standard Recipe B pattern | ✅ |
RewriteCond %{THE_REQUEST} ^...\.php... HTTP/; RewriteRule ^(.+)\.php$ "..." [R=404] (refuse direct .php) | BlockPhpExtMiddleware — returns 404 when URI ends in .php | ✅ |
RewriteCond %{REQUEST_FILENAME}\.test.php -f; RewriteRule ^([^/.]+)$ $1.test.php (extensionless .test.php resolver) | Custom route that file-existence-checks then includes | ⚠ Document fall-through semantics |
RewriteCond %{REQUEST_FILENAME}\.php -f; RewriteRule ^([^/.]+)$ $1.php | Built-in via implicit /{file} route + $ignore_php_ext = true | ✅ Recipe A |
RewriteCond %{REQUEST_FILENAME} !-d; RewriteRule ^([^/]+)/$ "..." [R=301] (strip trailing slash for non-directories) | App::stripTrailingSlash(true) — inverse of App::$directory_slash | ✅ |
RewriteCond ... !-f; RewriteCond ... !-d; RewriteRule ^([^/]+)/?$ "profile.php?username=$1" | setFallback with one-segment URL check, return 404 otherwise | ✅ Recipe C generalised |
Coverage: ~80% fully supported with no new framework work (every patternRoute + App::include case, redirects, CORS, MIME via static handler, extension resolver, front-controller fallback). ~20% need a small middleware addition or 5-line custom inline.
Apache AllowOverride coverage matrix
Every directive that can appear in a .htaccess file (sourced from Apache's overrides reference), grouped by AllowOverride category. ✅ built-in / ⚠ custom middleware / 💡 PHP-level / ❌ obsolete or unsupported.
AllowOverride All — request shape, server identity, conditionals
| Apache | ZealPHP |
|---|---|
<Files>, <FilesMatch> | ✅ Route patterns + middleware conditionals on $g->server['REQUEST_URI'] |
<If>, <ElseIf>, <Else> | ✅ Native PHP control flow — Apache ap_expr becomes PHP expressions |
<IfModule>, <IfDefine>, <IfDirective>, <IfFile>, <IfSection>, <IfVersion> | ✅ N/A — PHP if (class_exists / extension_loaded / file_exists / version_compare) |
LimitRequestBody | ✅ 'package_max_length' => N in $app->run() |
LimitXMLRequestBody | 💡 libxml_disable_entity_loader + check Content-Length |
LogIOTrackTTFB | 💡 Custom logging middleware |
Lua hooks (mod_lua) | ✅ N/A — PSR-15 middleware is the equivalent |
RLimitCPU / MEM / NPROC | 💡 PHP-level (set_time_limit / memory_limit) or OS-level (ulimit, systemd LimitNPROC=) |
ServerSignature | ✅ Built-in (always off — no signature footer) |
SSI directives (SSIErrorMsg, etc.) | ❌ SSI not supported |
AllowOverride AuthConfig — authentication and authorisation
| Apache | ZealPHP |
|---|---|
AuthType Basic + AuthName + AuthUserFile + Require | ✅ BasicAuthMiddleware — htpasswd file or callback verifier, same DX as CorsMiddleware |
AuthType Digest + AuthDigest* | ⚠ Niche; deferred. Document recommends BasicAuth + HTTPS. |
AuthLDAP* | ❌ Custom middleware via PHP's ldap_* extension |
Anonymous* | ❌ Niche |
Require valid-user / user X / group Y | ✅ BasicAuthMiddleware config |
<Limit>, <LimitExcept> | ✅ Route methods array |
<RequireAll>, <RequireAny>, <RequireNone>, Satisfy | ✅ BasicAuthMiddleware config (boolean composition) |
Session* (mod_session) | ✅ N/A — PHP native sessions via ZealPHP's Session family |
SSL* (cipher suite, SSLRequire, etc.) | 💡 OpenSwoole TLS config at server boot |
CGIPassAuth | ✅ Auth headers already in $g->server['HTTP_AUTHORIZATION'] |
AllowOverride FileInfo — response headers, content negotiation, rewrites, env vars (the big one)
| Apache | ZealPHP |
|---|---|
RewriteEngine, RewriteBase, RewriteCond, RewriteRule, RewriteOptions | ✅ Native routing — covered exhaustively by Recipes A–L above |
Redirect, RedirectMatch, RedirectPermanent, RedirectTemp | ✅ $response->redirect($url, $status) — see Recipe K |
Header set / append / unset / add / merge | ✅ HeaderMiddleware — declarative add() / set() / unset() with conditional variants |
RequestHeader | ✅ HeaderMiddleware, request-side variant |
ErrorDocument N /foo.php | ✅ $app->setErrorHandler(N, fn() => App::include('/foo.php')) — Recipe J |
AddDefaultCharset, AddCharset | ✅ CharsetMiddleware — appends ; charset=utf-8 to text-ish responses; reads App::$default_charset |
AddType X .Y (MIME types) | ✅ MimeTypeMiddleware for non-static responses; static handler MIME map for files |
AddEncoding gzip .gz | ✅ OpenSwoole http_compression |
AddHandler X .Y | ✅ N/A — ZealPHP IS the runtime |
AddInputFilter, AddOutputFilter, SetOutputFilter, AddOutputFilterByType | ✅ Apache filter chains → PSR-15 middleware (compression, ETag, range, headers, etc.) |
Substitute "s/foo/bar/" (mod_substitute) | ✅ BodyRewriteMiddleware — single-line regex substitution on response body |
AddLanguage, DefaultLanguage, LanguagePriority | ⚠ ContentNegotiationMiddleware if demand emerges |
BrowserMatch, SetEnvIf, SetEnv, UnsetEnv, PassEnv | 💡 PHP-level: read $g->server['HTTP_USER_AGENT'], set $g->server['MY_VAR'] |
Cookie* (mod_usertrack) | ✅ Built-in: setcookie() override supports all attrs incl. samesite |
FileETag | ✅ Built-in via ETagMiddleware (md5 of body) |
EnableMMAP, EnableSendfile | ✅ $response->sendFile() uses kernel sendfile transparently |
ForceType X | ✅ MimeTypeMiddleware or one-line $response->header('Content-Type', $type) |
Action handler /script | ✅ N/A — ZealPHP routes are explicit |
AcceptPathInfo | ✅ Native routing handles this; also App::$path_info |
QualifyRedirectURL | ⚠ Niche |
DefaultType | ✅ Deprecated by Apache too |
CGI*, CharsetSourceEnc, CharsetDefault, CharsetOptions | ✅ N/A or 💡 PHP-level |
ScriptInterpreterSource | ✅ N/A (Windows-only CGI quirk) |
ISAPI* | ✅ N/A (Windows IIS, irrelevant) |
AllowOverride Indexes — directory listings, autoindex, expires headers
| Apache | ZealPHP |
|---|---|
DirectoryIndex index.php index.html | ✅ App::$directory_index = ['index.php', 'index.html'] |
DirectorySlash On | ✅ App::$directory_slash = true |
FallbackResource | ✅ App::setFallback(fn() => ...) |
DirectoryIndexRedirect | ⚠ Trivial via redirect in fallback |
ExpiresActive, ExpiresByType, ExpiresDefault (mod_expires) | ✅ ExpiresMiddleware — Expires: by content type; pairs with CacheControlMiddleware |
mod_autoindex full surface (AddIcon, IndexStyleSheet, etc.) | ❌ Not supported (basic autoindex is on the roadmap) |
ImapBase, ImapDefault (server-side imagemaps) | ❌ Dead tech (~1995) |
MetaDir, MetaFiles, MetaSuffix (CERN meta files) | ❌ Dead tech |
AllowOverride Limit — legacy host-based access control
| Apache | ZealPHP |
|---|---|
Allow from X, Deny from Y, Order Allow,Deny | ✅ IpAccessMiddleware — CIDR allow/deny lists with allow-first or deny-first ordering |
<Limit METHOD>, <LimitExcept METHOD> | ✅ Route methods array |
AllowOverride Options — feature toggles and filter chains
| Apache | ZealPHP |
|---|---|
Options Indexes | ❌ Not supported (basic autoindex on roadmap) |
Options FollowSymLinks, SymLinksIfOwnerMatch | 💡 PHP-level: realpath() follows symlinks; App::includeCheck() is the safety gate |
Options ExecCGI | ✅ N/A — no CGI handlers outside legacy-app mode |
Options Includes / IncludesNoExec / XBitHack (SSI) | ❌ SSI not supported |
Options MultiViews | ⚠ Custom middleware if needed; uncommon |
ContentDigest | ⚠ Trivial custom middleware |
CheckSpelling, CheckCaseOnly (mod_speling) | ❌ Not supported |
FilterChain, FilterDeclare (mod_filter) | ⚠ Map to PSR-15 middleware |
SSLOptions | 💡 OpenSwoole TLS config |
Headline coverage
| Category | ZealPHP coverage |
|---|---|
Rewrites & redirects (mod_rewrite, mod_alias) | 100% — native routing |
Directory & front-controller (mod_dir) | 100% — built-in |
HTTP method limits (<Limit>) | 100% — route methods array |
| MIME, charset, encoding | ✅ gzip ✅ CharsetMiddleware ✅ MimeTypeMiddleware |
| Headers & cookies | ✅ Cookie + HeaderMiddleware (declarative response-header manipulation) |
| Cache & expires | ✅ ExpiresMiddleware + CacheControlMiddleware (extension-based static-asset caching) |
| ETag | ✅ built-in |
| Compression | ✅ OpenSwoole http_compression |
| Error documents | ✅ App::setErrorHandler() |
| Range / conditional requests | ✅ RangeMiddleware + ETagMiddleware |
| HTTP Basic Auth | ✅ BasicAuthMiddleware (htpasswd file or callback verifier) |
| LDAP / Digest auth | ❌ niche — PHP extension integration documented |
| Env vars per request | 💡 PHP-level inline |
| IP allow/deny | ✅ IpAccessMiddleware (CIDR allow/deny lists) |
| SSI | ❌ not supported — use templates |
| Autoindex | ❌ basic listing on roadmap; full Apache customisation surface not planned |
Body rewrite (mod_substitute) | ✅ BodyRewriteMiddleware (single-line regex substitution; multi-line variants on roadmap) |
| Content negotiation | ⚠ custom middleware on demand |
| Server identity | ✅ no-op / ⚠ trivial |
| Dead tech (imap, speling, CERN meta, ISAPI, mod_dav, XBitHack) | ❌ N/A — not goals |
Verdict: ZealPHP covers the practical 80–90% of .htaccess capability that real PHP apps actually use, has clear middleware-extension paths for another 10%, and explicitly disclaims the dead-tech ~5%.
nginx coverage matrix
For users porting from nginx.conf. Sourced from ngx_http_core_module + ngx_http_rewrite_module.
Virtual host & listen
| nginx | ZealPHP / OpenSwoole |
|---|---|
server { … } | ✅ One App::init(host, port) instance per server block. Multi-app deployments run multiple instances on different ports (PID-file-per-port already supports this) |
listen 80; / listen 443 ssl http2; | ✅ App::init('0.0.0.0', 80); for TLS pass ssl_cert_file / ssl_key_file / enable_http2 in $app->run() settings |
server_name a.com b.com; (name-based vhosts) | ✅ HostRouterMiddleware — dispatches on $g->server['HTTP_HOST'] to per-host handlers; OR run one instance per host behind Caddy/Traefik for true isolation |
Routing — the location family
| nginx | ZealPHP |
|---|---|
location /prefix/ { … } | ✅ $app->route('/prefix/...', ...) or nsPathRoute('prefix', ...) |
location = /exact { … } | ✅ $app->route('/exact', ...) |
location ~ \.php$ { … } (regex) | ✅ patternRoute('.*\.php$', ...) |
location ~* \.(css|js)$ { … } (case-insensitive) | ⚠ ZealPHP patterns are case-sensitive; wrap regex with (?i) flag |
location ^~ /static/ { … } (prefix wins over regex) | ✅ Route registration order determines priority |
location @named { … } (named locations) | ✅ App::setErrorHandler(N, fn() => App::include('/fallback.php')) |
root /var/www/html; | ✅ Built-in — public/ is the document root by convention |
alias /var/some/other/path; | ✅ Custom route + App::include() with the aliased path |
index index.php index.html; | ✅ App::$directory_index = ['index.php', 'index.html'] |
try_files $uri $uri/ /index.php?$args; | ✅ Recipe C — implicit router tries public/{file}.php, then setFallback catches the rest |
error_page 404 /custom-404.html; | ✅ App::setErrorHandler(404, ...) — Recipe J |
X-Accel-Redirect | ⚠ ZealPHP IS the origin; the offload pattern collapses to $response->sendFile() after auth |
Rewrite module
| nginx | ZealPHP |
|---|---|
rewrite ^/old$ /new last; | ✅ Route /old + App::include('/new.php'), or use patternRoute |
rewrite ^/old$ /new redirect; (302) | ✅ return $response->redirect('/new', 302); |
rewrite ^/old$ /new permanent; (301) | ✅ return $response->redirect('/new', 301); |
return 301 https://$host$request_uri; | ✅ Universal HTTPS middleware (Recipe G) or inline $response->redirect(...) |
return 200 "OK\n"; (inline body) | ✅ return "OK\n"; (return contract) |
if ($http_user_agent ~ MSIE) { … } | ✅ if (preg_match('/MSIE/', $g->server['HTTP_USER_AGENT'])) |
if (-f $request_filename) { … } | ✅ if (file_exists(App::$cwd . '/public/' . $path)) |
set $foo bar; | ✅ Plain PHP variable |
Body, headers, transmission, keep-alive, types, cache, logs, rate limits, auth, proxy, TLS
| nginx | ZealPHP / OpenSwoole |
|---|---|
client_max_body_size 100m; | ✅ 'package_max_length' => 100 * 1024 * 1024 |
sendfile on; / tcp_nopush / tcp_nodelay | ✅ Built-in via $response->sendFile() and OpenSwoole socket options |
keepalive_timeout 75s; | ✅ OpenSwoole 'keepalive_timeout' => N |
types { … } / default_type | ⚠ OpenSwoole static_handler_locations MIME map |
open_file_cache | ✅ OpenSwoole built-in static-file caching |
disable_symlinks on; | ✅ App::includeCheck() rejects paths outside public/ |
access_log / error_log | ✅ Built-in: access_log(), elog(), zlog() (configurable via ZEALPHP_* env vars) |
log_format custom "…"; | ✅ App::$access_log_format — Apache %h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i" %D tokens supported |
limit_rate / limit_req / limit_conn | ✅ RateLimitMiddleware (sliding window in Store) + ConcurrencyLimitMiddleware (in-flight cap via Counter); limit_rate bandwidth throttle is a 5-line response wrapper |
limit_except GET POST { deny all; } | ✅ Route methods array |
auth_basic + auth_basic_user_file | ✅ BasicAuthMiddleware |
proxy_pass http://backend; | ⚠ Not built-in. Use Caddy/Traefik/Nginx in front, OR a handler using OpenSwoole's HTTP client |
fastcgi_pass unix:/run/php-fpm.sock; | ✅ N/A — ZealPHP IS the PHP runtime |
ssl_certificate, ssl_certificate_key, ssl_protocols, ssl_ciphers | ✅ OpenSwoole 'ssl_*' settings |
if_modified_since / etag on; | ✅ ETagMiddleware handles If-None-Match |
expires 30d; | ✅ ExpiresMiddleware + CacheControlMiddleware |
client_max_body_size / large_client_header_buffers / max_headers | ✅ App::$limit_request_fields + $limit_request_field_size + $limit_request_line (Apache LimitRequestFields family). client_max_body_size is 'package_max_length' in $app->run(). |
merge_slashes on; | 💡 Middleware normalising $g->server['REQUEST_URI'] |
server_tokens off; | ✅ No Server header sent by default |
chunked_transfer_encoding on; | ✅ OpenSwoole handles chunked encoding for streaming responses |
gzip on; / gzip_types | ✅ OpenSwoole http_compression |
Headline: nginx-as-front-controller patterns (try_files, location, rewrite, return, error_page) port 1:1 to ZealPHP's native routing — same way .htaccess rewrites do. The "I serve PHP via FastCGI" half of nginx configs is N/A. The proxy/upstream/load-balancing half is intentionally delegated to a real front proxy.
Rewrite rules — internal vs. external
Apache RewriteRule has two flavors that get conflated all the time. The flag in
brackets decides whether the URL bar in the user's browser changes.
- No
[R]flag = internal rewrite. Apache serves the destination's file but the URL bar still shows the original URL. No Location header sent. The user never sees the internal path. Most SEO-safe, used for friendly URLs over a front controller. [R=301]or[R=302]= external redirect. Apache sends a Location header; browser does a fresh request to the destination; URL bar changes. Used to permanently move a page or for vanity short-links.
These map to two different ZealPHP patterns. Don't use header('Location: …')
for a non-[R] rewrite — that would expose the internal URL the original
rule was hiding.
Custom error pages for legacy apps
Mirror .htaccess's ErrorDocument 404 /custom-404.php with App::setErrorHandler() — see Recipe J above:
$app->setErrorHandler(404, function ($status) {
// Hand 404s to WordPress so it can render its own theme template.
return App::include('/wp/index.php');
});
$app->setErrorHandler(500, function ($exception) {
// Send a JSON envelope to API clients, HTML to browsers.
$g = ZealPHP\RequestContext::instance();
if (str_contains($g->server['HTTP_ACCEPT'] ?? '', 'application/json')) {
return ['error' => 'Internal Server Error', 'trace_id' => uniqid()];
}
return App::renderToString('error/500', ['exception' => $exception]);
});
Handlers receive $status, $exception, $request, $response by param injection — same machinery as regular routes. Returns follow the universal return contract. See Responses for details and the docs/error-handling.md deep dive.
AI Config Converter
Paste your .htaccess or nginx config — get a working app.php streamed in real-time. The converter knows about the 12 recipes above, the known limitations matrix, the universal return contract, and the $g-vs-$_* parity rule — so it emits modern App::include() form, refuses unsupported directives explicitly (rather than silently dropping them), and uses $g->get['x'] over $_GET['x']. Powered by gpt-5.4-mini with the full ZealPHP API reference.
// Output will appear here...
# Pipe any config — get app.php on stdout
cat .htaccess | uv run examples/agents/config_converter.py
# Interactive mode
uv run examples/agents/config_converter.py
WordPress example
A complete app.php that runs WordPress on ZealPHP:
<?php
require 'vendor/autoload.php';
use ZealPHP\App;
use ZealPHP\RequestContext;
App::superglobals(true);
App::cgiMode('pool'); // warm FPM-style worker pool (default)
App::cgiPoolMaxRequests(1); // recycle each request — WP needs a fresh boot
App::$ignore_php_ext = false;
$app = App::init('0.0.0.0', 9501);
// Redirect /wp-admin to /wp-admin/index.php
$app->route('/wp-admin', function ($response) {
$g = RequestContext::instance();
$qs = !empty($g->server['QUERY_STRING'])
? '?' . $g->server['QUERY_STRING'] : '';
return $response->redirect('/wp-admin/index.php' . $qs, 301);
});
// Fallback: unmatched URLs → WordPress front controller
// Replaces Apache's: RewriteRule . /index.php [L]
$app->setFallback(fn() => App::include('/index.php'));
$app->run(['task_worker_num' => 0]);
Setup Steps
See the full working example: github.com/sibidharan/zealphp-wordpress
- Create a ZealPHP project:
composer create-project zealphp/project my-wordpress - Download WordPress into
public/:cd my-wordpress/public && wp core download - Configure
public/wp-config.phpwith your database settings - Write
app.phpas shown above - Start:
php app.php(orphp app.php start -p 9501 -dto daemonize) - Visit
http://localhost:9501/wp-admin/install.phpto complete installation
CGI Worker Architecture
App::include() dispatches to two different paths depending on the mode:
- Coroutine mode (
App::superglobals(false)) — runs the file in-process via the sharedApp::executeFile()core. Captures output, applies the universal return contract. - Superglobals mode (
App::superglobals(true)) — dispatches eachpublic/*.phpto a warmsrc/cgi_worker.phpdrawn from thecgiMode('pool')worker pool (the default), or forwards to an external FastCGI / php-fpm pool undercgiMode('fcgi'). Either way the request runs in a clean, separate PHP process with true global scope — the same model as PHP-FPM / Apache prefork, but the interpreter stays resident between requests instead of cold-starting each time. The worker captures the file's return value over the stderr metadata channel and threads it back through the same contract.
OpenSwoole Worker (long-lived) Pool Worker (warm, resident)
┌─────────────────────────┐ ┌──────────────────────────┐
│ │ dispatch │ php cgi_worker.php │
│ Route matched │ ──────────► │ (drawn from warm pool) │
│ App::include('/x.php') │ │ TRUE global scope: │
│ │ stdin │ ├─ $_SERVER, $_GET, etc.│
│ Serializes context: │ ──────────► │ ├─ $_COOKIE, $_FILES │
│ ├─ $_SERVER, $_GET │ (POST body)│ │ │
│ ├─ $_POST, $_COOKIE │ │ ├─ uopz captures: │
│ └─ Request body │ │ │ header(), setcookie()│
│ │ stdout │ │ http_response_code() │
│ Reads response: │ ◄────────── │ │ │
│ ├─ Body from stdout │ │ ├─ include file.php │
│ ├─ Metadata from stderr│ stderr │ │ ← app runs at global │
│ │ (status, headers, │ ◄────────── │ │ scope │
│ │ cookies, return │ │ ├─ resets global scope │
│ │ value as JSON) │ │ │ (FPM-style cleanup) │
│ └─ Applies to response │ │ └─ stays warm for next │
│ │ │ request (recycled │
│ │ │ after N requests) │
└─────────────────────────┘ └──────────────────────────┘
What the CGI worker handles
| Feature | How |
|---|---|
| All HTTP methods | $_SERVER['REQUEST_METHOD'] passed via context; request body piped to stdin (php://input) |
header() / header_remove() | Captured via zealphp_override() (ext-zealphp preferred; uopz_set_return fallback) — sent back as JSON metadata |
setcookie() / setrawcookie() | Captured — applied to response by parent worker |
http_response_code() / headers_list() | Captured — status and headers returned in metadata |
| File return value | Serialised over stderr metadata; threaded through the universal return contract |
exit() / die() | register_shutdown_function flushes output and metadata |
| SSE streaming | Detects text/event-stream; streams via flush() like Apache |
| Static files | Served directly by OpenSwoole — never reaches PHP |
| File uploads / Sessions | $_FILES via context; PHP native sessions work in CGI process |
CLI Management
php app.php # Start with defaults
php app.php start -p 9501 # Start on port 9501
php app.php start -p 9501 -d # Start daemonized
php app.php stop # Stop the server (reads PID file)
php app.php status # Check if server is running
php app.php start -w 8 # Start with 8 workers
php app.php --help # Show all options
Performance & Hybrid Mode
Performance: In superglobals mode, each App::include() is dispatched to a warm worker from the cgiMode('pool') pool (or forwarded to an external FastCGI pool under cgiMode('fcgi')) — the interpreter stays resident, so there is no per-request cold start. Static files bypass CGI dispatch entirely (served by OpenSwoole). For the absolute hottest paths, convert them to native ZealPHP routes that run in coroutine mode.
Streaming: SSE works in CGI mode via flush(). WebSocket requires native ZealPHP routes (App::ws()).
Hybrid approach: Mix native routes (coroutine mode, high performance) with legacy PHP file serving (CGI mode) in the same app. Explicit $app->route() handlers run directly in the worker.
CGI backends — host any language
ZealPHP can serve files written in any language that speaks CGI/1.1 — Perl, Python, Ruby, shell scripts, or compiled binaries — side-by-side with your PHP app. Register per-extension backends with App::registerCgiBackend() before $app->run().
Works in every lifecycle mode. CGI dispatch is no longer gated on process-isolation. A registered non-.php extension is dispatched through its backend in coroutine mode too — the 'proc' path spawns the interpreter on demand through a coroutine-aware launcher (Coroutine\System::exec()) that yields to the scheduler instead of blocking the worker, supports a POST body on the interpreter's stdin, and can stream. The interpreter's RFC 3875 CGI response (headers + blank line + body, with a Status: pseudo-header) is read off stdout via cgiInterpreterResponse() — Apache mod_cgi parity. The .php fast path (warm 'pool' by default) is unchanged.
Apache / nginx parity table
| Mode | Apache equivalent | nginx equivalent | ZealPHP |
|---|---|---|---|
'proc' |
AddHandler cgi-script .pl + Options +ExecCGI |
— | App::registerCgiBackend('.pl', ['mode'=>'proc', 'interpreter'=>'/usr/bin/perl']) |
'proc' (shebang) |
AddHandler cgi-script .cgi — relies on #! line |
— | App::registerCgiBackend('.cgi', ['mode'=>'proc']) |
'fcgi' |
ProxyPassMatch ^/(.+\.py)$ fcgi://127.0.0.1:9001/… |
location ~ \.py$ { fastcgi_pass 127.0.0.1:9001; } |
App::registerCgiBackend('.py', ['mode'=>'fcgi', 'address'=>'127.0.0.1:9001']) |
'pool' |
— (no direct equivalent; built-in warm subprocess pool is ZealPHP-specific) | — | App::cgiMode('pool') — default; .php only |
'fork' (experimental) |
Apache MPM prefork (prefork MPM + mod_php) — fresh process per request, interpreter not shared across requests |
— | App::cgiMode('fork') — .php only; long-lived fork-master (src/fork_master.php) forks a fresh child per request (~1 ms), true global scope, hard-exits after; requires pcntl + posix; concurrency capped by App::$cgi_fork_max_concurrent (default 16; 503 when full); no App::mode() preset — set directly via App::cgiMode('fork') |
Worked examples
// .php falls through to App::$cgi_mode (default 'pool' — warm worker pool).
// No registration needed — this is the existing behaviour.
App::processIsolation(true); // enables CGI mode
$app->setFallback(fn() => App::include('/index.php'));
App::registerCgiBackend('.pl', [
'mode' => 'proc',
'interpreter' => '/usr/bin/perl',
]);
// public/info.pl is now executed via /usr/bin/perl
// with the same RFC 3875 CGI env ZealPHP builds for PHP scripts.
App::registerCgiBackend('.py', [
'mode' => 'fcgi',
'address' => '127.0.0.1:9001', // or 'unix:/run/python-fpm.sock'
'fcgi_params' => ['APP_ENV' => 'prod'], // merged into CGI env (nginx fastcgi_param parity)
]);
// Requests to /hello.py are proxied to the FastCGI server — no process spawn per request.
// Same machinery as the framework-wide App::cgiMode('fcgi') — see #cgi-mode-fcgi below.
App::registerCgiBackend('.cgi', [
'mode' => 'proc',
'exec_paths' => ['/cgi-bin'], // ExecCGI scope — only execute under /cgi-bin/*
]);
// ZealPHP execs ['path/to/script.cgi'] directly — the OS reads the #! line.
// Script must output CGI/1.1 headers: Content-Type + blank line + body.
// A .cgi requested OUTSIDE /cgi-bin (e.g. an uploaded /uploads/x.cgi) → 403,
// never executed and never served as source.
exec_paths — the ExecCGI scope (default-off)
By default a registered extension does not execute via an implicit URL. exec_paths opts specific URL path prefixes into execution — ZealPHP's parity for Apache's Options +ExecCGI being off by default. A request whose extension is registered but whose URL falls outside every exec_paths prefix is treated as a stray/uploaded script: it is neither executed nor served as source, returning 403 Forbidden. This closes the classic "upload a .py into the docroot and have it execute" hole. Files outside the scope remain reachable via App::include() (which applies its own docroot-containment check).
Implicit URL parity
Implicit routes are registered per registered extension, so GET /cgi-bin/report.py runs public/cgi-bin/report.py through the .py backend with no explicit $app->route() — the same shape as Apache serving a script out of a cgi-bin directory.
cgiScriptAlias() — Apache ScriptAlias parity
App::cgiScriptAlias('/cgi-bin', ['mode' => 'proc', 'interpreter' => '/usr/bin/python3']);
// Any file served under /cgi-bin is executable regardless of its extension.
// Takes the same mode / interpreter / address / fcgi_params config as registerCgiBackend().
Implicit URL routing is automatic for ScriptAlias prefixes. run() registers an implicit patternRoute for every cgiScriptAlias() prefix, so a ScriptAlias-only setup (no matching registerCgiBackend()) gets an automatic GET/POST route — cgiScriptAlias('/cgi-bin', ['mode' => 'proc']) alone makes GET /cgi-bin/hello.sh work (the file runs via its #! shebang). A per-extension backend is only needed when you want a specific interpreter (e.g. .py → python3).
pool mode — PHP only constraint
Why pool is PHP-only. 'pool' pre-spawns PHP subprocesses that inherit the ZealPHP boot environment and reset global scope between requests. This warm-subprocess mechanism is specific to the PHP runtime. For other languages, use 'fcgi' (warm pool managed by the language runtime) or 'proc' (spawn on demand).
Attempting App::registerCgiBackend('.py', ['mode' => 'pool']) throws \InvalidArgumentException with the message: "pool mode requires a PHP target; use 'fcgi' (warm external pool, language-agnostic) or 'proc' for .py".
Reader: App::resolveCgiBackend()
App::resolveCgiBackend(string $absPath, string $urlPath = ''): array resolves the backend config and the ExecCGI permission for a path. It returns ['backend' => [...], 'mayExecute' => bool]. Resolution order: cgiScriptAlias() prefixes first (always executable), then the per-extension registry gated by exec_paths, then an unregistered fallback (['mode' => App::$cgi_mode], mayExecute = false). When mayExecute is false the dispatcher returns 403 rather than executing or leaking source.
App::registerCgiBackend('.py', [
'mode' => 'fcgi',
'address' => '127.0.0.1:9001',
'exec_paths' => ['/cgi-bin'],
]);
$r = App::resolveCgiBackend('/var/www/app/public/cgi-bin/hello.py', '/cgi-bin/hello.py');
// ['backend' => ['mode' => 'fcgi', 'address' => '127.0.0.1:9001', ...], 'mayExecute' => true]
$r = App::resolveCgiBackend('/var/www/app/public/uploads/hello.py', '/uploads/hello.py');
// ['backend' => [...], 'mayExecute' => false] ← outside ExecCGI scope → 403
$r = App::resolveCgiBackend('/var/www/app/public/index.php', '/index.php');
// ['backend' => ['mode' => 'pool'], 'mayExecute' => false] ← unregistered, App::$cgi_mode (default 'pool')
See examples/multi-lang-cgi/ for a runnable demo registering .pl (proc/Perl) alongside the default PHP backend.
Framework-wide cgiMode('fcgi') — front an upstream FPM pool
The per-extension 'fcgi' backend above is for mixing languages. The framework-wide setter App::cgiMode('fcgi') applies the same FastCGI-forwarding behaviour to every public/*.php file — turning ZealPHP into a thin HTTP layer in front of an existing php-fpm pool. Same wire protocol as Apache's mod_proxy_fcgi and nginx's fastcgi_pass, so the FastCGI listener you point at can be php-fpm, HHVM, RoadRunner, or any other FCGI 1.0 backend.
use ZealPHP\App;
App::superglobals(true); // CGI dispatch path (pool / fcgi)
App::processIsolation(true);
App::cgiMode('fcgi'); // 'pool' (default, warm pool) | 'fcgi'
App::fcgiAddress('127.0.0.1:9000'); // or 'unix:/run/php/php-fpm.sock'
App::init('0.0.0.0', 8080);
$app = new App();
$app->run();
When to reach for this:
- You already operate a tuned php-fpm pool (sized for your workload, hooked into your observability stack) and don't want to retire it — ZealPHP adds OpenSwoole's HTTP / WebSocket / coroutine layer on top.
- You want an externally-managed warm pool — let php-fpm be that pool. The FPM master keeps interpreters warm across requests just as ZealPHP's built-in
cgiMode('pool')does (~1–3 ms warm either way); the difference is who owns the pool process. - You're migrating from an
nginx → fastcgi_passdeployment and want a drop-in shape change rather than a code rewrite. Thefcgi_paramsarray onApp::registerCgiBackend()mirrors nginx'sfastcgi_paramdirective.
Under the hood: dispatch lives in App::cgiFcgi(string $path, ?string $address = null, array $extraParams = []), which builds the CGI/1.1 environment via buildCgiEnv(), forwards via ZealPHP\CGI\FastCgiClient::request($params, $stdinBody), and applies the upstream's status code + headers to $g->zealphp_response. A failed connection or FastCgiException from the upstream surfaces as a clean 502 Bad Gateway — same shape Apache and nginx emit when their FCGI upstream is down.
Performance: we don't run PHP at all in this mode — throughput equals whatever your FPM pool delivers minus one local socket hop. We deliberately don't quote a number here: it depends on your FPM pm.max_children, the file under load, and whether you're on Unix sockets vs TCP. The bridge-cost table at /vs-fpm compares the in-process modes (the warm 'pool' and Mixed-mode) and intentionally omits 'fcgi' because the answer is "ask your FPM pool."
cgiMode('fork') — experimental Apache MPM prefork runner
Experimental. cgiMode('fork') is the Apache MPM prefork CGI runner. It is functional but not yet recommended as a stable default. Test thoroughly before deploying to production.
cgiMode('fork') is the fourth .php isolation strategy alongside 'pool', 'proc', and 'fcgi'. Where cgiMode('pool') reuses warm workers and cgiMode('proc') spawns via proc_open (~30–50 ms cold start), the fork runner sits between them: a long-lived fork-master process (src/fork_master.php) binds a UNIX socket and forks a fresh child per request that runs the target .php at true global scope (~1 ms fork cost), then hard-exits. Result: fresh-process correctness (no "Cannot redeclare class") for unmodified WordPress/legacy apps, at fork cost rather than proc_open cost.
Reachability constraint. There is no App::mode() preset for fork and no App::isolation() Fork case — fork is reached exclusively via App::cgiMode('fork') or App::registerCgiBackend('.php', ['mode' => 'fork']). It is .php-only (like 'pool').
Requirements: the PHP build must include ext-pcntl and ext-posix. Verify with php -m | grep -E 'pcntl|posix'.
<?php
require 'vendor/autoload.php';
use ZealPHP\App;
App::superglobals(true);
App::cgiMode('fork'); // experimental: fork-per-request MPM prefork runner
App::$ignore_php_ext = false; // allow /wp-login.php in URLs
// App::$cgi_fork_max_concurrent = 16; // default; returns 503 when live children hit the cap
$app = App::init('0.0.0.0', 9501);
$app->setFallback(fn() => App::include('/index.php'));
$app->run(['task_worker_num' => 0]);
fork vs pool — when to use which
| Concern | cgiMode('pool') (default) | cgiMode('fork') (experimental) |
|---|---|---|
| Per-request startup cost | ~0 ms (interpreter already warm in pool worker) | ~1 ms (fork syscall) |
| Interpreter state between requests | Shared pool worker; recycled after cgiPoolMaxRequests(N) (default 500) |
Always fresh — child hard-exits after every request; no shared state possible |
WordPress require_once bootstrap re-run |
Needs cgiPoolMaxRequests(1) to force recycle each request |
Automatic — fresh process every request; no maxRequests tuning needed |
| Concurrency cap | cgiPoolSize(N) per HTTP worker; queue if all busy |
App::$cgi_fork_max_concurrent (default 16) live children; 503 when full |
| PHP extensions required | None beyond OpenSwoole | pcntl + posix |
| Stability | Stable (default since v0.2.41) | Experimental |
Backed by src/CGI/ForkPool.php + src/fork_master.php; dispatched via Dispatcher::cgiFork() in src/App.php. Design doc: docs/architecture/2026-06-02-fork-per-request-cgi-pool.md.
What the CGI bridge does for you (security)
Every dispatch mode — the warm 'pool', 'fcgi' forwarding, and the per-language backends — builds the CGI/1.1 environment through the same App::buildCgiEnv() path, so the hardening below applies uniformly. Each item ships a corresponding Apache parity rationale rather than being ZealPHP-specific behaviour.
- httpoxy CVE-2016-5385 mitigation — incoming
Proxy:request headers are NOT forwarded asHTTP_PROXYin the CGI env (src/App.php:2830-2836). Apacheutil_script.c:224-227parity. Prevents the well-known PHP/Go/Python CGI library family that readsHTTP_PROXYto choose an outbound proxy from being hijacked by a hostile client. - CGI script timeout —
App::$cgi_timeoutdefault 60 s (src/App.php:273). When a CGI subprocess exceeds the budget the worker escalatesSIGTERM→SIGKILLand returns control to the request handler. ApacheCGIScriptTimeoutparity. Override by setting the public static property:App::$cgi_timeout = 120;beforeApp::init(). - CGI
Status:header parsed from stdout (src/cgi_worker.php:101-113) — a legacy script that emitsStatus: 404 Not Found\r\nsets the response status to 404 and theStatus:header itself is NOT forwarded to the client. Range-clamped 100–599; non-numeric or out-of-range values fall back to 200. mod_cgi parity. - stderr drained to
elog— anything the subprocess writes to fd 2 (PHP warnings, custom debug, uncaught notices) is routed to/tmp/zealphp/debug.logvia thecgi_workerlog channel, never leaked into the response body. Prevents the classic "PHP warning rendered into the HTML page" disclosure path.
None of these are opt-in — they're always active on the CGI dispatch path, in every lifecycle mode (the path is no longer gated on processIsolation(true)). There is no flag to disable the HTTP_PROXY strip, the timeout has a floor of 1 s rather than an unbounded option, and stderr always lands in elog.
Coroutine-safe exec
In vanilla OpenSwoole, shelling out (git, ffmpeg, convert, …) via PHP's built-in functions would block the worker — one slow command stalls every coroutine sharing it. ZealPHP solves this: in coroutine mode (the default), overrides intercept shell_exec, exec, system, passthru, and the backtick operator via zealphp_override() (ext-zealphp preferred; uopz_set_return() as fallback), routing them through App::exec() which yields to the scheduler instead of blocking. Legacy code that shells out works safely with no changes.
| API | What it does |
|---|---|
App::exec(string $cmd, ?float $timeout = null): array | Coroutine-safe execution. Inside a coroutine, yields via OpenSwoole\Coroutine\System::exec(); outside one (boot / CLI) falls back to blocking App::rawExec(). Returns ['output' => string, 'code' => int, 'signal' => int] either way. |
App::rawExec(string $cmd): ?string | Explicit blocking escape hatch — returns captured stdout (or null if the process failed to start). Uses a low-level launcher deliberately (NOT the overridden shell_exec/exec/system/passthru/popen), so it stays recursion-safe even with the override on. |
App::hookExec(?bool) / App::$hook_exec | Toggles the transparent override. null (default) resolves to on in coroutine mode (superglobals === false); a non-null value forces it on/off. When on, shell_exec, exec, system, passthru, and the backtick operator all route through App::exec() via zealphp_override() (ext-zealphp preferred; uopz_set_return() fallback) — same override family as header() and session_*(). The low-level process launchers are intentionally NOT overridden, so the coroutine-safe shell-out path stays recursion-safe. |
New ZealPHP-native code should still prefer explicit App::exec(), but the override means unmodified legacy code that shells out stops blocking the worker automatically.
Performance & Hybrid Mode (continued)
For the full performance picture including CGI backends, see the performance page.