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.

Case study · Production migration

From Apache to ZealPHP: How We Made the Same PHP Codebase Run on Two Servers Simultaneously

41 commits. ~2,300 lines of app code. One custom Rust extension. Zero downtime.

We just finished migrating the Selfmade Ninja Labs dashboard — a large PHP/MongoDB/jQuery web application — from traditional Apache+mod_php to ZealPHP. But we didn't replace Apache. We made both run side by side, in the same container, serving the same files. Here's the full story of what it took, what broke, what we built, and what we learned.

What we started with

The Selfmade Ninja Labs platform (labs.selfmade.ninja) is a Docker-based educational infrastructure. The web dashboard is a PHP application served by Apache with mod_php — the most traditional PHP stack there is. It handles user auth (OAuth), lab management, a code arena, quizzes, clan systems, roadmaps, discussion forums, and an internal economy system called Zeal/Jolt.

The codebase is large: hundreds of PHP files, MongoDB models via a custom ORM (MongoGetterSetter), a REST API layer, Server-Sent Events endpoints for real-time streaming, and a Grunt-based frontend build. It was never designed to run on anything other than Apache.

What ZealPHP is (and why we wanted it)

ZealPHP is an async PHP web framework built on OpenSwoole. Instead of Apache spawning a new PHP process for every request, ZealPHP keeps a long-running PHP process with an event loop — like Node.js, but for PHP.

The benefits are significant:

  • Persistent connections — MongoDB, Redis, and RabbitMQ connections stay open across requests instead of being recreated every time
  • Coroutines — concurrent I/O without callback hell (PHP Fibers + OpenSwoole)
  • No CGI overhead — no forking, no process boot cost per request
  • Built-in SSE/streaming — native support instead of Apache's flush-and-pray approach
  • Event loop cron — schedule recurring tasks inside the server process via App::tick(), no external cron daemon needed

But migration from a superglobal-based, process-per-request PHP codebase to a shared-process async runtime is not trivial. It is, in fact, one of the hardest migrations in the PHP world.

The architecture: one container, two servers, same volume

Here's what we ended up with in development (not yet cut over to production):

Architecture diagram
Internet
                    │
                ┌───┴───┐
                │Traefik│  :443 (HTTPS + TLS)
                └───┬───┘
                    │
    ┌───────────────┼───────────────┐
    │                               │
labsdev.selfmade.ninja          zealphp.selfmade.ninja
    │                               │
    ▼                               ▼
Apache :80                     ZealPHP :8080
(mod_php, CGI model)          (OpenSwoole 26, async)
    │                               │
    └───────── same volume ─────────┘
         /var/www/labs-dashboard-web

Both servers live inside the same Docker container (labs). Apache listens on port 80, ZealPHP on port 8080. Traefik routes based on hostname. They mount the same code volume. Change a PHP file and both servers see it instantly (Apache because it re-reads on every request, ZealPHP because it was configured for development reload).

The entire infrastructure change in labs-devops was remarkably small — 5 files, 56 insertions:

  1. Dockerfile — Ubuntu 24.04 → 25.04 (PHP 8.4), added OpenSwoole 26 + uopz, Rust toolchain for building zealphp-mongodb
  2. entry.sh — 8 lines: "if server.php exists, start ZealPHP as a daemon on port 8080"
  3. php-override.ini — Swapped extension=mongodb.soextension=zealphp_mongodb.so
  4. docker-compose.override.prod.yml — Added Traefik router for zealphp.selfmade.ninja → port 8080
  5. docker-compose.yml — Exposed port 8080

That's the devops side. The application side is where the real work happened.

Phase 1: Route translation (the easy part)

The first commit was c39e6429a feat(zealphp): add ZealPHP server with full .htaccess route translation.

Apache uses .htaccess with mod_rewrite for clean URLs:

.htaccess — Apache rewrites
RewriteRule ^quiz/(.*)$ quiz.php?path=$1 [QSA,L]
RewriteRule ^labs/(.*)$ labs.php?path=$1 [QSA,L]
RewriteRule ^profile/([^/]+)/?$ profile.php?username=$1 [QSA,L]

ZealPHP uses explicit route registration in server.php:

server.php — equivalent ZealPHP routes
$app->route('/quiz/{path:.*}',     fn() => page('quiz.php',     ['path']));
$app->route('/labs/{path:.*}',     fn() => page('labs.php',     ['path']));
$app->route('/profile/{username}', fn() => page('profile.php',  ['username']));

Every rewrite rule was translated. This was mechanical work — tedious but straightforward. The page() helper function loads the PHP file in the ZealPHP context, passing route parameters as if they were query string arguments.

Phase 2: The superglobals problem (the hard part)

This is where the migration gets interesting.

In Apache's process-per-request model, PHP superglobals ($_SESSION, $_SERVER, $_GET, $_POST) are naturally isolated. Each request gets its own process with its own copy of these variables. When the request ends, the process dies and everything is garbage collected.

In ZealPHP's shared-process model, all requests share the same PHP process. If request A writes to $_SESSION['username'] = 'alice' and request B reads $_SESSION['username'], they'd get each other's data. This is a catastrophic data leak.

The solution was already built into ZealPHP: $g — the RequestContext.

The $g context — ZealPHP's RequestContext

ZealPHP's RequestContext (aliased as $g) provides per-coroutine isolated properties that mirror PHP's superglobals:

ZealPHP RequestContext — declared properties
public array $server  = [];
public array $get     = [];
public array $post    = [];
public array $request = [];
public array $cookie  = [];
public array $files   = [];
public array $session = [];

In coroutine mode, RequestContext::instance() returns a per-coroutine instance from Coroutine::getContext() — each request gets its own $g with isolated state, automatically freed when the coroutine ends.

A natural question: doesn't $g->get already refer to $_GET automatically? No. These are declared public properties — plain arrays. ZealPHP populates them from the OpenSwoole request object on every incoming request:

Inside ZealPHP App.php on("request") handler
$g->get    = $request->get    ?? [];
$g->post   = $request->post   ?? [];
$g->cookie = $request->cookie ?? [];
$g->server = /* built from $request->server + $request->header */;

$_GET is never involved. The RequestContext class does have a __get magic method that maps to $GLOBALS['_GET'] in superglobals mode — but that only fires for undeclared properties. Since get, post, server etc. are declared properties, PHP accesses them directly and the magic method never runs.

So under ZealPHP, $g->get works natively — the framework handles everything. But under Apache, ZealPHP isn't loaded at all. There's no RequestContext class, no OpenSwoole, no coroutines. We needed a shim:

load.php — the dual-runtime shim (Apache fallback is the only "bridge" part)
$GLOBALS['g'] = $g = class_exists('\ZealPHP\RequestContext', false)
    ? \ZealPHP\RequestContext::instance()    // ZealPHP: populated by framework
    : (object)[                               // Apache: references to superglobals
        "get"     => &$_GET,
        "post"    => &$_POST,
        "server"  => &$_SERVER,
        "files"   => &$_FILES,
        "request" => &$_REQUEST,
        "cookie"  => &$_COOKIE,
        "session" => &$_SESSION
      ];

The Apache fallback creates a plain PHP object where $g->get is a reference to $_GET — so reads and writes pass through to the superglobal. Under ZealPHP, $g->get is a per-coroutine array populated from the async request. Same API, completely different mechanisms underneath.

The bulk of the migration work was replacing every $_GET, $_POST, $_SERVER, and $_SESSION reference with $g->get, $g->post, $g->server, and $g->session. That's 35 commits of mechanical changes — the code moved toward ZealPHP's native pattern, and the 5-line Apache shim kept backward compatibility.

Similarly, $_SESSION access was wrapped in a Session class:

Session class — same API, two runtime backings
Session::get('username')      // maps to $GLOBALS['g']->session['username']
Session::set('username', $v)  // maps to $GLOBALS['g']->session['username'] = $v
Session::isset('username')    // maps to isset($GLOBALS['g']->session['username'])

Is $g an anti-pattern? No — $g is ZealPHP's native request context. The application code was already moving toward the framework's design, not away from it. The only "bridge" part is the 5-line Apache fallback object, which exists solely so the same code runs when ZealPHP isn't loaded.

ZealPHP also offers a superglobals mode (App::superglobals(true)) that auto-populates $_GET/$_POST etc. per request — but that breaks under concurrent coroutines unless you run App::mode(App::MODE_COROUTINE_LEGACY) with ext-zealphp, which snapshots and restores the superglobals per coroutine so concurrent requests don't race. We chose the $g pattern for Apache parity instead (the same code runs without ZealPHP loaded). Using $g->get directly is the recommended pattern for the dual-runtime case. The migration happened file by file over 35 commits — no big-bang rewrite needed.

The Apache fallback is now a first-class ZealPHP artifact.

What started as our load.php shim is now shipped and documented in the framework as the canonical dual-runtime Apache-parity bridge — a standalone, dependency-free compat/g.php (with a drift-guard test) that any project running on both Apache and ZealPHP can drop in. It's not a workaround for a limitation; it's the only design that can work across the "with ZealPHP / without ZealPHP" boundary, because on Apache the framework simply isn't loaded. See the dual-runtime guide for the full pattern and why it can't be a runtime feature. (Note: this is the coroutine-mode story — distinct from v0.2.27's superglobals(true) drop-in LAMP mode, where ZealPHP-only apps can read $_GET/$_SESSION directly and skip the shim.)

Phase 3: Killing die() and exit()

This one surprised us. The codebase had die() and exit() calls scattered across error paths, validation failures, and early returns:

A typical Apache-era guard
if (!$user) {
    http_response_code(403);
    die(json_encode(['error' => 'unauthorized']));
}

Under Apache, die() kills the current PHP process. A new process spawns for the next request. No problem.

Under ZealPHP, die() kills the entire server. All 12 worker processes. Every connected user. Gone.

Two commits tackled this:

7c2dfe09d  fix: eliminate all die()/exit() calls on HTTP hot paths
ecc4c88ff  fix: replace die()/exit() with HaltException/return for ZealPHP coroutine safety

Every die() was replaced with either a return (in functions) or a throw new HaltException() (in deeply nested code where returning wasn't possible). ZealPHP catches HaltException at the request boundary and cleanly ends just that one request.

Phase 4: The MongoDB wall (and the Rust extension)

We hit the biggest blocker when we tried to go fully async: the PECL MongoDB driver (mongodb.so) is a synchronous C extension. When it makes a network call to MongoDB, it blocks the entire event loop. In a coroutine-based server, one slow MongoDB query freezes all concurrent requests.

We had two choices:

  1. Accept blocking I/O and lose most of ZealPHP's performance benefit
  2. Build an async MongoDB driver

We chose option 2.

zealphp-mongodb is a PHP extension written in Rust that wraps the official MongoDB Rust driver (which is fully async) and bridges it with OpenSwoole's coroutine system. When a coroutine makes a MongoDB query, it yields control to the event loop while waiting for the response. Other requests continue processing.

The implementation:

  • Rust with ext-php-rs for PHP FFI
  • Official MongoDB Rust driver for async I/O
  • Drop-in API compatibility with mongodb/mongodb — same class names, same methods (Collection, Database, Client, ObjectId, UTCDateTime, etc.)
  • Built during Docker image build (cargo build --release in the Dockerfile)
php-override.ini
; extension=mongodb.so   ; replaced by zealphp-mongodb Rust driver
extension=zealphp_mongodb.so

This was 6 commits of fighting toolchains:

739b16b  feat: Dockerfile builds zealphp-mongodb Rust extension, replaces PECL mongodb
d4f6571  fix: bump Rust toolchain 1.83 → 1.87 (edition2024 support required)
95f56e1  fix: use rust:latest for Dockerfile (deps need Rust 1.88+)
0ff9a20  fix: install libclang-dev for bindgen in Rust build
875ba8a  fix: Dockerfile uses signed-by for MongoDB 8.0 repo (apt-key removed in 25.04)
681e346  fix: clone zealphp-mongodb to /home/labs/ (not /tmp/) so PHP library persists

Phase 5: SSE streaming — why SSEStream exists (and why not $response->sse())

Seven endpoints in the application use Server-Sent Events for real-time streaming: deploy logs, AI chat responses, code arena execution output, roadmap generation. Under Apache, these used the classic PHP streaming pattern:

Apache-era SSE
header('Content-Type: text/event-stream');
while (ob_get_level()) ob_end_flush();
while (!connection_aborted()) {
    echo "data: " . json_encode($chunk) . "\n\n";
    flush();
}

ZealPHP has a clean native SSE API:

ZealPHP-native SSE
$app->route('/events', function($response) {
    $response->sse(function($emit) {
        $emit(json_encode(['tick' => 1]), 'update', '1');
    });
});

So why didn't we just use $response->sse()?

Because the SSE endpoints need to work on both Apache and ZealPHP.

The /api/instance/deploylog.php endpoint, for example, is accessed from browser JavaScript via new EventSource('/api/instance/deploylog.php?...'). When the request arrives via Apache (port 80), there's no $response object — it's classic PHP with echo and flush(). When it arrives via ZealPHP (port 8080), we need $response->write() on the OpenSwoole response.

We could have branched every SSE endpoint, but instead we built SSEStream — a 94-line class that auto-detects the runtime and routes I/O to the right backend:

SSEStream.php — runtime-detecting SSE writer
class SSEStream
{
    private bool $isZealPHP = false;
    private $response = null;

    public function __construct()
    {
        if (class_exists('\ZealPHP\RequestContext')) {
            $g = \ZealPHP\RequestContext::instance();
            if (isset($g->openswoole_response) && $g->openswoole_response) {
                $this->response = $g->openswoole_response;
                $this->isZealPHP = true;
            }
        }
    }

    public function write(string $raw): void
    {
        if ($this->isZealPHP) {
            if ($this->response->isWritable()) {
                $this->response->write($raw);   // async, non-blocking
            }
        } else {
            echo $raw;
            @flush();   // Apache buffered output
        }
    }

    public function isConnected(): bool
    {
        if ($this->isZealPHP) {
            return $this->response && $this->response->isWritable();
        }
        return !connection_aborted();
    }
}

Every SSE endpoint now does:

Endpoint shape — same code, both servers
$sse = new SSEStream();
$sse->start();
$sse->emit('status', ['step' => 'generating']);
// ... work ...
$sse->emit('done', ['result' => $output]);
$sse->end();

Why not just use $response->sse() even in the ZealPHP path?

Two reasons:

  1. The SSE endpoints are PHP files loaded by include — they're not route closures that receive $response as a parameter. They're legacy files included into ZealPHP's request context via the page() helper. They don't have direct access to the route-level $response object.
  2. $response->sse() uses a callback pattern — you pass a closure and the framework manages the lifecycle. Our SSE endpoints are imperative: they open a process, read its stdout line by line in a loop, stream each line to the client, and check for disconnection. The callback pattern doesn't fit without restructuring the entire endpoint.

SSEStream gives us $response->sse()'s non-blocking I/O underneath, but with the imperative API our existing code expects. It's the same bridge philosophy as $g — make the old code work in the new world without rewriting it.

Is using $g->openswoole_response in SSEStream an anti-pattern?

Reaching into $g->openswoole_response directly is definitely reaching past the framework's abstraction layer. In a greenfield ZealPHP application, you'd use $response->sse() and never touch the raw OpenSwoole response.

But this isn't greenfield — it's a migration. SSEStream exists in the narrow space between "the old code pattern" and "the new runtime." It accesses $g->openswoole_response because:

  • The included PHP files don't receive $response as a parameter
  • ZealPHP's RequestContext exposes openswoole_response specifically for this use case — it's a documented escape hatch, not a private internal
  • The alternative is restructuring 7 SSE endpoints into closure-based routes, which breaks Apache parity

Once Apache is eventually retired, SSEStream becomes unnecessary. Each endpoint can be rewritten as a clean ZealPHP route with $response->sse(). But that's a future migration — today, SSEStream is the bridge that makes both worlds work.

Phase 6: OAuth, sessions, and the edge cases

With the core migration done, the real testing began. OAuth login was the first thing that broke:

65f60af6b  fix: OAuth login — $_SERVER → $g->server in home.php, get_config uses $GLOBALS
66a36ff6b  fix: safe token access in OAuth flow — handle array/object session data
e0fe116dc  feat: OAuth login working on ZealPHP — full authenticated flow

OAuth callbacks read $_SERVER['HTTP_HOST'] to construct redirect URIs. In ZealPHP coroutine mode, $_SERVER is empty — you need $g->server['HTTP_HOST']. One missed reference and the entire login flow breaks silently.

Session handling had its own subtlety:

8842919e8  fix: re-bind $g->session reference after session_start()

In Apache mode, $g->session is a reference to $_SESSION. But PHP's session_start() replaces the $_SESSION variable entirely — the old reference points to the pre-start empty array, not the hydrated session data. We had to re-bind the reference after every session_start():

Apache-mode session re-bind
if (!($g instanceof \ZealPHP\RequestContext)) {
    $g->session = &$_SESSION;   // re-bind after session_start() replaces $_SESSION
}

Phase 7: The revert and recovery

The commit history tells an honest story. We didn't get it right on the first try:

42a94a314  feat: enable superglobals(false) — full coroutine mode with HOOK_ALL
65fc77565  fix: revert to superglobals(true) CGI mode (stable)
7f62b9460  chore: revert to stable CGI mode + document coroutine bridge blocker

We enabled full coroutine mode, discovered the PECL MongoDB driver was blocking the event loop (making the entire server feel slower than Apache), and reverted to CGI mode while we designed and built zealphp-mongodb.

Three commits later:

f68e8f0e5  docs: zealphp-mongodb design spec
afb410338  docs: zealphp-mongodb full spec — complete mongodb/mongodb API coverage
95e09f506  docs: zealphp-mongodb T0 implementation plan

And after building the Rust extension, we went back to full coroutine mode and this time it stuck:

d81e68577  feat: full authenticated dashboard working on ZealPHP coroutine mode

What we built along the way

The migration wasn't just about making old code work. It also unlocked new capabilities:

Native cron via event loop

server.php — no external cron daemon needed
App::tick(60000, function() {
    // runs every 60 seconds inside the ZealPHP event loop
    // only on worker #0 to prevent duplicate execution
});

Apache needs a system cron daemon to run scheduled tasks. ZealPHP's event loop can schedule them internally with App::tick(), with the added benefit that cron jobs share the same connection pool and memory space as the web server.

Non-blocking labsctl via Coroutine::exec()

The dashboard frequently shells out to labsctl (the Python control plane CLI) for container operations. Under Apache, this is a blocking exec() call — the PHP process sits idle waiting for labsctl to finish.

Under ZealPHP:

Non-blocking shell-out
// Yields to event loop while waiting for labsctl
$result = \OpenSwoole\Coroutine::exec("labsctl deploy ...");

The coroutine yields while labsctl runs. Other requests continue processing on the same worker.

zealphp-mongodb: async MongoDB for PHP

The custom Rust extension we built is arguably the most significant artifact of this migration. It provides:

  • Full API parity with mongodb/mongodb (Collection, Database, Client)
  • 25 Collection methods, 15 Database methods, 12 Client methods
  • Complete BSON type system (ObjectId, UTCDateTime, Regex, Binary, Decimal128, Int64)
  • True async I/O — MongoDB queries yield to the OpenSwoole event loop
  • Drop-in replacement — existing code works without changes

The numbers

MetricValue
labs-dashboard-web commits41 (since origin/master)
labs-devops commits9
App code changed237 files, +2,298 / −1,189 lines
Vendor dependencies added563 files, +77,764 lines (ZealPHP framework, openswoole stubs, composer.lock)
Docs & migration plans6 files, +4,050 lines
Total (all categories)806 files, +84,112 / −1,299 lines
SSE endpoints migrated7
Reverts during migration3
Custom extensions built1 (zealphp-mongodb, Rust)
die()/exit() calls eliminatedAll on HTTP hot paths
$_SESSION references replacedAll

The headline number (84K lines) is 92% vendor — the ZealPHP framework and its dependencies committed into the repo. The actual migration work touched 237 files with ~2,300 lines of app code changes.

Where it runs today

Both servers are live right now on the same machine (labsdev — 172.30.0.3):

URLServerHeader
labsdev.selfmade.ninjaApache :80Server: Apache/2.4.63
zealphp.selfmade.ninjaZealPHP :8080X-Powered-By: ZealPHP + OpenSwoole

Production (labs.selfmade.ninja at 106.51.76.75) still runs Apache only — ZealPHP hasn't been deployed there yet. That's the next step:

URLServerHeader
labs.selfmade.ninjaApache :80Server: Apache/2.4.58

Note: php.zeal.ninja is a separate project — the ZealPHP portfolio/demo site, not the Labs dashboard. It was born from the same framework but is its own application.

The same PHP files. The same MongoDB. The same Redis sessions. Two completely different PHP execution models. Running in the same container on dev, with production deployment coming next.

Key takeaways

  1. Bridge patterns beat big-bang rewrites. The $g context object and SSEStream class each added a thin abstraction layer that let us migrate incrementally. Neither is the "final" architecture — they're scaffolding that can be removed once Apache is retired.
  2. die() is a time bomb in plain async PHP. Any codebase migrating from Apache to an async runtime needs to audit and eliminate every die() and exit() call on HTTP hot paths. On PHP 8.4+, coroutine-legacy mode catches exit()/die() per-coroutine (via OpenSwoole's ExitException) so the worker survives — but explicit HaltException/return is still the clean, portable pattern that works across PHP versions and avoids the dependency on that behaviour.
  3. The MongoDB driver was the real blocker, not the application code. We could work around superglobals, die(), and sessions with application-level patterns. But a synchronous database driver in an async runtime is a fundamental architectural mismatch that required a new extension.
  4. Revert early, revert honestly. When coroutine mode was slower than Apache because of the blocking MongoDB driver, we didn't push through — we reverted to stable CGI mode and took the time to design a proper solution. The commit history shows the reverts. That's fine. That's engineering.
  5. Same files, two servers is a superpower during migration. Having Apache as a fallback meant we could test ZealPHP endpoint by endpoint. If something broke, users were still served by Apache. Zero downtime, zero risk.

What's next

The $g bridge and SSEStream will eventually be retired. Each SSE endpoint can become a clean ZealPHP route with native $response->sse(). Each page handler can take $request and $response as parameters instead of reaching into $g.

There is also a framework-native path worth exploring for the longer term: App::mode(App::MODE_COROUTINE_LEGACY). This preset (requires ext-zealphp) is designed exactly for the problem this migration solved — running legacy superglobal request-style PHP concurrently. It isolates the seven superglobals, $GLOBALS, function-local static variables, and require_once re-execution per coroutine at the scheduler level, so code that was written for Apache's process-per-request model runs safely under OpenSwoole concurrency without a bespoke $g shim. The bespoke shim we built is the right solution for the dual-runtime (Apache + ZealPHP simultaneously) case — coroutine-legacy is the right solution once Apache is retired.

But that's optimization, not migration. The migration is done on dev — the old code works, the new server works, both serve the same files. Production deployment is the next milestone.

Sometimes the best migration is the one where you don't have to choose.


Built at Selfmade Ninja Labs — an educational platform where students learn by doing. The ZealPHP framework is open source at github.com/sibidharan/zealphp.