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.

Coroutines

OpenSwoole coroutines are cooperative — they yield only on I/O, making parallel fetch trivial. ZealPHP enables HOOK_ALL so most PHP I/O (file, curl, sleep) becomes coroutine-aware automatically.

GET Parallel fetch — 3 coroutines in 1s not 3s
$app->route('/demo/coroutine/parallel', function() {
    $ch    = new Channel(3);
    $start = microtime(true);

    go(fn() => [$ch->push(simulated_fetch('users',  1))]);
    go(fn() => [$ch->push(simulated_fetch('orders', 1))]);
    go(fn() => [$ch->push(simulated_fetch('stats',  1))]);

    $results = [];
    for ($i = 0; $i < 3; $i++) $results[] = $ch->pop();

    return ['results' => $results, 'elapsed_s' => round(microtime(true) - $start, 3)];
    // All 3 run in parallel → ~1s total, not 3s
});
LIVE OUTPUT Click Run →
GET Channel — producer/consumer pattern
$app->route('/demo/coroutine/channel', function() {
    $ch = new Channel(1); // buffer of 1

    go(function() use ($ch) {
        co::sleep(1);
        $ch->push(['value' => 42, 'from' => 'producer coroutine']);
    });

    $result = $ch->pop(); // blocks until producer pushes
    return ['received' => $result, 'pattern' => 'producer/consumer'];
});
LIVE OUTPUT Click Run →

How it works

PrimitivePurpose
go(callable)Spawn a coroutine. Runs concurrently when current coroutine yields.
co::sleep(float $s)Yield for N seconds without blocking the event loop.
new Channel(int $capacity)Buffered queue for coroutine communication. push() + pop().
usleep(int $us)Coroutine-aware micro-sleep under HOOK_ALL (use for sub-second delays).
OpenSwoole\Runtime::HOOK_ALLMakes most PHP I/O — curl, file, sleep — yield the event loop. PDO is not hooked in OpenSwoole 22.1–26.2.
Coroutines work in both modes. With ext-zealphp, App::mode('coroutine-legacy') runs superglobals code under coroutines — $_GET/$_SESSION are saved/restored per coroutine, and the preset isolates statics and globals too. With superglobals(false) (scaffold default), each coroutine gets isolated RequestContext::instance() state. Either way, every request runs in its own coroutine.

$g vs $_* — request state in both modes

One-line rule. Use $g->get, $g->post, $g->cookie, $g->server, $g->session, $g->files (where $g = RequestContext::instance()). It works identically in both modes — zero overhead, always safe. With ext-zealphp, $_GET/$_SESSION are also per-coroutine safe in both modes, so legacy code using superglobals works unchanged.

⚠ Direct $_SESSION (and $_GET / $_POST) under plain superglobals(false) is a concurrency bug. Without ext-zealphp these superglobals are process-wide — a read in one coroutine can see data written by another, so $uid = $_SESSION['user_id'] may return a different user’s id under load (issues #272 / #273). Always go through $g->session (or your framework’s Session::* API, which should delegate to it); those are per-coroutine in every mode. The safe pattern for snapshotting session state into a dispatched task/worker is RequestContext::instance()->session, never $_SESSION. In coroutine-legacy (ext-zealphp) the extension isolates all seven superglobals — including $_SESSION — per coroutine, so direct $_SESSION access becomes safe there.

The two forms diverge on one axis: how the framework populates them per request.

Mode$g->get / $g->post / …$_GET / $_POST / …
App::mode(App::MODE_MIXED)
legacy / migration mode
✅ Bridged to $GLOBALS['_GET'] etc. on each request — both forms read & write the same backing array, so they're observationally equivalent. ✅ Repopulated per request by the framework. With ext-zealphp + mode('coroutine-legacy'), superglobals are saved/restored per coroutine — concurrent requests are safe. Without ext-zealphp, sequential mode (one request at a time per worker).
App::mode(App::MODE_COROUTINE)
coroutine mode (recommended default)
✅ A per-coroutine typed property on RequestContext, stored on Coroutine::getContext(). Isolated per request even when thousands run concurrently in the same worker. ⚠️ Not populated by default. With ext-zealphp + zealphp_coroutine_superglobals(true), they become per-coroutine safe (saved/restored on yield/resume). Without ext-zealphp, they’re process-wide and leak across coroutines.

Why $g is the recommended convention: $g->get works in every mode with zero overhead — no extension required. With ext-zealphp, $_GET also works in both modes (per-coroutine safe). Either form is valid; $g->get is recommended because it works universally regardless of whether ext-zealphp is installed.

The same code, both modes
use ZealPHP\RequestContext;

$app->route('/article/{id}', function ($id) {
    $g = RequestContext::instance();

    // Recommended — always works in both modes:
    $g->get['id'] = $id;
    $g->server['PHP_SELF'] = '/article.php';

    // Also works with ext-zealphp (per-coroutine safe in both modes):
    //     $_GET['id'] = $id;
    //     $_SERVER['PHP_SELF'] = '/article.php';

    return App::include('/article.php');
});

Pages that touch request state link here rather than restating this rule: Legacy Apps (the Apache rewrite recipes), Sessions, Routing, and the API layer.

Lifecycle modes — pick a preset, or tune the two axes

The runtime is described by two orthogonal axes, with a one-call preset on top:

  • App::superglobals(bool) — real process-wide $_GET/$_POST/$_SESSION populated per request (true) vs per-coroutine $g only (false).
  • App::isolation(Isolation) — how a request is isolated. One value folds the old processIsolation × enableCoroutine × hookAll × cgiMode cross-product: Isolation::Coroutine (in-process, per-coroutine, concurrent), Isolation::CgiPool (warm pre-spawned PHP worker pool, interpreter resident, ~1-3 ms — the default for superglobals(true)), Isolation::CgiProc (proc_open subprocess per request, ~30-50 ms cold start — recursion-safe fallback for fresh-process semantics without a pre-spawned pool), Isolation::CgiFcgi (forward to an external FPM/upstream), or Isolation::None (in-process, sequential).

App::mode(string) sets both axes in one call. This is the recommended entry point — reach for the individual setters only to fine-tune:

App::mode(...)superglobalsisolationFor
App::MODE_COROUTINE
'coroutine'
falseCoroutineModern ZealPHP apps — the recommended default shape (scaffold default).
App::MODE_COROUTINE_LEGACY
'coroutine-legacy' · EXPERIMENTAL
trueCoroutineExperimental. Legacy request-style code run concurrently (the PHP-FPM mental model, modernised); modern Composer apps (Symfony, Laravel, Slim). Auto-enables the full per-coroutine isolation stack (see isolation scope). Requires ext-zealphp. Provided the class graph is Composer-autoloaded or preloaded; pure require_once apps with no autoloader (classic WordPress) use MODE_LEGACY_CGI.
App::MODE_LEGACY_CGI
'legacy-cgi'
trueCgiPoolUnmodified WordPress / Drupal — pure require_once apps with no autoloader. Each request runs in a warm subprocess pool (full global-scope isolation).
App::MODE_MIXED
'mixed'
trueNoneSymfony / Laravel bridge — real $_SESSION, in-process, sequential, no CGI fork cost.

Each axis decomposes into individually-exposed knobs (since v0.2.23). They default to null and resolve to "follow App::$superglobals" at App::run() time, so apps that only call App::mode() (or nothing) see no surprises:

KnobSetternull resolves toWhat it controls
$superglobals App::superglobals(bool) — (no default) $g storage strategy: process-wide PHP superglobals (true) vs per-coroutine RequestContext (false). Also picks SessionManager (true) vs CoSessionManager (false).
$process_isolation App::processIsolation(bool) $superglobals App::include() dispatch: true → subprocess per file via cgiMode() backend (default 'pool' — warm FPM-style worker pool with the interpreter resident in memory, ~1-3 ms per dispatch; 'fcgi' — forward to an external upstream FPM pool). false → in-process via executeFile() (sub-ms, no isolation).
$enable_coroutine_override App::enableCoroutine(bool) !$superglobals OpenSwoole's enable_coroutine server setting — auto-coroutine-per-request wrapper. false → workers handle one request at a time synchronously.
$hook_all_override App::hookAll(bool|int) !$superglobals (HOOK_ALL or 0) OpenSwoole\Runtime::enableCoroutine($flags) — process-wide PHP I/O hooks (curl, fopen, mysqli). PDO is not hooked in OpenSwoole 22.1 / 26.2 regardless.
$session_lifecycle App::sessionLifecycle(bool) true Whether SessionManager / CoSessionManager drive the per-request session lifecycle (session_start, cookie emission, session_write_close). Set to false when another framework (Symfony's NativeSessionStorage via the zealphp-symfony bridge) owns sessions.

Supported mode matrix

The isolation column is the resolved App::isolation() value; the knob columns it expands to (processIsolation / enableCoroutine / hookAll) are listed in the fine-grained table above.

ModeApp::mode()superglobalsisolationWhen to use
Coroutine
scaffold default
MODE_COROUTINEfalsecoroutineModern apps benefiting from concurrent coroutine I/O; OpenSwoole-native code.
Coroutine-legacy
compatibility runtime · EXPERIMENTAL
MODE_COROUTINE_LEGACYtruecoroutine
+ isolation stack
Experimental. Traditional request-style PHP run concurrently — every request-state primitive ($GLOBALS, statics, require_once, …) isolated per coroutine. Modern Composer apps too. Requires ext-zealphp. See isolation scope — provided the class graph is Composer-autoloaded or preloaded; pure require_once apps with no autoloader (classic WordPress) use MODE_LEGACY_CGI.
Legacy CGI
default when superglobals(true)
MODE_LEGACY_CGItruecgi-poolUnmodified WordPress / Drupal — pure require_once apps, no autoloader. Warm subprocess pool (FPM-style isolation, ~1-3 ms); raise cgiPoolSize() for more CGI concurrency.
Mixed-mode / SymfonyMODE_MIXEDtruenoneSymfony / Laravel on ZealPHP — real $_SESSION needed, but no per-include CGI fork cost. Sequential per worker → no race on superglobals.
Coroutine without HOOK_ALLtune hookAll(0)falsecoroutinePer-request coroutine isolation but no auto I/O hooks (testing, custom hooks, blocking-I/O legacy apps that deadlock under HOOK_ALL).
Coroutine + Process Isolation
hybrid — explicit processIsolation(true)
tunefalsecgi-pool
+ coroutine parent
Modern coroutine app with occasional isolated legacy PHP. Parent runs coroutines + dispatches concurrently to pool workers; each public/*.php gets full subprocess isolation. See the hybrid explainer below.
Superglobals + coroutines: App::mode('coroutine-legacy')

With ext-zealphp loaded, the coroutine-legacy preset runs superglobals code with coroutine concurrency. The extension saves and restores $_GET / $_POST / $_SESSION per coroutine, and the preset also isolates statics and globals, which concurrent requests would otherwise share.

Setting superglobals(true) with coroutines by hand throws RuntimeException at boot unless that isolation stack is on (since v0.4.20; without ext-zealphp it always throws):

  • superglobals(true) + enableCoroutine(true)
  • superglobals(true) + hookAll(non-zero)

Migration path: install ext-zealphp (pie install zealphp/ext) and use App::mode(App::MODE_COROUTINE_LEGACY). Your existing $_GET / $_SESSION code works unchanged with full concurrency.

Isolation scope — depends on the mode. The bare superglobals(true) + isolation(coroutine) combo isolates the 7 PHP superglobals ($_GET, $_POST, $_COOKIE, $_SERVER, $_FILES, $_REQUEST, $_SESSION — that's S1 Superglobals) plus header() / setcookie() response state per coroutine. App::mode(App::MODE_COROUTINE_LEGACY) goes much further — it auto-enables the full per-coroutine isolation stack so traditional request-style PHP runs concurrently with every request-state primitive isolated: $GLOBALS / global $x (including object-valued globals like $wpdb, ext 0.3.23) — S2 Globals; function-local static $x — S5a; conditional function/class re-declaration (first wins) — S3 Redeclare; and per-request require_once / include_once re-execution — S7 Include-once. Each stage is also a standalone fluent setter (App::coroutineGlobalsIsolation() (S2), App::coroutineStaticsIsolation() (S5a), App::silentRedeclare() (S3), App::includeIsolation() (S7)), default off outside coroutine-legacy. define() constant isolation (S10 Constants) is a separate opt-in via App::defineIsolation(true) (not auto-enabled by the preset). What it does NOT isolate: live DB connections and resource handles (process-shared by nature) — use one connection per coroutine. For pure require_once apps with no autoloader (classic unmodified WordPress), the race-free home is App::mode(App::MODE_LEGACY_CGI) (process-isolated). See the mode matrix below. Stage names (S1–S12) follow the canonical taxonomy in docs/architecture/isolation-stages.md.

Mode 6 — Coroutine + Process Isolation (the hybrid)

With ext-zealphp, both combos are safe (superglobals are saved/restored per coroutine). Without it, they’re gated on superglobals(true). With superglobals(false) the parent uses per-coroutine $g, so neither race exists either way — which means you can also combine coroutine concurrency at the parent with per-request subprocess isolation:

App::superglobals(false);     // per-coroutine $g (safe — no race)
App::processIsolation(true);  // public/*.php → CGI pool subprocess
App::enableCoroutine(true);   // parent runs coroutines (resolved from null)
App::hookAll(\OpenSwoole\Runtime::HOOK_ALL); // hooks pipe I/O (resolved from null)
// cgiMode('pool') is the default since v0.2.41 — warm FPM-style worker pool

What you get:

  • Parent worker: N concurrent coroutines in flight, each with its own $g context. Routes, API, middleware run at full coroutine speed.
  • App::include('/wp-login.php'): the parent pops a pool worker from its Coroutine\Channel, writes the request frame over stdin, and yields on the pipe read (HOOK_ALL hooks it). The scheduler runs other coroutines while the subprocess executes.
  • Multiple coroutines dispatch in parallel: each coroutine pops a different pool worker (channel queue), up to cgiPoolSize (default 4). True request-level concurrency through the CGI path.
  • Inside the subprocess: real $_GET / $_POST / $_SERVER / $_COOKIE / $_REQUEST populated per request, reset to clean state between requests. Full global-scope isolation per request — define() calls in a WordPress plugin don't leak across requests.

What you do NOT get (honest caveat):

  • Coroutines do not run INSIDE the pool subprocess — each subprocess handles one request at a time, sequentially. To scale CGI concurrency, raise cgiPoolSize(), not enable a scheduler inside the subprocess (that would re-introduce the superglobals-race bug at a different layer).
  • This is not the default. You must explicitly set App::processIsolation(true) — otherwise processIsolation resolves from null to follow sg=false → pi=false (no isolation). The defaults assume you either want full coroutine speed (no isolation) OR full superglobal compat (Legacy CGI mode); the hybrid is for the modern-mostly-with-legacy-pockets case.

Pinned by 13 lifecycle tests + 12 cgiPool tests at tests/Unit/LifecycleModesMatrixTest.php and tests/Unit/CGI/CgiPoolDispatchTest.php. See also "What cgiMode('pool') buys you" on the legacy-apps page for measured pool overhead.

The default coupling — null everywhere — preserves the historical behaviour for any app that doesn't touch these knobs. The zealphp-symfony bridge uses superglobals(true) + processIsolation(false) + sessionLifecycle(false) to get the Mixed-mode lifecycle.

What survives a request

Long-running PHP changes the rules from PHP-FPM. This is the discipline contract you accept when running on ZealPHP — what the framework isolates for you, and what you have to keep clean yourself.

Isolated per coroutine — framework handles this

In coroutine mode (App::superglobals(false), scaffold default since v0.2.4), RequestContext::instance() returns an instance stored on Coroutine::getContext($cid). It's allocated when the coroutine starts and freed when it ends. Every field on it is per-request:

FieldPurpose
$g->get, $g->post, $g->cookie, $g->files, $g->server, $g->requestRequest inputs — populated by the session manager on request entry
$g->sessionSession data — loaded from the file-backed store on entry, written back on exit
$g->statusHTTP status code being prepared
$g->zealphp_request, $g->zealphp_responsePSR-7 request/response wrappers
$response->headersList, $response->cookiesList, $response->rawCookiesListOutbound headers/cookies pending emission (on the Response object since v0.2.6)
$g->error_handlers_stack, $g->exception_handlers_stack, $g->shutdown_functionsHandler stacks pushed via set_error_handler() / register_shutdown_function() — freed when the coroutine ends, so legacy code that re-registers per-request can't accumulate handlers
Any local variable inside your handlerStack-allocated, dies when the handler returns. Safe.

NOT isolated — lives in worker process memory until the worker recycles

The following survive every coroutine boundary and every request boundary. The framework cannot isolate them. Treat them as worker-lifetime state.

PatternWhy it leaksWhat to do
function foo() { static $cache = []; ... } Static-in-function lives in the function's symbol table, which is process-scoped. (coroutine-legacy isolates these per coroutine — S5a — and resets them to the boot template per request — S11b.) Don't use it for request-scoped data. Use a local variable or a property on $g.
class MyService { private static $instance; } Class-level statics live on the class, which is loaded once per worker. (coroutine-legacy isolates class statics per coroutine — S5b — and resets them per request — S11c.) Treat any class static as cross-request state. Singletons are worker-lifetime.
OpenSwoole\Table rows (via Store) By design — Store is cross-worker shared memory. That's its purpose. OK to use, but never store per-request data here. Use it for counters, caches, rate-limit windows.
Closures captured by App::tick() / App::after() / App::onWorkerStart() By design — these fire outside any request. Whatever they capture lives until the worker recycles. Capture configuration/handles, not per-request state.
ini_set('date.timezone', ...) and friends Mutates process state. PHP doesn't reset it between requests. (In coroutine-legacy the stage stack isolates these per coroutine: timezone is S9d, ini_set() values S9g, locale S9b, umask S9c, mb encoding S9e, cwd S9a — see isolation scope.) Set globally at boot (in app.php before App::run()) or accept that the change is sticky. Don't ini_set() per request.
OPcache compiled bytecode Process-wide. Deploys need a worker restart (or php app.php restart) for the new code to load. See the deploy guide. opcache.validate_timestamps=0 + restart-on-deploy is the production pattern.
Pooled DB / Redis connection state A pool keeps connections alive across requests. BEGIN without COMMIT, SET SESSION sql_mode, CREATE TEMPORARY TABLE all survive on the connection. If you pool, always reset on checkout: ROLLBACK, restore sql_mode, deallocate prepares. (A ZealPHP\Pool helper with this baked in is on the v0.3 roadmap.)

The discipline contract

ZealPHP's per-request isolation is a discipline contract, not a runtime guarantee. The framework isolates what it owns (everything in RequestContext); it can't isolate what your code puts in static $foo or private static $instance. That state lives in worker process memory and survives every coroutine boundary, until the worker recycles.

This is the same trade-off every long-running PHP runtime makes. Hyperf and RoadRunner both ship worker recycling for exactly this reason — the surface area of state outside the framework's request-scoped object is too large to audit programmatically. The trust story is isolation + recycling, not either alone.

The backstop — worker recycling (max_request)

ZealPHP defaults to max_request=100000 since v0.2.4. After a worker handles 100,000 requests, OpenSwoole sends it SIGTERM, drains the current request, and the manager process forks a fresh worker. All process state — static variables, accumulated closures, leaked memory, the lot — is reset to zero. The TCP listener stays open via the manager, so no requests are dropped during the handoff.

Tuning knobs:

KnobHow to setWhen to change
ZEALPHP_MAX_REQUEST (env var)ZEALPHP_MAX_REQUEST=50000 php app.phpTighter window if you know your app leaks; looser if your perf budget can't afford 100k-request fork churn
$app->run(['max_request' => N])Code-level override in app.phpSame as env var, but checked in
ZEALPHP_MAX_REQUEST=0Env varDisable recycling entirely (don't, unless you're benchmarking)

Safety matrix (per mode)

With ext-zealphp, both modes support coroutines — the distinction is about how request state is stored, not whether coroutines are available. superglobals(false) uses per-coroutine RequestContext; superglobals(true) uses PHP superglobals with ext-zealphp saving/restoring them per coroutine. Without ext-zealphp, superglobals(true) runs sequentially (one request at a time per worker) in the legacy/mixed shapes; explicitly combining superglobals(true) with enableCoroutine(true) (or a non-zero hookAll) throws a RuntimeException at boot rather than silently degrading — install ext-zealphp to run that combination (it is coroutine-legacy mode). Implicit file routes in processIsolation(true) mode run through the CGI bridge for true global-scope isolation. See the rendering methods and the universal return contract.

ConcernCoroutine mode
(App::superglobals(false), scaffold default)
Superglobals mode
(App::superglobals(true))
Concurrency model Coroutine scheduler enabled, HOOK_ALL active; thousands of concurrent requests per worker With ext-zealphp + mode('coroutine-legacy'): ✅ full coroutine concurrency (superglobals saved/restored per coroutine). Without ext-zealphp: sequential, one request at a time per worker.
Implicit file routes (legacy public/*.php) Run in the worker process directly (processIsolation(false) default) With processIsolation(true): CGI bridge (warm cgiMode('pool') worker, or 'fcgi' to an external FPM) — true global-scope isolation. With processIsolation(false): in-process, same as coroutine mode.
$g->session, $g->status, etc. ✅ Per-coroutine via Coroutine::getContext(), isolated ⚠ Process-wide singleton; framework resets per-request, but write at your own risk
$_GET, $_POST direct access
(see the parity rule)
⚠️ Not populated by default. With ext-zealphp coroutine hooks: per-coroutine safe. $g->get / $g->post recommended (always safe, zero overhead). ✅ Populated per request. With ext-zealphp: per-coroutine safe under mode('coroutine-legacy'). $g->get / $g->post also work — they bridge to the same arrays.
header(), setcookie() via ext-zealphp ✅ Writes to per-coroutine $response->headersList ⚠ Writes to the single in-flight request's response — synchronous, no cross-request bleed because requests don't overlap in a worker
set_error_handler() / register_shutdown_function() ✅ Stack lives on per-coroutine RequestContext, freed on coroutine end ⚠ Process-wide stack; legacy code that re-registers per-request would accumulate handlers, but SessionManager explicitly resets the stacks at request entry (fixed in v0.2.10)
go() inside a request handler ✅ Allowed and recommended for parallel I/O With ext-zealphp + mode('coroutine-legacy'): ✅ works — coroutine scheduler is active. Without ext-zealphp (sequential mode): ❌ scheduler not running.
static $cache = [] in user functions ❌ Survives across coroutines until worker recycles — requires the max_request backstop ❌ Same — survives across requests until worker recycles
OpenSwoole\Table mid-write atomicity Single set() is atomic at the C level; multi-call updates to the same row are not transactional. incr / decr / compareAndSet are atomic. SIGKILL mid-write may leave the row's spinlock held — graceful shutdown (including max_request recycle) releases cleanly. Use Store as best-effort cache, not a database. Same

Common patterns

I want to…Do thisNot this
Cache something for the duration of one request $cache = [] as a local variable, or property on $g static $cache = [] inside a function
Share state across requests in the same worker Class-level static, but reset/clear at known points — or Store with explicit row expiry Class static that grows unbounded
Share state across all workers Store (OpenSwoole\Table) or Counter (OpenSwoole\Atomic) Class static (each worker has its own copy)
Run a one-time init when a worker starts App::onWorkerStart(function() { ... }) Boot-time singleton + first-request init race
Schedule a recurring task App::tick($ms, $fn) inside onWorkerStart Sleep loop in a request handler
Want to dig deeper? See Store & Cache for shared-memory semantics, Migration for the lift-and-shift path, and Deploy for production tuning (opcache settings, supervisor config, worker counts).