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.

The Mental Model

Apache that drank coffee and decided to stay. The mental model in one lesson.

You will learn

  • Why traditional PHP throws everything away after each request — and what that costs you
  • What stays warm in ZealPHP (and what doesn't)
  • The two modes — superglobals vs coroutine — and which one you should use
  • The Apache→ZealPHP swap table: what changes in your code, and what doesn't

The fish problem

Traditional PHP is a goldfish. Every HTTP request, a brand-new fish is born — your autoloader boots, your config parses, your DB connection opens, your routing table compiles. The fish lives for 50 milliseconds, answers one request, and dies. The next request gets a new fish.

This is beautiful in one way: no request can corrupt another. It is wasteful in every other way. You re-pay the boot cost on every request. You can’t hold an open WebSocket. You can’t share anything in memory. You can’t run a background task without a separate process. Apache and php-fpm built an entire ecosystem of side-cars — Redis, queue workers, supervisord, Nginx — to paper over this constraint.

The constraint is the runtime model, not the language. PHP the language is perfectly capable of keeping a process alive. It just never did, because the request-per-process model became the default the same way QWERTY became the default — by being there first.

ZealPHP keeps the fish alive

ZealPHP runs on OpenSwoole, a PHP extension that gives PHP an event loop and an HTTP server. The process boots once. Workers fork. They live for thousands of requests. Your autoloader is hot. Your opcode cache is warm. Your routing table is already compiled. The request walks in, your handler runs, the response goes out. No teardown.

What’s shared between requests (cheap):

  • The Composer autoloader — class maps already resolved
  • Opcode cache — your PHP files are already bytecode
  • Class metadata — reflection results, attribute reads
  • Route table, middleware stack — registered once at $app->run()
  • Store tables and Counters — shared memory across all workers
  • WebSocket connections, timers, background coroutines

What’s not shared between requests (intentionally isolated):

  • The current request and response objects (a fresh RequestContext per request)
  • $_GET, $_POST, $_SESSION — wired to the current request only
  • Per-request session data — looked up by cookie, just like Apache

The trick is making this work without you noticing. header(), setcookie(), session_start() — all the PHP built-ins you know — quietly route to this request’s response object, not a global one. You write code that looks like Apache PHP, but every primitive is per-request-aware under the hood.

The Apache→ZealPHP swap table

If you’ve written PHP on Apache or PHP-FPM, almost nothing changes in the code you write. What changes is what runs underneath it.

ThingApache / PHP-FPMZealPHP
Routing.htaccess + RewriteRuleFile in public/ or $app->route()
Sessionssession_start()session_start() — same call, same files
Headersheader('X: Y')header('X: Y') — same call
TemplatesPlain .php files with includePlain .php files with App::render()
DatabasePDO / mysqliPDO / mysqli — unchanged
WebSocketBolt on Node.js / Ratchet$app->ws() — same process
Shared cacheRedisStore::make() — same process
Background tasksCron, supervisord, queue workerApp::tick(), task workers — same process
ServerApache + PHP-FPM + Nginxphp app.php

The first three rows are the entire migration story for 80% of apps. You don’t rewrite your code; you change what serves it.

Why we even need a coroutine mode

The swap table is almost the whole story. The 20% it doesn’t cover is one specific category of bug, and it’s worth understanding before we describe the modes — because once you see it, the two modes read as the answer, not just a configuration option.

Classic PHP gives you $_GET, $_POST, $_SESSION, $_SERVER, $_COOKIE, $_FILES "for free". The SAPI (Apache, PHP-FPM) populates them at the start of each request, and the process dies at the end. There is no chance one request’s $_GET can leak into another, because there is no other request in this process — the next one gets a brand-new fish.

OpenSwoole has no per-request SAPI. Workers live for thousands of requests. Two coroutines on the same worker share the same process address space — including any $GLOBALS['_GET'] array sitting there. If a handler reads $_GET['id'] while another coroutine just wrote a different $_GET['id'], you read the wrong one:

sequenceDiagram
    participant W as Worker (process)
    participant A as Coroutine A
    participant B as Coroutine B
    A->>W: $_GET = ['id' => 5]
    A-->>W: yield (await DB)
    B->>W: $_GET = ['id' => 9]
    Note over W: $_GET is process-wide
both coroutines see it W-->>A: resume A->>A: read $_GET['id'] → 9 (B's data!)

That’s the trap. Naive PHP-FPM code that reads $_GET assuming "this is my request’s data" silently picks up another request’s data the moment two coroutines overlap. The bug is intermittent, traffic-dependent, and impossible to reproduce locally with one user clicking around — which is the worst kind of bug to debug in production.

ZealPHP solves it two ways — these are the two modes:

  • Coroutine mode (default). The superglobals are simply not populated. Use $g->get, $g->post, $g->cookie, $g->server, $g->files instead — these live on the coroutine’s own context object via Coroutine::getContext(), so concurrent coroutines can’t see each other’s state. Reading $_GET['id'] in coroutine mode returns null, which is loud and obviously wrong — not silently-wrong like a race.
  • Superglobals mode. The framework runs each request in a forked CGI subprocess (a true isolated address space). $_GET et al. are populated normally. You pay a fork per request (~1ms), but unmodified WordPress / Drupal — and any code base whose mental model is "I am the only PHP script running" — works without a line changing.

Sessions are the one exception that works in both modes via $_SESSION as well as $g->session. The framework intercepts every session_*() call via uopz at startup so legacy session_start() code routes to a per-request session bag instead of a shared global. See Lesson 16: Sessions for the mechanics.

The two modes

ZealPHP has two runtime modes. New apps default to coroutine mode — that’s the one your app.php declares with App::superglobals(false). The other one, superglobals mode, exists for one reason: to run unmodified WordPress or Drupal without touching their source.

Coroutine mode (default)

graph TD
    R[Request] --> CO[Coroutine spawned]
    CO --> CTX["RequestContext on coroutine context"]
    CTX --> H[Your handler]
    H --> RES[Response]
    style CO fill:#fffbeb,stroke:#f59e0b,stroke-width:2px
    style CTX fill:#ecfdf5,stroke:#059669

Per-coroutine state · concurrent · isolated

Superglobals mode

graph TD
    R[Request] --> CGI[CGI subprocess]
    CGI --> G["true $_GET, $_POST, $_SESSION"]
    G --> H[Legacy PHP code]
    H --> RES[Response]
    style CGI fill:#fef2f2,stroke:#f87171
    style G fill:#fef2f2,stroke:#f87171

Per-request fork · WordPress / Drupal compatibility

Coroutine mode is the one you want for new code. It’s faster (no fork per request), cleaner (no global state leaking between requests), and the only mode where you get to use real concurrency inside a request — fan out three DB queries in parallel, wait on all three, return the result.

Superglobals mode forks a CGI worker per request, so it pays a millisecond or two of overhead. In return, any PHP code that mutates $_SESSION directly, sets globals, modifies ini_set(), or assumes “this script runs alone” works unchanged. It’s the bridge for migrating legacy apps without rewriting them.

What this means for you, in practice

Three things change once you internalize this model:

  1. You don’t pay boot cost per request. A 50 ms autoloader walk doesn’t happen 1,000 times a second — it happens once per worker, at startup. Your routes feel instant because the framework has nothing to set up before calling your handler.
  2. You can share memory. The next request from the same user, or any user, can read what the previous one wrote — through Store::make() or Counter. No Redis. No serialization round-trip. Just a typed table that lives in shared memory.
  3. You think twice about globals. Mutating a static class property in one request will leak into the next request handled by the same worker. ZealPHP boxes $_GET and friends per-request, but it doesn’t police your own static state. The rule is: anything request-specific goes in $g (RequestContext) or as handler parameters. Static state is for config, not data.

Everything else — your routes, templates, sessions, error pages, PDO calls — is the same PHP you already write. ZealPHP modernizes the runtime; it doesn’t modernize you.

You set a static class property in one request handler. The next request hits the same worker. What does it see?

Key Takeaways

  • Traditional PHP throws away the entire runtime after each request. ZealPHP keeps it.
  • Workers boot once, live for thousands of requests, and isolate per-request state via RequestContext.
  • Headers, cookies, sessions, and templates work exactly like Apache PHP — the framework wires them to the right request.
  • Coroutine mode is the default for new apps. Superglobals mode exists to run legacy code unchanged.
  • Anything request-specific belongs in $g or handler parameters — never in your own static class state.