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.

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.

Reading this page: each section follows the same shape — what we chose, what it costs you, what mitigates it. Code links point to 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-zealphp extension at install time (pie install zealphp/ext or build from source). PHPStan can't see through the redirection — it thinks header() 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. MSHUTDOWN auto-restores all originals. The legacy uopz extension 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), so App::mode(App::MODE_COROUTINE_LEGACY) just works. Without ext-zealphp, the legacy constraint applies: unsafe combinations throw RuntimeException at 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_array returning mixed. Every caller of a proxied method gets a mixed-type alarm at level 9+.
  • Mitigation: class-level @method PHPDoc on HTTP/Request.php and HTTP/Response.php declares 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 @method block eliminates more.

4. Reflection-based route parameter injection

Route handlers declare their dependencies by parameter name:

Flask-style by-name injection
$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 ReflectionFunction once 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 via cgiPoolSize() / cgiPoolMaxRequests(); 'proc' spawns a fresh proc_open subprocess 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, requires pcntl + posix), giving unmodified-WordPress correctness without the proc_open cold-start tax; 'fcgi' forwards to an external php-fpm / FastCGI pool via the bundled FastCgiClient (no per-request spawn at all). Per-extension backends register via App::registerCgiBackend('.py', …).
  • ScriptAlias + ExecCGI scope. App::cgiScriptAlias('/cgi-bin', …) (Apache ScriptAlias parity) and per-backend exec_paths (Apache ExecCGI parity) gate which URLs may execute. A stray script outside its declared exec scope is neither executed nor leaked as source — it returns 403 (the security hole Apache leaves when ExecCGI is off).
  • URL parity. GET /cgi-bin/report.py runs 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 client Proxy: header never reaches HTTP_PROXY), POST body piped to script stdin, Status:-header parsing, streaming / SSE pass-through, and a CGIScriptTimeout (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-bin of 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 in src/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, .php only 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 __set rejects undeclared property writes in coroutine mode (typos surface immediately, not 200 requests later). RequestContext::once($key, $fn) gives a safe alternative to static $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=100000 default in App::run() (configurable via ZEALPHP_MAX_REQUEST) recycles workers periodically. Worker-recycle access-log line surfaces the recycling in production. Opt-in IniIsolationMiddleware snapshots ini_set() changes per request. The RequestContext::once() helper gives you a safe primitive for memoization without touching static.

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 ignoreErrors patterns in phpstan.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 Ys so 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.