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):
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:
- Dockerfile — Ubuntu 24.04 → 25.04 (PHP 8.4), added OpenSwoole 26 + uopz, Rust toolchain for building zealphp-mongodb
- entry.sh — 8 lines: "if
server.phpexists, start ZealPHP as a daemon on port 8080" - php-override.ini — Swapped
extension=mongodb.so→extension=zealphp_mongodb.so - docker-compose.override.prod.yml — Added Traefik router for
zealphp.selfmade.ninja→ port 8080 - 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:
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:
$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:
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:
$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:
$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::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.
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:
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:
- Accept blocking I/O and lose most of ZealPHP's performance benefit
- 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-rsfor 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 --releasein the Dockerfile)
; 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:
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:
$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:
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:
$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:
- The SSE endpoints are PHP files loaded by
include— they're not route closures that receive$responseas a parameter. They're legacy files included into ZealPHP's request context via thepage()helper. They don't have direct access to the route-level$responseobject. $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
$responseas a parameter - ZealPHP's
RequestContextexposesopenswoole_responsespecifically 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():
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
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:
// 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
| Metric | Value |
|---|---|
| labs-dashboard-web commits | 41 (since origin/master) |
| labs-devops commits | 9 |
| App code changed | 237 files, +2,298 / −1,189 lines |
| Vendor dependencies added | 563 files, +77,764 lines (ZealPHP framework, openswoole stubs, composer.lock) |
| Docs & migration plans | 6 files, +4,050 lines |
| Total (all categories) | 806 files, +84,112 / −1,299 lines |
| SSE endpoints migrated | 7 |
| Reverts during migration | 3 |
| Custom extensions built | 1 (zealphp-mongodb, Rust) |
die()/exit() calls eliminated | All on HTTP hot paths |
$_SESSION references replaced | All |
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):
| URL | Server | Header |
|---|---|---|
| labsdev.selfmade.ninja | Apache :80 | Server: Apache/2.4.63 |
| zealphp.selfmade.ninja | ZealPHP :8080 | X-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:
| URL | Server | Header |
|---|---|---|
| labs.selfmade.ninja | Apache :80 | Server: 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
- Bridge patterns beat big-bang rewrites. The
$gcontext object andSSEStreamclass 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. die()is a time bomb in plain async PHP. Any codebase migrating from Apache to an async runtime needs to audit and eliminate everydie()andexit()call on HTTP hot paths. On PHP 8.4+,coroutine-legacymode catchesexit()/die()per-coroutine (via OpenSwoole'sExitException) so the worker survives — but explicitHaltException/returnis still the clean, portable pattern that works across PHP versions and avoids the dependency on that behaviour.- 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. - 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.
- 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.