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, superglobals(true) + enableCoroutine(true) is fully safe — $_GET/$_SESSION are saved/restored per coroutine. 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 + enableCoroutine(true), 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.
v0.3.0: superglobals + coroutines now safe with ext-zealphp

With ext-zealphp loaded, superglobals(true) + enableCoroutine(true) is fully supported. The extension saves and restores $_GET / $_POST / $_SESSION per coroutine — no races, no leaks. Legacy code using $_GET just works with concurrent coroutine I/O.

Without ext-zealphp (uopz fallback), the old constraint applies — these combinations throw RuntimeException at boot to prevent process-wide superglobal races:

  • superglobals(true) + enableCoroutine(true) without ext-zealphp
  • superglobals(true) + hookAll(non-zero) without ext-zealphp

Migration path: install ext-zealphp (pie install zealphp/ext), set App::superglobals(true), and enable coroutines. 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 $xS5a; 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=falsepi=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 file-execution family 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 + enableCoroutine(true): ✅ 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 even with enableCoroutine(true). $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 + enableCoroutine(true): ✅ 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).