Migrate your PHP codebase to async
Bring your existing code along. session_start(), header(),
$_GET, $_POST, echo — all overridden via ext-zealphp to
work inside the coroutine runtime, so the migration ladder starts with "drop your
app in and run php app.php" rather than "rewrite for an event loop."
From several services to one PHP application server
Typical PHP stack today
- Nginx / Apache (front-end)
- PHP-FPM (cold start every request)
- Redis (sessions, cache, pub/sub)
- Socket.io / Ratchet (WebSocket)
- Supervisor / cron (background jobs)
- SSE proxy or browser polling
Each tier is mature in isolation, but the per-feature wiring lives across several services and config files.
Same app on ZealPHP
php app.php
- HTTP + WebSocket + SSE built in
- Coroutine-safe sessions (single-node, file-backed; Redis-backed handler available)
- Shared memory across workers (Store, Counter)
- Task workers (no cron / supervisor)
- Persistent connections, no cold starts
- WordPress via the CGI bridge — showcase
Not every stack fits. Depends on app — see "When migration won't help" below.
The migration ladder — go at your own pace
Each rung is functional on its own. With ext-zealphp, coroutines work at every rung — the ladder is about which framework features you adopt, not about unlocking concurrency. Stop at the rung that gives you enough upside without forcing changes you're not ready for.
App::mode(App::MODE_COROUTINE_LEGACY); $app->setFallback(fn() => App::include('/index.php'));
Drop your PHP files into public/ (the document root — configurable via App::documentRoot()). setFallback() catches every URL and routes it through your existing index.php, just like Apache's RewriteRule . /index.php [L]. With ext-zealphp, $_GET/$_SESSION are per-coroutine safe, so coroutines work out of the box. Apps that use define() heavily (WordPress/Drupal) can opt into processIsolation(true) for the CGI bridge. See documented limits for complex apps.
Wins: Persistent process, no per-request boot. Sub-millisecond TTFB on cached routes. Coroutines + superglobals together — no code rewrites needed.
Trade-off: Apps with define() need App::mode(App::MODE_LEGACY_CGI) (the legacy CGI bridge). Dispatch goes through the warm worker pool — cgiMode('pool'), the default, ~1–3 ms warm because the PHP interpreter stays resident — or cgiMode('fcgi') to forward to an external FastCGI / php-fpm pool. Without ext-zealphp, running coroutines with superglobals throws a RuntimeException at boot — install ext-zealphp (pie install zealphp/ext) or use App::mode(App::MODE_COROUTINE) for coroutine concurrency without it.
public/public/about.php → /about · public/users/list.php → /users/list
File-based routing. $_GET, session_start(), echo — everything you know works. No new mental model.
Wins: Add new endpoints without leaving the LAMP idiom your team already uses.
Trade-off: Nothing — this is purely additive.
$app->route('/api/v2/users', fn($request) => [...]); // new endpoint, old app untouched
Your migrated app keeps its existing routes via setFallback(). New features go through framework routes or the file-based api/ layer — no need to rewrite existing endpoints.
Wins: Extend your app with new API endpoints, WebSocket handlers, or SSE streams without touching legacy code.
Trade-off: Nothing — purely additive. Old routes still flow through the fallback.
$app->route('/ws/chat', ...); $response->sse(...); yield $html;
WebSocket, SSE streaming, coroutines — available when you're ready, not forced upfront. Mix file-based pages with programmatic routes in the same app.
Wins: Real-time features without spinning up a separate Node/Go service. Stream AI responses, push live updates, run background coroutines.
Trade-off: Blocking I/O inside handlers still blocks the worker unless HOOK_ALL is enabled (default in coroutine mode). Use coroutine-aware drivers for DB/HTTP.
App::mode('coroutine'); // modern default: per-coroutine $g isolation + HOOK_ALL non-blocking I/O
App::mode('coroutine') is the modern, recommended preset — superglobals(false) + per-coroutine $g/RequestContext isolation + HOOK_ALL non-blocking I/O, no extension required. Read input via $g->get / $g->post / $g->session. If you have legacy request-style code that reads real $_GET/$_SESSION and you want it to run under coroutine concurrency, App::mode('coroutine-legacy') is the experimental compatibility runtime (requires ext-zealphp; it isolates the seven superglobals (S1), $GLOBALS (S2), function-local statics (S5a), and require_once state (S7) per coroutine). See lifecycle modes for the full preset matrix.
Wins: Peak throughput. 117k req/s on 4 workers — Express on the same box does 20k. Thousands of concurrent connections per worker, sub-millisecond TTFB.
Trade-off: Blocking I/O outside coroutine-hooked extensions still blocks the worker. Use HOOK_ALL and coroutine-aware drivers. coroutine-legacy is experimental and needs ext-zealphp.
How the compatibility bridge works
PHP-FPM gives you fresh superglobals ($_GET, $_SESSION),
fresh header(), fresh session_start() on every request.
OpenSwoole is one long-running process — those functions would normally collide
across requests. ZealPHP fixes that via three mechanisms:
-
ext-zealphp function overrides (53 functions). At server boot,
header(),setcookie(),http_response_code(), thesession_*()family, exec functions, and more are replaced with implementations that read/write a per-requestG::instance()object. Yourheader('Location: /foo')routes to the right OpenSwoole response without you knowing. -
Per-coroutine superglobal isolation (S1).
ext-zealphp hooks into OpenSwoole's yield/resume/close scheduler callbacks so
$_GET,$_POST,$_SESSIONare saved and restored on every context switch.superglobals(true) + enableCoroutine(true)just works — legacy code and coroutine concurrency in the same process. -
Stream-wrapper redirection.
php://inputis rewired to return the current request body, not stdin. Legacy code that doesfile_get_contents('php://input')in a JSON API handler works unchanged. -
CGI worker bridge (opt-in). When
processIsolation(true)is set (App::mode(App::MODE_LEGACY_CGI)), each public.phpfile is dispatched through a configurable isolation backend — full process isolation fordefine()-heavy apps like WordPress/Drupal. Four strategies are available:cgiMode('pool')(default) keeps a pre-spawned warm worker pool resident in memory, ~1–3 ms warm dispatch;cgiMode('proc')spawns a new subprocess per request viaproc_open, ~30–50 ms cold start;cgiMode('fork')is the experimental Apache MPM prefork runner — a long-lived fork-master forks a fresh child per request (~1 ms fork cost), giving true fresh-process correctness for unmodified WordPress without theproc_openoverhead (requirespcntl+posix; set viaApp::cgiMode('fork')— noApp::mode()preset);cgiMode('fcgi')forwards to an external FastCGI / php-fpm pool. This is opt-in, not the default — most apps don't need it.
Net effect — at every rung, your code can't tell it's running on OpenSwoole.
ext-zealphp makes $_GET/$_SESSION coroutine-safe from day one;
you opt into higher rungs when you want framework features like WebSocket and SSE.
The drop-in PHP-FPM equivalent: App::mode(App::MODE_MIXED)
If you want the closest apples-to-apples swap for a PHP-FPM deployment,
App::mode(App::MODE_MIXED) is it. It gives you native $_GET,
$_POST, $_SESSION populated per request, one request at a
time per warm worker — the exact execution model PHP-FPM / mod_php use — but
in-process, with no FastCGI socket hop and no separate web server
to bridge HTTP↔FastCGI. The HTTP server is built in. The worker is already warm, so
there's no per-request interpreter startup either.
Under the hood it expands to superglobals(true) + processIsolation(false) + enableCoroutine(false)
(which turns off the coroutine scheduler entirely) with the session lifecycle handled per request — but the preset is the recommended
surface. Because each worker handles one request at a time, there's no coroutine race
on the superglobals to worry about: it's the same shared-nothing-per-request mental
model you already have, minus the FastCGI plumbing. This is the drop-in FPM
replacement story. When you're ready for concurrency, move up to
App::mode(App::MODE_COROUTINE).
Apache+mod_php parity reference
What ZealPHP emulates so legacy apps run unchanged. Most of this is invisible — these rows exist to answer "does X work?" without a code-dive.
Function overrides (via ext-zealphp)
| Apache+mod_php function | ZealPHP behavior |
|---|---|
header(), header_remove(), headers_list(), headers_sent() | Per-request via $response->headersList on the Response wrapper ($g->zealphp_response). Supports header("HTTP/1.1 404 Not Found") status-line form and the optional $http_response_code param. CRLF/NUL in values rejected to prevent response splitting. |
setcookie(), setrawcookie() | Per-request via $response->cookiesList / rawCookiesList. setrawcookie preserves the raw value (no urlencoding). Cookie name char-class matches PHP native (rejects =,; \t\r\n\013\014\0). |
http_response_code() | Per-request via G->status. |
flush(), ob_flush(), ob_end_flush() | Switch the response into streaming mode — buffer pushed to OpenSwoole's $response->write(), flips G->_streaming = true. |
apache_request_headers(), getallheaders() | Return canonical (hyphen-capitalized) request headers from the OpenSwoole request. |
apache_response_headers() | Returns currently-set outbound headers. |
apache_setenv(), apache_getenv(), apache_note() | Per-request scratch tables in G->apacheContext (ZealPHP\Legacy\ApacheContext, lazy-allocated). |
virtual() | Returns false — internal subrequests aren't supported in this model. |
set_time_limit() | No-op success. OpenSwoole owns the worker/coroutine timeout. |
ignore_user_abort(), connection_status(), connection_aborted() | Per-request; reads $response->isWritable() for connection state. |
is_uploaded_file(), move_uploaded_file() | Whitelist of $_FILES['*']['tmp_name'] — same security guarantees as mod_php. |
session_*() (18 functions) | Coroutine-safe session lifecycle via CoSessionManager; files in /var/lib/php/sessions. |
set_error_handler(), set_exception_handler(), register_shutdown_function(), error_reporting() | Per-coroutine via G stacks. A native dispatcher installed at boot delegates to the active coroutine's handler stack — isolated despite PHP's process-global semantics. See Responses. |
public/ routing (DocumentRoot behavior)
| Apache directive | ZealPHP |
|---|---|
DirectoryIndex index.php index.html index.htm | Same fallback order via App::$directory_index. HTML/HTM served via $response->sendFile() with ETag + Range. |
DirectorySlash On | /foo → 301 /foo/ when foo is a directory. |
AcceptPathInfo On | /script.php/extra exposes PATH_INFO=/extra; rewrites REQUEST_URI. |
<FilesMatch "^\.>" deny | Dotfile URLs return 403 (.well-known/ allow-listed per RFC 8615). |
RewriteRule . /index.php [L] (catch-all, internal) | App::setFallback(fn() => App::include('/index.php')). URL stays whatever the user typed; body, status, headers, Generator return all preserved (see the universal return contract). |
RewriteRule ^old$ /new [L] (specific, internal — no [R]) | $app->route('/old', fn() => App::include('/new.php')). Same in-process include; user still sees /old in the URL bar. Don't use header('Location: …') here — that would expose the internal target. |
RewriteRule ^old$ /new [R=301,L] (external) | $app->route('/old', fn($response) => $response->redirect('/new', 301)). Browser does a fresh request; URL bar changes. Use R=302 for temporary. |
ErrorDocument 404 /custom.php | App::setErrorHandler(404, $cb). Catch-all variant: setErrorHandler($cb). Handlers fire for every 4xx/5xx site in the framework. |
FileETag / conditional GET | $response->sendFile() emits weak ETag + Last-Modified; evaluates all four conditional headers in ap_meets_conditions() order — If-Match / If-Unmodified-Since → 412, If-None-Match / If-Modified-Since → 304. |
Deeper detail (boot-order tricks, recursion guards, per-coroutine isolation mechanism, source-line references): Apache parity and docs/error-handling.md.
When migration is a good fit
Good fit
- ✓ You're already on PHP and the team knows it
- ✓ You want WebSocket / SSE / streaming without a separate Node service
- ✓ You have I/O-bound endpoints (DB, HTTP fetches) — coroutines fan them out
- ✓ You hit PHP-FPM bottlenecks (request rate, cold start latency, FPM pool tuning)
- ✓ You want cross-worker pub/sub without Redis on one node; cross-node deploys flip to Redis with one line
- ✓ You want to keep
session_start()+header()+echo— not rewrite for an event loop
Probably wrong fit
- ✗ Workload is purely CPU-bound — coroutines don't help, just buy more cores
- ✗ App relies on extensions OpenSwoole's runtime hooks don't cover (rare, but exists)
- ✗ You'd accept a full rewrite anyway — Go/Rust/Elixir give bigger ceilings if you can pay the cost
- ✗ Hard requirement for shared-nothing per-request memory (PHP-FPM's strongest guarantee)
- ✗ Production team can't accept alpha (v0.3.x) stability — wait for v1.0
- ✗ You need byte-for-byte Apache/nginx config replacement — ZealPHP covers the common .htaccess / nginx.conf patterns but isn't a drop-in for every directive
Convert your existing config
Paste your Apache .htaccess or nginx config — AI converts it to a working app.php in real-time. The same engine that bridges the migration ladder above.
// Output will appear here...
Performance: 117K req/s text · 106K JSON · 50K templated (coroutine mode, available at every rung with ext-zealphp).
WordPress + custom CMS migrations: see the showcase repo.