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.

A Request's Journey

A request walks into a server. Nine things happen before your code runs. Let's audit the trip.

You will learn

  • How the server boots — the one-time setup that happens before any request
  • The nine steps a request goes through from socket to your handler
  • Where per-request state actually lives (and why it's isolated)
  • How middleware wraps a request (and why order is reversed at registration)
  • What ResponseMiddleware does last that you never see

Step 0 — the boot

Before any request, the server boots. php app.php runs your bootstrap top to bottom:

$app = App::init('0.0.0.0', 8080);

$app->addMiddleware(new CorsMiddleware());      // registered (not running yet)
$app->addMiddleware(new ETagMiddleware());
$app->addMiddleware(new SessionStartMiddleware());

Store::make('rate_limits', 10000, [...]);       // shared memory allocated

$app->route('/health', fn() => ['ok' => true]); // route table populated

$app->run();                                     // ← OpenSwoole takes over here

At the moment $app->run() is called, the master process forks N workers (one per CPU core by default), each worker boots its own PHP runtime (autoloader, opcode cache), then sits in an event loop waiting for connections. The route table, middleware stack, and Store tables are inherited — not re-computed per request. Everything after this point is per-request work.

The trip, end to end

In Apache, a request’s journey is short: php-fpm spawns a worker, the worker runs your script, the worker dies. ZealPHP’s journey looks longer because the worker doesn’t die, so the framework has to do per-request setup carefully — and just as carefully tear it down. Once you see the nine steps, the rest of the framework stops feeling magical.

sequenceDiagram
    participant B as Browser
    participant SW as OpenSwoole
Server participant CO as Coroutine
(spawned per request) participant CSM as CoSessionManager participant MW as Middleware stack participant RM as ResponseMiddleware participant H as Your handler B->>SW: HTTP request SW->>CO: spawn coroutine CO->>CSM: onRequest(req, res) CSM->>CSM: build RequestContext on
Coroutine::getContext() CSM->>MW: handle(PSR-7 request) MW->>MW: CORS / ETag / Session / ...
(last-added runs first) MW->>RM: pass to innermost RM->>RM: match route, build param map RM->>H: invoke handler(...$injected) H-->>RM: return value RM-->>MW: wrap as PSR-7 response MW-->>SW: emit response SW-->>B: HTTP response

The nine steps

  1. OpenSwoole accepts the socket. The HTTP server you started with $app->run() is listening on port 8080. It parses the request line, headers, and body, then hands them to your worker process.
  2. A coroutine spawns. In coroutine mode, every request gets its own coroutine with its own Coroutine::getContext() bag — the per-request scratchpad that keeps your data from leaking into the next request.
  3. CoSessionManager runs. It’s the registered onRequest handler. It builds a fresh RequestContext, attaches the request/response wrappers, copies headers into $_GET/$_POST-shaped arrays, and stores the whole thing in the coroutine context. From this point, RequestContext::instance() always returns the right object for the current coroutine — no globals, no cross-talk.
  4. The middleware stack engages. ZealPHP uses a PSR-15 middleware chain. CORS runs first, then ETag, then your custom ones, then session-start, then range… the outermost middleware gets the request first, passes it down, and processes the response on the way back up.
  5. Order looks backwards. If you registered CORS first and ETag second, ETag runs first at request time. ZealPHP reverses the registration order before building the stack so that the last thing you add wraps the rest — the same convention as Slim, Express, Laravel. It’s subtle until you debug it once.
  6. ResponseMiddleware is the bottom of the stack. Every middleware eventually calls $handler->handle($request) on the next layer. The innermost handler is ResponseMiddleware — the one that actually finds your route.
  7. Routes match. Parameters resolve. ResponseMiddleware looks up your URI in the route table, finds the handler, and uses a parameter map built at registration time (via reflection, cached — no per-request reflection cost) to figure out which arguments your handler wants: $request, $response, $app, the captured {id} from the URL, anything else.
  8. Your handler runs. Whatever you return — an int, a string, an array, a Generator, void with echo — ResponseMiddleware translates into the right PSR-7 response. Streaming handlers skip the buffering step entirely.
  9. Response unwinds back up the stack. Each middleware that saw the request gets a turn with the response — ETag adds the cache header, compression compresses, CORS adds Access-Control-*, range middleware trims to the requested byte range. OpenSwoole emits the bytes. The coroutine ends. Your worker is ready for the next request.

Where state lives at each step

The persistent-vs-per-request distinction maps to three storage tiers:

TierWhat lives thereLifetime
Process Autoloader, route table, middleware stack, Store, Counter, WebSocket connections, timers Until the worker exits (or max_request recycles it)
Coroutine RequestContext, request/response objects, $_GET/$_POST/$_SESSION shims One request
Session User-attached state (cart, login, preferences), keyed by cookie Until cookie expires or server cleans up the file

Your handler can touch all three tiers freely. The only rule: don’t put per-request data in a process-level static, and don’t put process-level data in a per-request slot. The first causes data leaks between requests; the second wastes memory and gets discarded on the next request.

What never happens (the Apache trauma list)

Things you don’t need to worry about in this lifecycle — that you might have spent years working around on Apache or php-fpm:

  • The autoloader doesn’t rebuild. It’s already loaded in the worker.
  • The opcode cache doesn’t cold-start. Your scripts are already compiled.
  • The DB connection doesn’t reopen per request (if you use a pool). You hold it across requests.
  • The framework doesn’t re-parse routes. The route table is in memory.
  • The worker doesn’t fork. The coroutine spawns and parks — no pcntl_fork.

That’s the cost ZealPHP buys back. The nine-step journey above is what it spends to keep the cost down to memory access and a coroutine spawn — not 50 ms of boot.

You register middleware in this order: CORS, ETag, SessionStart. Which one runs first at request time?

Key Takeaways

  • Nine steps: OpenSwoole → coroutine → CoSessionManager → middleware stack → ResponseMiddleware → handler → response unwind → emit → coroutine ends.
  • Per-request state lives on Coroutine::getContext() — never on globals you control.
  • Middleware is registered "outermost last" — the last-added middleware runs first at request time.
  • ResponseMiddleware is always the innermost layer; it matches routes and resolves handler parameters by name.
  • The lifecycle is short by design: every step that could happen per-request was moved to startup.