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 four modes — selected with App::mode() — 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 with four different modes (configured via App::mode() or the App::MODE_* constants):

  • Coroutine mode (App::MODE_COROUTINE). The recommended default for new apps. 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.
  • Coroutine-Legacy mode (App::MODE_COROUTINE_LEGACY). Experimental, but incredibly powerful! It keeps real $_GET, $_POST, and friends populated per request and runs concurrently. It isolates the seven superglobals, $GLOBALS, and function statics per coroutine via ext-zealphp. This gives you the ultimate superpower: you can just write classic PHP syntax, but enjoy the blistering concurrency and performance of coroutines.
  • Mixed mode (App::MODE_MIXED). The in-process drop-in PHP-FPM equivalent. Native $_GET/$_SESSION are populated normally, handling one request at a time per warm worker. No fastCGI hop, no separate web server. Safe, traditional, shared-nothing per request.
  • Legacy CGI mode (App::MODE_LEGACY_CGI). Dispatch through a warm, pre-spawned PHP worker pool (fork, pool, proc, fcgi). True isolated address spaces, resident between requests (~1-3 ms warm). It's designed so define()-heavy code like unmodified WordPress or Drupal — 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 all modes via $_SESSION as well as $g->session. The framework intercepts every session_*() call via ext-zealphp 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 four modes

ZealPHP has four runtime modes, all selected with one call to App::mode() using the App::MODE_* constants. New apps default to coroutine mode (App::MODE_COROUTINE) — that’s the one the framework demo and every composer create-project scaffold ship with.

1. Coroutine Mode

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

2. Coroutine-Legacy Mode

graph TD
    R[Request] --> CO[Coroutine spawned]
    CO --> CTX["ext-zealphp isolates global state"]
    CTX --> H["Classic PHP syntax ($_GET, global $x)"]
    H --> RES[Response]
    style CO fill:#fffbeb,stroke:#f59e0b,stroke-width:2px
    style CTX fill:#ecfdf5,stroke:#059669
    style H fill:#fef2f2,stroke:#f87171

Concurrent · Classic PHP syntax powered by ext-zealphp

3. Mixed Mode

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

In-process sequential · PHP-FPM drop-in

4. Legacy-CGI Mode

graph TD
    R[Request] --> CGI[Pre-warmed subprocess pool]
    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

Pooled CGI worker · WordPress / Drupal compatibility

Coroutine mode is faster, cleaner (no global state leaking between requests), and lets you use real concurrency inside a request — fan out three DB queries in parallel, wait on all three, return the result.

Coroutine-Legacy mode (experimental) is the superpower. It gives you the blistering concurrent performance of coroutines but lets you write classic PHP syntax. You just write $_GET, $_POST, and global $x, and the C-extension isolates them per-coroutine for you! We will show both side by side in the lessons.

Mixed mode keeps native $_GET/$_SESSION by handling one request at a time per warm worker in-process. Legacy-CGI mode adds full process isolation by dispatching each .php through a warm, pre-spawned worker pool (fork/pool/proc/fcgi). These are your bridges 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. Coroutine-legacy mode lets you keep classic PHP syntax with blistering concurrency.
  • Anything request-specific belongs in $g or handler parameters — never in your own static class state.