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.
Bootstrapping
App::init() performs one-time initialization:
- Validates that the
uopzextension is loaded (ZealPHP would otherwise be unable to intercept functions such asheader()orsetcookie()). - 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 via
uopz_set_return()to route them through ZealPHP shims. 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),uopz_set_return()re-pointsshell_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.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 special parameters:
app,request,response, andserver. - 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('proc' | 'fork' | 'fcgi')selects the per-request isolation strategy for legacypublic/*.phpfiles.'proc'forks a fresh PHP interpreter per request viaproc_open+cgi_worker.php(mod_php-style global isolation, ~30–50 ms cold start — what unmodified WordPress/Drupal needs).'fork'(v0.2.29) forks the warm worker viaOpenSwoole\Processfor ~5× faster startup at the cost of function-scope semantics.'fcgi'(v0.2.39+) 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 three mode values:
mode |
What it does | Languages |
|---|---|---|
'proc' |
proc_open spawns the interpreter (or reads the #! shebang) per request — Apache CGI semantics |
any (interpreter optional) |
'fork' |
warm OpenSwoole\Process fork (~5× faster than proc) |
.php only — registerCgiBackend('.py', ['mode' => 'fork']) throws InvalidArgumentException |
'fcgi' |
forwards to a FastCGI daemon at address (php-fpm, a Python/Ruby FCGI server, …) — no per-request spawn |
any FastCGI/1.0 server |
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
Long-running shell-outs (git, ffmpeg, convert, …) block the OpenSwoole
worker if you call exec() / shell_exec() / system() / passthru() or a
backtick directly — one slow command stalls every coroutine sharing that worker.
ZealPHP provides a coroutine-aware wrapper plus a transparent override so legacy
code gets the safe behaviour for free.
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 uopz-overridden 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(). Belongs to the same uopz-override family 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
| Mode | App::superglobals(...) |
Coroutines (go()) |
Prefork / coproc |
Use Case |
|---|---|---|---|---|
| Legacy | true (default) |
Disabled in main request; use coproc() or task workers |
Available | Drop-in support for existing PHP apps that expect mutable superglobals. |
| Coroutine-first | false |
Enabled automatically | coproc() unavailable (superglobals disabled), still can use task workers |
New code bases leveraging async IO and coroutine scheduling without global state. |
Pick the mode that matches your application’s performance profile. You can toggle superglobals early in app.php before calling App::run().
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 forks cgi_worker.php via proc_open per file (~30–50 ms, 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 is NOT hooked in OpenSwoole 22.1 / 26.2 regardless — Doctrine queries always block. |
App::cgiMode(string) |
cgiMode(?string $mode = null): string |
'proc' |
CGI dispatch strategy when processIsolation() is on. 'proc' (default) — fresh PHP per request via proc_open (full WordPress/Drupal compat); 'fork' (v0.2.29) — warm OpenSwoole\Process fork (~5× faster; function-scope only); '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
| Mode | superglobals |
processIsolation |
enableCoroutine |
hookAll |
When to use |
|---|---|---|---|---|---|
| Legacy CGI | true |
true |
false |
0 |
Unmodified WordPress / Drupal — define()-heavy plugins need a fresh process per request |
| Coroutine | false |
false |
true |
HOOK_ALL |
Modern apps benefiting from concurrent coroutine I/O; OpenSwoole-native code |
| Mixed-mode / Symfony | true |
false |
false |
0 |
Symfony / Laravel on ZealPHP — real $_SESSION needed, but no per-include CGI fork cost. Sequential request handling per worker → no race risk on superglobals |
| In-process + sync | true |
false |
false |
0 |
Same shape as Mixed-mode — the "scheduler off, no CGI" combo |
| 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 combinations that race process-wide superglobals across concurrent coroutines:
superglobals(true) + enableCoroutine(true)— concurrent coroutines would race the process-wide$_GET/$_POST/$_SESSIONarrays (this is exactly the bug per-coroutine$gwas designed to avoid).superglobals(true) + hookAll(non-zero)— hooked I/O can yield mid-request, exposing process-wide superglobal mutations to other concurrent coroutines.
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 to run an unsafe combination for security-audit or debugging purposes can fork and remove the throw at App::validateLifecycleCombination(). The supported matrix above covers every safe configuration.