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.

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.

Production proof point. Selfmade Ninja Labs (labs.selfmade.ninja) — a large PHP/MongoDB dashboard with OAuth, SSE streaming, and a custom MongoDB ORM — runs the same codebase on both Apache and ZealPHP in production. Two servers, one volume, zero downtime during migration. Read the case study →
WordPress homepage served by ZealPHP
WordPress front page
WordPress admin dashboard on ZealPHP
Admin dashboard — full menu, widgets, Quick Draft
WordPress posts list on ZealPHP
Posts management — CRUD, bulk actions, filters

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:

App-by-app summary (Mode 1 = CGI Pool, Mode 3 = Sync + functionIsolation)

AppCategoryMode 1 (CGI)Mode 3 (Sync+FI)Best mode
WordPressCMS✅ 3/3partialMode 1
JoomlaCMS✅ 3/3✅ 5/5Any
KanboardProject mgmt✅ 3/3✅ 5/5Any
OpenCartE-commerce✅ 3/3✅ 5/5Any
RoundcubeWebmail✅ 3/3✅ 5/5Any
AdminerDB admin✅ 3/3✅ 10/10*Any (Mode 3 needs functionIsolation)
TinyFileManagerFile mgr✅ 3/3✅ 10/10*Any (Mode 3 needs functionIsolation)
FreshRSSRSS reader✅ 3/3✅ 5/5*Any (Mode 3 needs functionIsolation)
CactiNet monitoring✅ 3/3Mode 1 (relative includes)
MatomoAnalytics✅ 3/3partialMode 1
DokuWikiWikipartial✅ 5/5Mode 3
phpMyAdminDB adminpartialMode 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

FeatureWhy notWorkaround 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_spelingCheckSpelling, CheckCaseOnly, fuzzy URL matching for typosSecurity-questionable (cache pollution, info disclosure), low-value, low-usage.Send a real 404 and let users retype.
mod_imagemap — server-side <map> filesDead 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_rubyZealPHP 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 hooksLuaHook*, LuaMapHandler, etc.Apache's scriptable hook layer. PSR-15 middleware is the native equivalent.Write a PSR-15 middleware.
CERN meta filesMetaDir, MetaFiles, MetaSuffixDead 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 customisationAddIcon, AddAlt, IndexStyleSheet, HeaderName, ReadmeNameBasic 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)

FeatureWhy / StatusWorkaround
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-SendfileDifferent 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

FeatureWhyWorkaround
coprocess() in coroutine modeProcess-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.

Before — manual preamble + absolute paths
$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'
    );
});
After — Apache document-root convention
$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:

FeatureWhat it doesApache 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
Minimal legacy app configuration — one-liner with App::mode()
<?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:

PresetWhat it setsUse 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:

ConfigurationResultNotes
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:

app.php — unmodified WordPress on ZealPHP
<?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):

app.php — WordPress on pool with recycle-each-request
<?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.

app.php — Concurrent WordPress via coroutine-legacy
<?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.phpwp-settings.phpnew 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.

PathThroughputPer-request latencyWhat 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:

Measure pool dispatch on your own box
# 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 cgiPoolSize subprocesses with a Coroutine\Channel queue. Scales with worker count.
  • Real $_GET / $_POST / $_SERVER / $_COOKIE inside the subprocess. Pinned by 5 unit tests in tests/Unit/CGI/CgiPoolDispatchTest.php (testPostSuperglobalReachesSubprocess, etc.).
  • Auto-respawn on crash. proc_get_status polled 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:

Mode 6 — coroutines at the parent, isolated subprocess per public/*.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:

compat/g.php — shipped with ZealPHP at vendor/zealphp/zealphp/compat/g.php
<?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:

public/dashboard.php — runs unchanged on Apache and ZealPHP
<?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']}";
Why this can't be a framework feature

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.

Runtimeclass_exists(RequestContext)$g->get resolves to$_GET
ZealPHP — coroutine modetrueper-coroutine RequestContext::$getempty (by design)
ZealPHP — superglobals modetrueRequestContext::$get → bridged to $_GETpopulated (v0.2.27+)
Apache + mod_phpfalseshim's &$_GET referencepopulated 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)

.htaccess
# URL /about → public/about.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}\.php -f
RewriteRule ^(.+)$ $1.php [L]
app.php — built-in, zero user code
// 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)

.htaccess
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]
app.php
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)

.htaccess
# 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]
app.php
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

.htaccess
# /api/v1/users/42 → /api/index.php
# with REQUEST_URI preserved
RewriteRule ^api/(.*)$ /api/index.php [L,QSA]
app.php
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

.htaccess
RewriteRule ^admin/?$         /admin/login.php       [L]
RewriteRule ^admin/users$     /admin/users/index.php [L]
RewriteRule ^checkout/done$   /shop/thankyou.php     [L]
app.php
$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

.htaccess
# WordPress: prevent direct hits on wp-includes/*.php
RewriteRule ^wp-includes/(.+\.php)$ - [F,L]
app.php
// Refuse with 403 (universal return contract — int = status).
$app->nsPathRoute('wp-includes', '{rest}(\.php)?', fn() => 403);

Recipe G — HTTPS canonical scheme

.htaccess
RewriteCond %{HTTPS} off
RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
app.php — middleware
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)

.htaccess
RewriteCond %{HTTP_HOST} !^www\. [NC]
RewriteRule (.*) https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
app.php — middleware (same shape as G)
// Inspect Host header; if missing www., 301 to the www form.
// See Recipe G for the full middleware skeleton.

Recipe I — Maintenance mode

.htaccess
RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.42$
RewriteRule .* /maintenance.html [R=503,L]
app.php — middleware
// 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)

.htaccess
ErrorDocument 404 /custom-404.php
ErrorDocument 500 /custom-500.php
app.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)

.htaccess
RedirectMatch 301 ^/old-section/(.*)$ /new-section/$1
RewriteRule ^blog/(.*)$ /articles/$1 [R=301,L]
app.php
$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

.htaccess
RewriteCond %{REQUEST_URI} !(\.[a-zA-Z]+|/)$
RewriteRule (.*) /$1/ [R=301,L]
app.php — built-in
// 301 to the trailing-slash form for directories under public/.
App::$directory_slash = true;

Recipe summary

PatternZealPHP equivalent
A — Strip .phpBuilt-in: App::$ignore_php_ext = true
B — Pretty URL → .php + parampatternRoute + App::include + $g->get
C — Front controllersetFallback(fn() => App::include('/index.php'))
D — API prefixnsPathRoute('api', '{path}', ...) + App::include
E — Specific file mapping$app->route('/url', fn() => App::include('/file.php'))
F — Block direct accessnsPathRoute(...) => 403 (int = status)
G/H — HTTPS / www canonicalPSR-15 middleware
I — Maintenance modePSR-15 middleware + App::include
J — Error pagessetErrorHandler(N, fn() => App::include('/...'))
K — SEO 301patternRoute + $response->redirect(..., 301)
L — Trailing slashBuilt-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 directiveZealPHP equivalentSupport
php_value upload_max_filesize 512Mini_set('upload_max_filesize', '512M'); in app.php boot, or php.ini
ServerSignature OffNo-op — OpenSwoole sends no server-signature footer
Options -IndexesNo-op — ZealPHP never lists directories
AddDefaultCharset utf-8App::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.phpBuilt-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

ApacheZealPHP
<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

ApacheZealPHP
AuthType Basic + AuthName + AuthUserFile + RequireBasicAuthMiddleware — 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 YBasicAuthMiddleware config
<Limit>, <LimitExcept>✅ Route methods array
<RequireAll>, <RequireAny>, <RequireNone>, SatisfyBasicAuthMiddleware 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)

ApacheZealPHP
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 / mergeHeaderMiddleware — declarative add() / set() / unset() with conditional variants
RequestHeaderHeaderMiddleware, request-side variant
ErrorDocument N /foo.php$app->setErrorHandler(N, fn() => App::include('/foo.php')) — Recipe J
AddDefaultCharset, AddCharsetCharsetMiddleware — 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, LanguagePriorityContentNegotiationMiddleware 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 XMimeTypeMiddleware 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

ApacheZealPHP
DirectoryIndex index.php index.htmlApp::$directory_index = ['index.php', 'index.html']
DirectorySlash OnApp::$directory_slash = true
FallbackResourceApp::setFallback(fn() => ...)
DirectoryIndexRedirect⚠ Trivial via redirect in fallback
ExpiresActive, ExpiresByType, ExpiresDefault (mod_expires)ExpiresMiddlewareExpires: 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

ApacheZealPHP
Allow from X, Deny from Y, Order Allow,DenyIpAccessMiddleware — 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

ApacheZealPHP
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

CategoryZealPHP 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 ✅ CharsetMiddlewareMimeTypeMiddleware
Headers & cookies✅ Cookie + HeaderMiddleware (declarative response-header manipulation)
Cache & expiresExpiresMiddleware + CacheControlMiddleware (extension-based static-asset caching)
ETag✅ built-in
Compression✅ OpenSwoole http_compression
Error documentsApp::setErrorHandler()
Range / conditional requestsRangeMiddleware + ETagMiddleware
HTTP Basic AuthBasicAuthMiddleware (htpasswd file or callback verifier)
LDAP / Digest auth❌ niche — PHP extension integration documented
Env vars per request💡 PHP-level inline
IP allow/denyIpAccessMiddleware (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

nginxZealPHP / 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

nginxZealPHP
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

nginxZealPHP
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

nginxZealPHP / 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_connRateLimitMiddleware (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_fileBasicAuthMiddleware
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_headersApp::$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:

Apache ErrorDocument equivalent
$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.

Apache / nginx config
ZealPHP app.php
// Output will appear here...
CLI usage (also available as a command-line tool)
# 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:

app.php — 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

  1. Create a ZealPHP project: composer create-project zealphp/project my-wordpress
  2. Download WordPress into public/: cd my-wordpress/public && wp core download
  3. Configure public/wp-config.php with your database settings
  4. Write app.php as shown above
  5. Start: php app.php (or php app.php start -p 9501 -d to daemonize)
  6. Visit http://localhost:9501/wp-admin/install.php to 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 shared App::executeFile() core. Captures output, applies the universal return contract.
  • Superglobals mode (App::superglobals(true)) — dispatches each public/*.php to a warm src/cgi_worker.php drawn from the cgiMode('pool') worker pool (the default), or forwards to an external FastCGI / php-fpm pool under cgiMode('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.
How App::include() works (superglobals mode, cgiMode('pool'))
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

FeatureHow
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 valueSerialised over stderr metadata; threaded through the universal return contract
exit() / die()register_shutdown_function flushes output and metadata
SSE streamingDetects text/event-stream; streams via flush() like Apache
Static filesServed directly by OpenSwoole — never reaches PHP
File uploads / Sessions$_FILES via context; PHP native sessions work in CGI process

CLI Management

CLI commands
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

ModeApache equivalentnginx equivalentZealPHP
'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 — default (no registration needed)
// .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'));
.pl — Perl via proc with interpreter
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.
.py — Python FastCGI (warm pool, language-agnostic)
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.
.cgi — direct shebang execution, scoped to /cgi-bin
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

Mark a whole URL prefix executable, any extension
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.

Inspect backend resolution + ExecCGI gate
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.

Boot — all .php forwarded to 127.0.0.1:9000 (php-fpm default)
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_pass deployment and want a drop-in shape change rather than a code rewrite. The fcgi_params array on App::registerCgiBackend() mirrors nginx's fastcgi_param directive.

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

app.php — WordPress with cgiMode('fork') (experimental)
<?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

ConcerncgiMode('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 as HTTP_PROXY in the CGI env (src/App.php:2830-2836). Apache util_script.c:224-227 parity. Prevents the well-known PHP/Go/Python CGI library family that reads HTTP_PROXY to choose an outbound proxy from being hijacked by a hostile client.
  • CGI script timeout — App::$cgi_timeout default 60 s (src/App.php:273). When a CGI subprocess exceeds the budget the worker escalates SIGTERMSIGKILL and returns control to the request handler. Apache CGIScriptTimeout parity. Override by setting the public static property: App::$cgi_timeout = 120; before App::init().
  • CGI Status: header parsed from stdout (src/cgi_worker.php:101-113) — a legacy script that emits Status: 404 Not Found\r\n sets the response status to 404 and the Status: 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.log via the cgi_worker log 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.

APIWhat it does
App::exec(string $cmd, ?float $timeout = null): arrayCoroutine-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): ?stringExplicit 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_execToggles 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.