Runtime Architecture
ZealPHP wraps OpenSwoole’s event-driven HTTP server with a framework that feels familiar to PHP developers while enabling coroutine-friendly patterns. This document highlights the moving parts that collaborate during a request, how state is isolated, and how to opt into advanced execution modes.
Configuration via environment: every runtime knob below can also be driven by a
ZEALPHP_*environment variable. The complete, code-verified list (defaults, scope, andenv_flagsemantics) lives indocs/environment-variables.md; the CLI-relevant subset is indocs/cli.md.
Bootstrapping
App::init() performs one-time initialization:
- Requires either ext-zealphp (recommended) or uopz to be loaded — both can intercept built-in functions such as
header()orsetcookie(). If neither is present, the constructor throws immediately with a message recommendingpie install zealphp/ext. - Records the current working directory and entry script so the framework can build absolute paths later.
- Prepares the PSR-15 middleware stack (
OpenSwoole\Core\Psr\Middleware\StackHandler) withResponseMiddlewareas the terminal handler. - Configures coroutine hooks if superglobals are disabled (see below).
- Overrides built-in PHP functions to route them through ZealPHP shims — using
zealphp_override()(ext-zealphp, preferred) when available, oruopz_set_return()(uopz, fallback) otherwise. This ensures headers, cookies, and response codes cooperate with the PSR response pipeline. The override family coversheader()/headers_list()/setcookie()/http_response_code(), allsession_*()functions, and — when the exec hook is on — the backtick operator,shell_exec,exec,system, andpassthru(see below). - Optionally installs the coroutine-safe exec hook. When
App::hookExec()resolves totrue(default-on in coroutine mode), ZealPHP overridesshell_exec,exec,system,passthru, and the backtick operator (the backtick compiles to ashell_exec()call, so overridingshell_execintercepts it transparently) atApp::exec()— so legacy/user code that shells out becomes coroutine-safe with no source changes. Same override mechanism as above (ext-zealphp preferred, uopz fallback).proc_open/popenare intentionally not overridden:App::rawExec()and the CGI subprocess path rely onproc_open, so leaving it untouched keeps the fallback recursion-safe. Toggle withApp::hookExec(bool); pass no arg to read the resolved value. See Coroutine-safe exec below.
App::run() then constructs the OpenSwoole HTTP server, includes custom route files, registers implicit routes, wires session managers, and starts the event loop. Pass an array of OpenSwoole settings to override defaults:
$app->run([
'enable_static_handler' => false,
'task_worker_num' => 8,
'document_root' => __DIR__ . '/public',
]);
ZealPHP merges your configuration with its defaults (enable_static_handler: true, task_worker_num: 4, pid_file: /tmp/zealphp.pid, etc.) and forces enable_coroutine based on the superglobal mode.
Document root. The
document_rootshown above is OpenSwoole's underlying static-handler setting. The framework-level way to set it isApp::documentRoot('public')— the ApacheDocumentRootequivalent: the folder every implicit route and the static handler resolve against, defaulting topublic/. Set it (like allApp::*config) beforeApp::init();App::run()resolvesApp::$document_rootinto thisdocument_rootsetting for you, so most apps use the setter and never passdocument_rootby hand. See routing.md and directory-structure.md.
Superglobals and the G Container
Gis an alias;ZealPHP\RequestContextis the class.Gis the short, conventional name for the per-request Global state container. The class was originally namedG, renamed toRequestContextin v0.2.6, withclass_alias(RequestContext::class, 'ZealPHP\G')(at the bottom ofRequestContext.php) keeping the short name working forever.G::instance()and the$gvariable are the everyday accessors — type against\ZealPHP\Gor\ZealPHP\RequestContext, they're literally the same class. In the API reference it's documented under its real name,RequestContext: a runtimeclass_aliashas no source declaration of its own, so phpDocumentor can't giveGa separate page.
Traditional PHP scripts rely on $_GET, $_POST, $_SERVER, etc. ZealPHP emulates this behaviour while running inside an event loop by funneling state through ZealPHP\RequestContext (alias G):
- When
App::$superglobalsis true (default), each request reconstructs the real PHP superglobals before executing route handlers. TheGcontainer proxies get/set operations so legacy code “just works.” - When
App::superglobals(false)is called, ZealPHP stops mutating global arrays and instead uses coroutine-safe properties on theGinstance. In this mode, OpenSwoole coroutine hooks are enabled and you can safely usego()from within the main request handler. Access request data through$g = G::instance(); $g->get,$g->server, etc.
G owns additional request-scoped values:
zealphp_request/zealphp_response– The wrapped OpenSwoole request/response objects exposed to route and API handlers.status– The HTTP status code chosen by the handler (defaults to 200).
Request Lifecycle
- Session Manager: Each incoming request is wrapped in either
Session\SessionManagerorSession\CoSessionManagerdepending on the superglobal mode. The manager drivessession_start(), associates the request withG, and ensuressession_write_close()always runs. - Middleware Stack: The OpenSwoole request is converted to a PSR-7 request (
OpenSwoole\Core\Psr\ServerRequest::from(...)). ZealPHP walks the middleware stack in reverse registration order untilResponseMiddlewareexecutes. - Route Matching:
ResponseMiddlewareevaluates the registered routes in the order they were defined. It supports:- Exact routes (
$app->route('/foo', ...)) - Namespaces (
$app->nsRoute('admin', '/dashboard', ...)) - Path-based namespaces (
$app->nsPathRoute('api', '{module}/{action}', ...)) - Regular expressions (
$app->patternRoute('/raw/(?P<rest>.*)', ...))
- Exact routes (
- Handler Invocation: Parameters captured from the URI are injected into the handler based on argument names. ZealPHP also recognises three special parameters:
app(theResponseMiddlewareinstance),request(ZealPHP\HTTP\Requestwrapper), andresponse(ZealPHP\HTTP\Responsewrapper). To reach the underlying OpenSwoole server, useApp::getServer()— it is not an injectable parameter. - Response Resolution:
- If the handler returns an
OpenSwoole\Core\Psr\Response, ZealPHP emits it immediately. - If it returns an
int, the value becomes the HTTP status code. - If it returns an array/object, ZealPHP serialises it to JSON and sets
Content-Type: application/json. - Otherwise buffered output (including
echo) is sent as the response body. - Exceptions are caught, logged via
elog(), and transformed into formatted stack traces whenApp::$display_errorsis true.
- If the handler returns an
Prefork Execution
ZealPHP favours a single-request-per-worker model to protect superglobals. When you need to isolate work:
App::cgiMode('pool' | 'proc' | 'fork' | 'fcgi')selects the per-request isolation strategy for legacypublic/*.phpfiles.'pool'(default) uses a pre-spawned subprocess pool (mod_php-style global isolation, ~1–3 ms warm — what unmodified WordPress/Drupal needs).'proc'forks a fresh PHP interpreter per request viaproc_open+cgi_worker.php(~30–50 ms cold start; use when true fresh-process isolation is required every request).'fork'is the Apache MPM prefork runner — a fork-master forks a fresh child per request at true global scope (~1 ms fork cost, EXPERIMENTAL, requirespcntl+posix).'fcgi'forwards to an upstream php-fpm pool viaApp::fcgiAddress()— no child process at all. See tasks-and-concurrency.md for the trade-off table.coprocess()/coproc()create dedicated processes with coroutine support for longer-running workloads that should not block the main worker. These helpers are only available when superglobals are enabled (coprocthrows otherwise).
Custom CGI backends — host any language
App::cgiMode() sets the strategy for .php files framework-wide. To serve other languages — Perl, Python, Ruby, shell, or anything that speaks CGI/1.1 or FastCGI — register a per-extension backend with App::registerCgiBackend(string $extension, array $config) before $app->run(). Unregistered extensions fall back to App::$cgi_mode.
use ZealPHP\App;
// Perl via proc (Apache `AddHandler cgi-script .pl` parity)
App::registerCgiBackend('.pl', [
'mode' => 'proc',
'interpreter' => '/usr/bin/perl',
]);
// Python via FastCGI — forward to a warm Python FCGI daemon
App::registerCgiBackend('.py', [
'mode' => 'fcgi',
'address' => '127.0.0.1:9001', // or unix:/run/python-fpm.sock
'fcgi_params' => ['APP_ENV' => 'prod'], // merged into the CGI env
]);
// .cgi via shebang — the OS reads the #! line, no explicit interpreter
// 'exec_paths' is the ExecCGI scope (see below) — only execute under /cgi-bin
App::registerCgiBackend('.cgi', ['mode' => 'proc', 'exec_paths' => ['/cgi-bin']]);
The supported mode values for registerCgiBackend():
mode |
What it does | Languages |
|---|---|---|
'proc' |
proc_open spawns the interpreter (or reads the #! shebang) per request — Apache CGI semantics |
any (interpreter optional) |
'fork' |
Apache MPM prefork: fork-master forks a fresh child per request at true global scope (~1 ms). EXPERIMENTAL — requires pcntl+posix. |
.php only |
'fcgi' |
forwards to a FastCGI daemon at address (php-fpm, a Python/Ruby FCGI server, …) — no per-request spawn |
any FastCGI/1.0 server |
'pool' |
pre-spawned subprocess pool (~1–3 ms warm) | .php only — passing 'pool' for a non-.php extension throws InvalidArgumentException |
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 uses OpenSwoole\Coroutine\System::exec() (or coroutine-aware
proc_open), which yields to the scheduler instead of blocking the worker,
supports a POST body on the interpreter's stdin, and can stream. The .php
fast path is unchanged (it still uses cgi_worker.php under
processIsolation(true), and the in-process executeFile() core in coroutine
mode). The cgiInterpreterResponse() reader parses a standard RFC 3875 CGI
response off the interpreter's stdout (headers + blank line + body, with a
Status: pseudo-header setting the HTTP status) — Apache mod_cgi parity.
exec_paths — the ExecCGI scope (default-off)
exec_paths lists the URL path prefixes under which a registered extension is
allowed to execute — ZealPHP's parity for Apache's Options +ExecCGI being
off by default. A file whose extension is registered but whose request URL
falls outside every exec_paths prefix is treated as a stray/uploaded
script: it is neither executed nor served as source — the framework returns
403 Forbidden (no source-leak). Omit exec_paths and the extension never
executes via an implicit URL (it is still reachable via App::include(), which
applies its own document-root containment check).
// .py executes ONLY under /cgi-bin/* — an uploaded /uploads/evil.py gets 403
App::registerCgiBackend('.py', [
'mode' => 'proc',
'interpreter' => '/usr/bin/python3',
'exec_paths' => ['/cgi-bin'],
]);
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() — same shape as Apache serving a
script out of a cgi-bin directory.
cgiScriptAlias() — Apache ScriptAlias parity
App::cgiScriptAlias('/cgi-bin', ['mode' => 'proc']) marks a URL prefix as an
executable area: any file served under it is treated as executable regardless
of its extension (mayExecute = true for the whole prefix). Resolution order in
App::resolveCgiBackend($absPath, $urlPath): ScriptAlias prefixes first
(always executable), then the per-extension registry gated by exec_paths, then
an unregistered fallback (['mode' => App::$cgi_mode], mayExecute = false).
Known limitation.
cgiScriptAlias()registers the resolution + ExecCGI scope, but URL-level implicit routing is wired per-extension only. A ScriptAlias-only setup (no matching per-extension backend) is reachable viaApp::include()but does not yet get an automatic/{file}.<ext>route. PaircgiScriptAlias()with aregisterCgiBackend()for the extensions you want auto-routed, or add an explicit route. (Follow-up.)
App::resolveCgiBackend('/path/file.py', '/cgi-bin/file.py') returns
['backend' => [...], 'mayExecute' => bool] for a given path + URL. Full
walkthrough — socket forms, fcgi_params, multiple upstream pools, 502/timeout
behaviour — in the FastCGI backends guide. The
framework-wide 'fcgi' setter (App::cgiMode('fcgi') + App::fcgiAddress())
is the "front an existing php-fpm pool" shortcut for when every
public/*.php should go to one upstream.
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),
ZealPHP's function overrides (ext-zealphp preferred, uopz fallback) intercept
shell_exec, exec, system, passthru, and the backtick operator, routing
them through App::exec() which yields to the scheduler instead of blocking.
Legacy code that shells out works safely with no changes.
App::exec(string $cmd, ?float $timeout = null): array{output, code, signal}— coroutine-safe command execution. Inside a coroutine (Coroutine::getCid() >= 0) it yields to the scheduler viaOpenSwoole\Coroutine\System::exec(); outside one (boot / CLI) it falls back to the blockingApp::rawExec()path. The return shape is identical either way:output(captured stdout),code(exit code),signal(terminating signal,0if none).$timeoutis the coroutine-mode budget in seconds (null= no timeout).App::rawExec(string $cmd): ?string— explicit blocking escape hatch. Returns captured stdout (ornullif the process failed to start). It is built onproc_opendeliberately — never onshell_exec/exec/system/passthru/popen— because those builtins are overridden (ext-zealphp / uopz) when the exec hook is on; routing throughproc_open(which is not overridden) keeps this escape hatch recursion-safe.App::hookExec(?bool)/App::$hook_exec— toggles the transparent override described in Bootstrapping.null(the 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 throughApp::exec(). Uses the same override mechanism (ext-zealphp preferred, uopz fallback) asheader()and thesession_*()shims.
CGI backends and the exec hook work in all lifecycle modes. New ZealPHP-native code should still prefer explicit
App::exec()/ native coroutine handlers over shelling out, but the override means unmodified legacy code stops blocking the worker automatically.
Task Workers
$server->task([...]) dispatches background jobs to OpenSwoole task workers. ZealPHP provides a simple convention: task handlers live in task/<name>.php and define a closure matching the filename. Within an API or route handler, pass the handler path and arguments, then process the asynchronous response in the finish callback. See api/swoole/task.php and task/backup.php for the reference pairing.
Middleware and PSR Integration
ZealPHP speaks PSR-7 (HTTP messages) and PSR-15 (HTTP server middleware). Custom middleware implements Psr\Http\Server\MiddlewareInterface and is pushed via App::addMiddleware(). Items are buffered until App::run() registers them, ensuring everything executes within the same PSR stack alongside the built-in ResponseMiddleware.
Common use cases:
- Authentication/authorisation checks
- CSRF validation for form endpoints
- Request/response logging
- Response header shaping (e.g., HSTS, CORS)
See middleware-and-authentication.md for concrete examples.
Session Management
SessionManager orchestrates cookie-based sessions that mimic native PHP semantics:
- Generates IDs via
session_create_id()unless an incoming cookie or request parameter provides one (configurable to use-only-cookies). - Persists session state using the custom
FileSessionHandler. - Attaches request/response wrappers to
Gso handlers can reachZealPHP\HTTP\RequestandZealPHP\HTTP\Response.
When superglobals are disabled, CoSessionManager applies the same behaviour while remaining coroutine-safe.
Error Handling and Logging
- Use
elog($message, $level)to emit structured logs. Levels such as"warn","error", and"task"are used throughout the framework. jTraceEx($exception)builds Java-style stack traces for easier debugging.- When
App::$display_errorsis false, clients receive generic500 Internal Server Errorresponses even if the server logs the detailed exception.
Choosing Between Execution Modes
One-call presets — App::mode()
The canonical way to configure the lifecycle is a single App::mode() call before App::init(). It sets both the superglobals strategy and the isolation axis in one shot:
App::mode(App::MODE_COROUTINE); // recommended default — modern apps
App::mode(App::MODE_LEGACY_CGI); // unmodified WordPress / Drupal
App::mode(App::MODE_COROUTINE_LEGACY); // legacy request-style PHP run concurrently (requires ext-zealphp)
App::mode(App::MODE_MIXED); // Symfony / Laravel — real $_SESSION, no CGI fork cost
| Preset constant | superglobals |
Isolation | Use for |
|---|---|---|---|
App::MODE_COROUTINE (’coroutine’) |
false |
Coroutine |
Modern ZealPHP apps — concurrent coroutine I/O, no superglobals |
App::MODE_LEGACY_CGI (’legacy-cgi’) |
true |
CgiPool (~1–3 ms warm) |
Unmodified WordPress / Drupal — require_once-heavy apps needing full process isolation |
App::MODE_COROUTINE_LEGACY (’coroutine-legacy’) |
true |
Coroutine + full isolation stack |
Legacy request-style PHP running concurrently — the PHP-FPM mental model under OpenSwoole. Requires ext-zealphp. |
App::MODE_MIXED (’mixed’) |
true |
None |
Symfony / Laravel — real $_SESSION, sequential per-worker, no CGI fork cost |
App::mode() is sugar over App::superglobals() and App::isolation(). For finer control, see the Lifecycle setters section below.
coroutine-legacy is the framework’s compatibility runtime: it runs traditional request-style PHP under coroutine concurrency with per-coroutine isolation of the 7 superglobals (S1),
$GLOBALS(including object-valued) (S2), function-localstatic $x(S5a),ini_set()(S9g),require_oncere-execution (S7), andexit/dieworker survival (S12) (define()constant isolation (S10) is a separate opt-in viaApp::defineIsolation(true)). See /coroutines#lifecycle-modes for the full isolation matrix and preload requirements, and the isolation-stages taxonomy for the canonical stage names.
Pick the mode that matches your application’s profile. You can call App::mode() (or set the individual knobs) early in app.php before calling App::init().
Stage 8 — true-global-scope request include (App::globalScopeInclude())
App::globalScopeInclude(?bool $on = null): bool is the capstone of the coroutine-legacy isolation stack. It only matters for legacy require_once-bootstrap apps (WordPress wp-admin in particular) running in-process under coroutine-legacy.
The problem it solves. In coroutine-legacy the request entry runs in-process through App::executeFile(), whose include $absPath; sits lexically inside a static method. PHP's rule is that an included file inherits the variable scope of the line the include appears on — so a bare file-scope $x = [...] (no global keyword) in the entry file, or in anything it transitively require_onces, becomes a method-local variable of executeFile() and never enters EG(symbol_table). WordPress builds $menu / $submenu / $_wp_submenu_nopriv exactly this way; a later global $_wp_submenu_nopriv; then reads the global table, finds NULL, and 500s with array_keys(null) on admin pages. The per-coroutine $GLOBALS isolation (the S2 globals stage) can't help — it operates only on EG(symbol_table), which a method-local CV never enters. This is a scope problem, orthogonal to isolation.
What it does. When enabled, the request entry (and its whole transitive require_once tree) runs at true global scope, so bare file-scope variables and the require_once'd bootstrap bind into $GLOBALS / EG(symbol_table) — exactly as PHP-FPM / mod_php would. The S2 globals stage then snapshots/partitions those globals per coroutine across yields and clears the non-baseline keys at request end, so there's no cross-request leak or cross-coroutine clobber.
The contract (important). The globally-scoped include does not see executeFile()'s injected $g or route params — those are deliberately not extracted into the global frame (they would otherwise pollute $GLOBALS). Stage 8 is strictly for legacy apps that read request state via superglobals ($_GET / $_POST / $_SERVER / $_SESSION), not via ZealPHP's $g. Code that wants $g should use a normal route handler or the standard in-process include — not the global-scope path.
| Property | Value |
|---|---|
| Setter | App::globalScopeInclude(?bool $on = null): bool — no-arg getter / one-arg setter |
| Default | Off. When left null, follows the ZEALPHP_GLOBAL_INCLUDE env var ('1' enables) |
| Gate | coroutine-legacy only. Ignored in other modes |
| Requires | ext-zealphp 0.3.26+ (the zealphp_require_global() primitive). With the primitive absent, the call is a no-op and the normal in-process include path is used |
| Use case | Unmodified require_once-bootstrap wp-admin in coroutine-legacy (closes the array_keys(null) / $_wp_submenu_nopriv NULL gap) |
App::mode(App::MODE_COROUTINE_LEGACY);
App::globalScopeInclude(true); // run the request entry at true global scope
// ... App::init(); $app->run();
Honest caveat. Stage 8 closes the globals-scope wall — unmodified wp-admin renders in-process under coroutine concurrency (all menu pages 200, globals correct). It does not resolve the separate mysqlnd connection-teardown heap-safety frontier (a $wpdb-close-under-HOOK_ALL issue that predates Stage 8 and reproduces without it), which is tracked independently. For fully production-safe unmodified wp-admin today, App::mode(App::MODE_LEGACY_CGI) (process-isolated) remains the conservative choice until that frontier lands.
Lifecycle setters (v0.2.23+) — fine-grained control with safe-by-default
Historically App::superglobals() bundled four orthogonal decisions into one flag: storage strategy, include dispatch, coroutine auto-wrapping, and runtime I/O hooks. As of v0.2.23, each is exposed as its own fluent static setter so applications can mix-and-match for their workload (Symfony wants real $_SESSION but no per-include fork cost; testing wants per-request isolation without HOOK_ALL; etc.). Every new knob defaults to null and resolves to a App::$superglobals-derived default at App::run() time — apps that don't touch them see no behaviour change.
The five setters
Configure these BEFORE App::init(). Each is a no-arg getter / one-arg setter (the same App::superglobals() convention).
| Setter | Signature | Default (when null) |
Controls |
|---|---|---|---|
App::superglobals(bool) |
superglobals(bool $enable = true): void |
— (explicit default true) |
$g storage strategy: process-wide PHP superglobals (true) vs per-coroutine RequestContext in Coroutine::getContext() (false). Also picks SessionManager (true) vs CoSessionManager (false). |
App::processIsolation(bool) |
processIsolation(?bool $on = null): bool |
follows App::$superglobals |
App::include() dispatch: true routes each .php file through a subprocess (strategy chosen by cgiMode() — default 'pool', ~1–3 ms warm; 'proc' fallback is ~30–50 ms cold — true global-scope isolation, Apache mod_php parity); false runs in-process through App::executeFile(). |
App::enableCoroutine(bool) |
enableCoroutine(?bool $on = null): bool |
follows !App::$superglobals |
OpenSwoole's enable_coroutine server setting — whether each inbound request is auto-wrapped in its own coroutine. false makes a worker handle one request at a time synchronously. |
App::hookAll(bool|int|null) |
hookAll($on = null): int |
follows !App::$superglobals (HOOK_ALL or 0) |
OpenSwoole\Runtime::enableCoroutine($flags) — process-wide PHP I/O hooks that make blocking calls (fopen, fread, curl, mysqli, ...) yield to the scheduler. Accepts true (HOOK_ALL), false (0), or an explicit int bitmask. PDO_MYSQL/mysqli on mysqlnd ARE coroutinized (mysqlnd rides php_stream, which the stream/TCP hooks intercept — no dedicated HOOK_PDO); libpq-based PDO_PGSQL, Oracle/ODBC stay blocking. Hooking makes I/O non-blocking ≠ a shared connection safe across coroutines — use a per-coroutine connection/pool. |
App::cgiMode(string) |
cgiMode(?string $mode = null): string |
'pool' |
CGI dispatch strategy when processIsolation() is on. 'pool' (default) — pre-spawned subprocess pool, ~1–3 ms warm, mod_php-style isolation; 'proc' — fresh PHP per request via proc_open (~30–50 ms cold, full WordPress/Drupal compat); 'fork' — Apache MPM prefork runner, fresh child per request at true global scope (~1 ms fork cost, EXPERIMENTAL, .php-only, requires pcntl+posix); 'fcgi' (v0.2.39+) — forward to a FastCGI backend via App::fcgiAddress() (no child process). |
Worked examples — one line per setter:
App::superglobals(false); // per-coroutine $g, CoSessionManager
App::processIsolation(false); // skip the proc_open fork in App::include()
App::enableCoroutine(true); // OpenSwoole auto-coroutines per request
App::hookAll(\OpenSwoole\Runtime::HOOK_ALL); // hook curl/fopen/mysqli (not PDO)
App::cgiMode('fcgi'); // dispatch legacy includes to php-fpm
Supported mode matrix
App::mode() preset |
superglobals |
processIsolation |
enableCoroutine |
hookAll |
When to use |
|---|---|---|---|---|---|
MODE_LEGACY_CGI |
true |
true (CgiPool) |
false |
0 |
Unmodified WordPress / Drupal — define()-heavy plugins need process isolation per request (~1–3 ms warm pool by default) |
MODE_COROUTINE |
false |
false |
true |
HOOK_ALL |
Modern apps benefiting from concurrent coroutine I/O; OpenSwoole-native code |
MODE_COROUTINE_LEGACY |
true |
false |
true |
HOOK_ALL |
Legacy request-style PHP running concurrently with per-coroutine isolation of all request-state primitives. Requires ext-zealphp. |
MODE_MIXED |
true |
false |
false |
0 |
Symfony / Laravel on ZealPHP — real $_SESSION needed, but no per-include CGI fork cost. Sequential per worker → no race risk on superglobals |
| (custom) Coroutine without HOOK_ALL | false |
false |
true |
0 |
Per-request coroutine isolation but no auto I/O hooks (e.g. testing, custom hooks) |
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.
Unsafe combinations — boot-time refusal (v0.2.27+)
App::run() invokes App::validateLifecycleCombination() after resolving the four knobs, and throws RuntimeException at boot for genuinely unsafe shapes:
superglobals(false) + enableCoroutine(false)— always throws unconditionally.CoSessionManagerrequires the coroutine scheduler for per-requestRequestContextisolation; this combination is never safe.superglobals(true) + enableCoroutine(true)— throws only when ext-zealphp is absent. Without it, concurrent coroutines race the process-wide$_GET/$_POST/$_SESSIONarrays. With ext-zealphp loaded, this combination is fully supported — it is exactlyApp::mode(App::MODE_COROUTINE_LEGACY), where ext-zealphp's scheduler hooks snapshot/restore superglobals per coroutine.superglobals(true) + hookAll(non-zero)— throws only when ext-zealphp is absent, for the same reason: hooked I/O can yield mid-request. With ext-zealphp, coroutine-legacy mode uses this combination safely.
Pre-v0.2.27 these were elog()'d at warn level into /tmp/zealphp/debug.log but didn't refuse — in practice the warning was invisible to anyone not actively reading the debug log, and the unsafe configuration is how cross-request state-leak bugs ship to production. v0.2.27 changes this to a hard throw at App::run() boot — fail loud, fail fast, before any request can be served against a broken contract. Apps that need a refused combination for security-audit or debugging purposes can fork and remove the throw at App::validateLifecycleCombination().