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. uopz overrides on PHP built-ins

At server boot, ZealPHP uses uopz_set_return() 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 uopz extension at install time (not bundled with FPM by default; one apt-get / pecl install). PHPStan can't see through uopz redirection — it thinks header() writes to a global table, when at runtime it writes to $response->headersList.
  • Mitigation: 16 inline @phpstan-ignore-next-line annotations across src/utils.php and src/Session/utils.php, each with a one-line reason. uopz is checked at App::init() and throws if missing.

2. Dual-mode runtime: coroutine vs superglobals

App::superglobals(false) (recommended default) enables the coroutine scheduler, per-coroutine state via Coroutine::getContext(), and HOOK_ALL on PHP's I/O functions. App::superglobals(true) disables all of that and runs each request single-threaded with Apache-mod_php-style global state — the path that lets unmodified WordPress/Drupal run via the CGI bridge.

  • What it buys: greenfield projects get coroutines (thousands of concurrent requests per worker); legacy migrations get a single-runtime path with no rewrite.
  • What it costs: two code paths means two 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). The /coroutines page has a side-by-side safety matrix per mode. Most users never touch the superglobals 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

App::include($publicPath) runs PHP files in a separate process via proc_open (when in superglobals mode), capturing their header() / setcookie() / echo output and stitching them into the OpenSwoole response. The file's return value also flows back through the universal return contract. This is how unmodified WordPress and Drupal run on ZealPHP.

  • What it buys: Apache mod_php compatibility for the last mile of migration. App::setFallback(fn() => App::include('/index.php')) serves WordPress unmodified.
  • What it costs: a process per legacy request — no coroutine async, no shared state, fork latency. Slow relative to a main-worker route. Adds maintenance surface (the bridge is 284 lines of glue in src/cgi_worker.php).
  • Mitigation: the bridge is opt-in via App::superglobals(true) and only fires when a route falls through to setFallback(). New routes you write run in the main worker at full coroutine speed. Use the bridge for the legacy 20% 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 superglobals mode 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) 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 (uopz / __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. For the lesson learned from over-categorizing the level-1 ceiling as "deliberate trade-off" when most of it was actually unwritten annotations, see the "Cite numbers, not categories" entry at the bottom of that file.

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.