Design Trade-offs
Every framework makes choices that cost something. Most don't show their math. This page is the math — what we chose, what each choice costs you, and what mitigation exists. Brutal honesty over marketing copy.
src/ on GitHub.
For the version-by-version trace of how we got here, see
CRITIC.md.
1. ext-zealphp: function overrides on PHP built-ins
At server boot, ZealPHP uses ext-zealphp (our own C extension) to
permanently replace PHP built-ins like header(), setcookie(),
session_start(), set_error_handler(), http_response_code().
Calls flow into per-request objects ($g->zealphp_response, $g->session)
instead of the global PHP state that mod_php and FPM rely on.
- What it buys: unmodified PHP-FPM-era code works. Legacy
libraries that call
session_start()just work; you don't rewrite them. - What it costs: requires the
ext-zealphpextension at install time (pie install zealphp/extor build from source). PHPStan can't see through the redirection — it thinksheader()writes to a global table, when at runtime it writes to$response->headersList. - Design: allowlist-only (53 functions), no class manipulation,
no constant overrides, no general-purpose API.
MSHUTDOWNauto-restores all originals. The legacyuopzextension is supported as a fallback for existing installs.
2. Dual-mode runtime: coroutine vs legacy modes
App::mode(App::MODE_COROUTINE) (recommended default) uses per-coroutine state via
$g->get / $g->session (Coroutine::getContext()).
App::mode(App::MODE_MIXED) or App::MODE_LEGACY_CGI populates $_GET/$_POST/$_SESSION
per request — with ext-zealphp, these are per-coroutine safe (saved/restored
on every yield/resume — S1), so legacy code works with full coroutine concurrency. Without ext-zealphp,
legacy modes without ext-zealphp run sequentially (one request at a time per worker). The lifecycle is now
described by two orthogonal axes with a one-call preset:
App::mode() (constants: App::MODE_COROUTINE,
App::MODE_LEGACY_CGI, App::MODE_COROUTINE_LEGACY,
App::MODE_MIXED) sets both axes in one call.
App::isolation() (constants: ISOLATION_COROUTINE,
ISOLATION_CGI_POOL, ISOLATION_CGI_PROC,
ISOLATION_CGI_FCGI, ISOLATION_NONE) folds the
processIsolation() / enableCoroutine() / hookAll() /
cgiMode() cross-product into one value. The fine-grained setters still work
unchanged underneath. See
/coroutines#lifecycle-modes
for the full preset matrix and per-mode safety guarantees.
- What it buys: greenfield projects get coroutines (thousands of concurrent requests per worker); legacy migrations get a single-runtime path with no rewrite; the four-knob split lets one binary serve a "supported mode matrix" (coroutine, legacy CGI, mixed-mode/Symfony, coroutine-without-hooks) instead of a single take-it-or-leave-it switch.
- What it costs: multiple code paths means multiple surfaces for bugs. A mode-specific bug only fires under one config. Documentation has to explicitly mark which mode each guarantee applies to.
- Mitigation: coroutine mode is the documented default for
new projects (scaffold ships it). With
ext-zealphp(v0.3.0+), all mode combinations are safe — the extension provides per-coroutine superglobal save/restore (S1), soApp::mode(App::MODE_COROUTINE_LEGACY)just works. Without ext-zealphp, the legacy constraint applies: unsafe combinations throwRuntimeExceptionat boot. The /coroutines page has a side-by-side safety matrix per mode. Most users never touch any flag.
3. __call proxies on HTTP wrappers
ZealPHP\HTTP\Request and ZealPHP\HTTP\Response wrap OpenSwoole's underlying
request/response. Both expose __call($name, $arguments) so any method we haven't
explicitly forwarded (and any future OpenSwoole-added method) is automatically proxied.
- What it buys: upstream OpenSwoole versions don't require a
framework release to expose new methods.
$response->newMethodFromOpenSwoole25()just works. - What it costs: PHPStan sees
call_user_func_arrayreturningmixed. Every caller of a proxied method gets a mixed-type alarm at level 9+. - Mitigation: class-level
@methodPHPDoc onHTTP/Request.phpandHTTP/Response.phpdeclares the proxied signatures for the common methods (isWritable,write,sendfile,getContent, etc.) — PHPStan resolves these statically. The remaining proxy fallback has 2 inline ignores. Adding a new frequently-called method to the@methodblock eliminates more.
4. Reflection-based route parameter injection
Route handlers declare their dependencies by parameter name:
$app->route('/users/{id}', function($id, $request, $app) {
// $id is the URL param, $request is HTTP\Request, $app is App
return ['id' => $id];
});
- What it buys: ergonomics. Handlers feel like Express / Flask —
no DI container, no annotations, no
$request-as-first-param convention to remember. - What it costs: handler signatures are unknown at static-analysis
time. PHPStan can't tell that the closure takes
(string, Request, App)from the route table. Reflection has a per-call cost too. - Mitigation: the param map is built via
ReflectionFunctiononce at route registration time (App::buildParamMap()) — zero per-request reflection. The dispatcher reads from the pre-built map. PHPStan ignores at the dispatch sites are documented as "handler param type known only at route binding."
5. CGI bridge for legacy apps and non-PHP scripts
ZealPHP ships a genuine Apache-mod_cgi-class bridge, not a toy fork-per-request. It runs
legacy PHP and non-PHP scripts (Python, Perl, anything with a shebang or interpreter) with a
full RFC 3875 CGI/1.1 environment, then stitches their output into the OpenSwoole response. The
executed script's return value (for PHP) flows back through the
universal return contract. Three things
make it real:
- Four dispatch modes (
App::cgiMode()):'pool'(default) uses a warm, pre-spawned PHP worker pool — the interpreter stays resident in memory, mod_php-style isolation, ~1–3 ms per request, configurable viacgiPoolSize()/cgiPoolMaxRequests();'proc'spawns a freshproc_opensubprocess per request (~30–50 ms cold start — recursion-safe fallback for cases where fresh-process semantics are needed without a pre-spawned pool);'fork'(experimental) is an Apache MPM prefork runner — a long-lived fork-master forks a fresh child per request at true global scope (~1 ms fork cost, requirespcntl+posix), giving unmodified-WordPress correctness without theproc_opencold-start tax;'fcgi'forwards to an external php-fpm / FastCGI pool via the bundledFastCgiClient(no per-request spawn at all). Per-extension backends register viaApp::registerCgiBackend('.py', …). - ScriptAlias + ExecCGI scope.
App::cgiScriptAlias('/cgi-bin', …)(ApacheScriptAliasparity) and per-backendexec_paths(ApacheExecCGIparity) gate which URLs may execute. A stray script outside its declared exec scope is neither executed nor leaked as source — it returns403(the security hole Apache leaves whenExecCGIis off). - URL parity.
GET /cgi-bin/report.pyruns the script — implicit routes are auto-registered for every CGI extension, registered before the generic public-file routes so they win. Full RFC 3875 env (httpoxy-stripped: the clientProxy:header never reachesHTTP_PROXY), POST body piped to script stdin,Status:-header parsing, streaming / SSE pass-through, and aCGIScriptTimeout(App::$cgi_timeout, Apache parity).
Beyond the file bridge, App::exec() runs shell commands coroutine-safely through
OpenSwoole\Coroutine\System::exec() (yields to the scheduler instead of blocking the worker),
and a transparent ext-zealphp override of the shell_exec / exec / system /
passthru family (and the backtick operator, which compiles to shell_exec) routes
legacy/user code through it with zero source changes (on by default in coroutine mode,
App::hookExec() to override).
- What it buys: the last mile of migration runs unmodified.
App::setFallback(fn() => App::include('/index.php'))serves WordPress as-is; a polyglot/cgi-binof Python/Perl reports runs without a separate web server; and'fcgi'mode lets ZealPHP front an existing php-fpm pool with no fork cost at all. - What it costs: the bridge is real maintenance surface
(
src/cgi_worker.php+ the CGI env/dispatch glue insrc/App.php). The warm'pool'default adds ~1–3 ms dispatch overhead vs. a native route — the interpreter stays resident, so there's no per-request startup tax. If you'd rather front an existing php-fpm pool,'fcgi'mode forwards to it with zero spawn cost. - Mitigation: CGI-script execution (non-PHP via
ScriptAlias / registered extensions) works in any lifecycle mode — it isn't bolted to
processIsolation. For PHP,.phponly goes through the subprocess in isolation mode; in coroutine mode it runs in-process at full speed. Use'pool'(the default warm path) for legacy PHP isolation,'fcgi'to front an existing php-fpm pool with zero spawn cost, and keep native routes for everything you've already modernized — use the bridge for the legacy slice you can't rewrite yet.
6. RequestContext (formerly G) — per-coroutine, looks like a god object
RequestContext::instance() returns a per-request state container holding
$server, $get, $post, $cookie,
$session, $zealphp_request, $zealphp_response, and the rest. In
coroutine mode it's stored in Coroutine::getContext() — one instance per coroutine,
isolated. In legacy modes it's a process singleton bridging to $_GET / $_POST / $_SESSION.
- What it buys: a single named object for every per-request
concern. Same shape across modes. Hyperf and Slim's
$_REQUEST-style globals follow the same pattern. - What it costs: looks like a god object on first read. Critics flag it before realizing that in coroutine mode it's per-coroutine, not process-wide. Frontend devs accustomed to React contexts have to map the mental model.
- Mitigation: strict
__setrejects undeclared property writes in coroutine mode (typos surface immediately, not 200 requests later).RequestContext::once($key, $fn)gives a safe alternative tostatic $cache = []for user code. The /coroutines docs page maps the isolation contract per mode.
7. The discipline contract for user-level statics
ZealPHP isolates the state it owns (request, response, session, $_SERVER — S1) per coroutine. It
does not isolate static $cache = [] inside your handler, or
private static $instance on your singleton class. Those live in worker process memory and
survive every request boundary.
- What it buys: the framework doesn't pay the cost of mediating every user-level static-property access. Hyperf, RoadRunner, and Laravel Octane all draw this line the same way.
- What it costs: a developer used to PHP-FPM (where every static dies at request end) can ship code that leaks across requests without noticing — until production memory creeps up.
- Mitigation:
max_request=100000default inApp::run()(configurable viaZEALPHP_MAX_REQUEST) recycles workers periodically. Worker-recycle access-log line surfaces the recycling in production. Opt-inIniIsolationMiddlewaresnapshotsini_set()changes per request. TheRequestContext::once()helper gives you a safe primitive for memoization without touchingstatic.
8. OpenSwoole / ext-posix stub mismatches
openswoole/ide-helper and PHPStan's bundled ext stubs sometimes declare types differently
from what the real C extension does. Examples: OpenSwoole\Runtime::enableCoroutine() says
bool, the ext takes int flags (HOOK_ALL etc.). posix_kill() says
always-true, the ext returns false on dead PIDs (which is exactly what the polling code is checking
for). ceil() stubs widen the return to float when PHP 8.0+ returns int.
- What it buys: IDE autocomplete still works.
- What it costs: PHPStan reports errors we can't fix in our code (the stubs are upstream and wrong).
- Mitigation: 9 targeted
ignoreErrorspatterns inphpstan.neon, each with a# reason:comment explaining the specific stub-vs-ext mismatch. Scoped tightly to the affected file + error pattern so future real bugs aren't swallowed.
9. 75 inline PHPStan ignore-with-reason sites
ZealPHP passes PHPStan level 10 (the strictest tier, what Symfony 8+ and Laravel 12+ score at) with
zero errors. It also has 75 inline @phpstan-ignore-next-line annotations
across src/, each annotated with a one-line reason for the design choice that makes that
site unverifiable statically.
Categories of those 75:
- Sections 1, 3, 4, 6, 7, 8 above account for the vast majority.
- The rest are array-shape boundaries where mixed flows through user-controlled session/request
keys and gets coerced to string/int at the boundary (
(string)$g->session['user_id']) — PHPStan can't prove these are safe statically; the boundary cast is the runtime contract.
Run grep -rn '@phpstan-ignore' src/ in a clone to see every site. Each one has the form
// @phpstan-ignore-next-line — <reason>. No bare ignores.
10. max_request worker recycling
Workers are recycled every max_request=100000 requests by default. After that count
OpenSwoole gracefully exits the worker and forks a fresh one.
- What it buys: bounds PHP-engine state accumulation. Static caches in user code, leaky extension state, accidental memory ballooning — all of it caps out. Required for honest long-running-PHP claims.
- What it costs: the request that triggers the recycle pays a fork latency hit (~milliseconds). One-in-100000 requests pays the cost.
- Mitigation: configurable via
ZEALPHP_MAX_REQUEST(set to 0 to disable). Recycle is visible in the access log as[recycle] worker N exited after K requests, peak RSS X MB, uptime Ysso the backstop isn't invisible in production.
The math
At PHPStan's strictest level (10 on PHPStan 2.x), ZealPHP has 75 documented ignore sites
across src/. ~57 are genuine architectural design choices (ext-zealphp / __call /
reflection / dual-mode). ~18 are PHPStan / stub-mismatch limitations where the upstream type
information is wrong.
For framework-level critique with version-by-version traces of how these trade-offs were discovered, justified, or fixed, see CRITIC.md.
Found a design tax we haven't documented? Open an issue. Each release in the v0.2.x line has been driven by public technical review.