Migrate your PHP codebase to async
Bring your existing code along. session_start(), header(),
$_GET, $_POST, echo — all overridden via uopz 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 process
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
6 services, 6 failure points, 6 sets of config.
Same app on ZealPHP
php app.php
- HTTP + WebSocket + SSE built in
- Coroutine-safe sessions (no Redis)
- 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. Stop at the rung that gives you enough upside without forcing changes you're not ready for. Most real migrations stay between rungs 1 and 3 for months before reaching 4.
App::superglobals(true); $app->setFallback(fn() => App::includeFile('index.php'));
Most existing PHP apps — WordPress, Drupal, custom legacy code — run unchanged on OpenSwoole through the CGI worker bridge. No code edits required to start serving requests faster.
Wins: Persistent process, no per-request boot. Sub-millisecond TTFB on cached routes.
Trade-off: Coroutines, WebSocket, SSE — you're still bound by the global-state model.
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.
api/api/users/get.php → GET /api/users · api/users/post.php → POST /api/users
Drop a PHP file, get a REST endpoint. ZealAPI auto-routes by filename and HTTP method. Zero config, zero framework boilerplate.
Wins: Replace your "PHP file behind nginx" API layer with structured endpoints in 5 lines each.
Trade-off: Still synchronous — handlers run sequentially. Fine for I/O-light endpoints.
$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: Still allows blocking calls inside individual handlers — coroutine isolation is opt-in at rung 4.
App::superglobals(false); // thousands of concurrent requests per worker
Replace $_GET/$_SESSION globals with G::instance(). Each coroutine gets its own context; one worker handles thousands of concurrent requests without blocking.
Wins: Peak throughput. 117k req/s on 4 workers — Express on the same box does 20k.
Trade-off: You must avoid blocking I/O outside coroutine-hooked extensions, and any code that mutates global state needs a per-coroutine equivalent.
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:
-
uopz function overrides. At server boot,
header(),setcookie(),http_response_code(), and thesession_*()family are replaced with implementations that read/write a per-requestG::instance()object. Yourheader('Location: /foo')routes to the right OpenSwoole response without you knowing. -
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. When
App::superglobals(true)+setFallback()are in use, requests that don't match a framework route are forwarded to a CGI-style child process viaproc_open— full process isolation, just like mod_php. That's how WordPress runs.
Net effect — at rung 0 and 1, your code can't tell it's running on OpenSwoole. At rungs 3 and 4, you opt into the coroutine model where it pays off.
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 uopz)
| Apache+mod_php function | ZealPHP behavior |
|---|---|
header(), header_remove(), headers_list(), headers_sent() | Per-request via G->response_headers_list. Supports header("HTTP/1.1 404 Not Found") status-line form and the optional $http_response_code param. |
setcookie(), setrawcookie() | Per-request via G->response_cookies_list / response_rawcookies_list. setrawcookie preserves the raw value (no urlencoding). |
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->apache_env / apache_notes. |
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] | App::setFallback(fn() => App::includeFile(...)). Body, status, headers, Generator return all preserved. |
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; honors If-None-Match and If-Modified-Since → 304. |
Deeper detail (boot-order tricks, recursion guards, per-coroutine isolation mechanism, source-line references): docs/apache-parity.md 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 long-lived sessions or pub/sub without Redis
- ✓ 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.2.x) stability — wait for v1.0
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.
Performance: 117K req/s text · 106K JSON · 50K templated at rung 4 (full coroutine mode).
WordPress + custom CMS migrations: see the showcase repo.