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.

Running Legacy PHP Apps

ZealPHP runs unmodified WordPress — admin dashboard, login, posts, plugins — out of the box. No patches, no forks, no compatibility layers. If it runs on Apache, it runs on ZealPHP.

WordPress homepage served by ZealPHP
WordPress front page
WordPress admin dashboard on ZealPHP
Admin dashboard — full menu, widgets, Quick Draft
WordPress posts list on ZealPHP
Posts management — CRUD, bulk actions, filters

Zero WordPress modifications required. Login, sessions, cookies, redirects, file uploads, REST API, pretty permalinks — everything works through ZealPHP's CGI worker with true global scope isolation. The same app.php works for Drupal, Laravel, or any traditional PHP application.

How It Works

Three framework features enable legacy app compatibility:

FeatureWhat it doesApache equivalent
App::superglobals(true) $_GET, $_POST, $_SERVER, $_SESSION, $_COOKIE work as expected mod_php (default behavior)
App::$ignore_php_ext = false Allows .php extensions in URLs (/wp-login.php, /admin/edit.php) AddHandler php-script .php
App::includeFile() Runs each PHP file in a separate process at true global scope via the CGI worker mod_prefork MPM + CGI
Minimal legacy app configuration
<?php
require 'vendor/autoload.php';
use ZealPHP\App;

App::superglobals(true);
App::$ignore_php_ext = false;

$app = App::init('0.0.0.0', 8080);
$app->run(['task_worker_num' => 0]);
// PHP files in public/ are served automatically with process isolation

Porting from Apache .htaccess

ZealPHP replaces .htaccess entirely. Here are real-world conversions:

WordPress .htaccess

Before (.htaccess)
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
After (app.php)
App::superglobals(true);
App::$ignore_php_ext = false;
$app = App::init('0.0.0.0', 8080);

$app->setFallback(function() {
    $g = G::instance();
    $g->server['PHP_SELF'] = '/index.php';
    $g->server['SCRIPT_NAME'] = '/index.php';
    $g->server['SCRIPT_FILENAME'] =
        App::$cwd . '/public/index.php';
    App::includeFile(
        App::$cwd . '/public/index.php'
    );
});

$app->run(['task_worker_num' => 0]);

Redirect rules

Before (.htaccess)
RewriteRule ^old-page$ /new [R=301,L]
RewriteRule ^blog/(.*)$ /articles/$1 [R=302,L]
After (app.php)
$app->route('/old-page', function() {
    header('Location: /new');
    return 301;
});

$app->patternRoute('/blog/.*', function() {
    $path = preg_replace(
        '#^/blog/#', '/articles/',
        $_SERVER['REQUEST_URI']
    );
    header('Location: ' . $path);
    return 302;
});

Quick reference

Apache .htaccessZealPHP equivalent
RewriteEngine OnNot needed — ZealPHP routes natively
RewriteRule . /index.php [L]$app->setFallback(function() { ... })
RewriteRule ^path$ /dest [R=301,L]$app->route('/path', function() { header('Location: /dest'); return 301; })
DirectoryIndex index.phpBuilt-in — implicit routes serve index.php for directories
Options -IndexesNot needed — ZealPHP never lists directories
<FilesMatch "\.php$">Not needed — ZealPHP IS the PHP runtime

Porting from nginx

Before (nginx.conf)
server {
    listen 80;
    root /var/www/html;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }
    location ~ \.php$ {
        fastcgi_pass unix:/run/php-fpm.sock;
    }
    location ~* \.(css|js|png)$ {
        expires 30d;
    }
}
After (app.php)
App::superglobals(true);
App::$ignore_php_ext = false;
$app = App::init('0.0.0.0', 8080);

// try_files → fallback
$app->setFallback(function() {
    $g = G::instance();
    $g->server['PHP_SELF'] = '/index.php';
    $g->server['SCRIPT_NAME'] = '/index.php';
    $g->server['SCRIPT_FILENAME'] =
        App::$cwd . '/public/index.php';
    App::includeFile(
        App::$cwd . '/public/index.php'
    );
});

// Static files (css, js, png) served
// automatically by OpenSwoole
// Cache headers: add custom middleware

$app->run(['task_worker_num' => 0]);
nginx directiveZealPHP equivalent
try_files $uri $uri/ /index.php$app->setFallback(fn() => App::includeFile(...))
location ~ \.php$ { fastcgi_pass ...; }Not needed — ZealPHP serves PHP directly
location ~* \.(css|js)$ { expires 30d; }OpenSwoole enable_static_handler + middleware for headers
proxy_pass http://backend;Use native $app->route() or reverse proxy in front

AI Config Converter

Paste your .htaccess or nginx config — get a working app.php streamed in real-time. Powered by gpt-5.4-mini with the full ZealPHP API reference.

Apache / nginx config
ZealPHP app.php
// Output will appear here...
Rate limit: 5 conversions per 10 minutes · Powered by gpt-5.4-mini · Source
CLI usage (also available as a command-line tool)
# Pipe any config — get app.php on stdout
cat .htaccess | uv run examples/agents/config_converter.py

# Interactive mode
uv run examples/agents/config_converter.py

WordPress Example

A complete app.php that runs WordPress on ZealPHP:

app.php — WordPress on ZealPHP
<?php
require 'vendor/autoload.php';
use ZealPHP\App;
use ZealPHP\G;

App::superglobals(true);
App::$ignore_php_ext = false;

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

// Redirect /wp-admin to /wp-admin/index.php
$app->route('/wp-admin', function() {
    $qs = !empty($_SERVER['QUERY_STRING']) ? '?' . $_SERVER['QUERY_STRING'] : '';
    header('Location: /wp-admin/index.php' . $qs);
    return 301;
});

// Fallback: unmatched URLs → WordPress front controller
// Replaces Apache's: RewriteRule . /index.php [L]
$app->setFallback(function() {
    $g = G::instance();
    $g->server['PHP_SELF'] = '/index.php';
    $g->server['SCRIPT_NAME'] = '/index.php';
    $g->server['SCRIPT_FILENAME'] = App::$cwd . '/public/index.php';
    App::includeFile(App::$cwd . '/public/index.php');
});

$app->run(['task_worker_num' => 0]);

Setup Steps

See the full working example: github.com/sibidharan/zealphp-wordpress

  1. Create a ZealPHP project: composer create-project sibidharan/zealphp-project my-wordpress
  2. Download WordPress into public/: cd my-wordpress/public && wp core download
  3. Configure public/wp-config.php with your database settings
  4. Write app.php as shown above
  5. Start: php app.php (or php app.php start -p 9501 -d to daemonize)
  6. Visit http://localhost:9501/wp-admin/install.php to complete installation

CGI Worker Architecture

App::includeFile() runs each PHP file in a separate process via proc_open. This gives every request a clean PHP interpreter with true global scope — exactly like Apache's prefork MPM.

How App::includeFile() works
OpenSwoole Worker (long-lived)          CGI Worker (per-request)
┌─────────────────────────┐             ┌──────────────────────────┐
│                         │  proc_open  │  php cgi_worker.php      │
│  Route matched          │ ──────────► │                          │
│  App::includeFile()     │             │  TRUE global scope:      │
│                         │   stdin     │  ├─ $_SERVER, $_GET, etc. │
│  Serializes context:    │ ──────────► │  ├─ $_COOKIE, $_FILES    │
│  ├─ $_SERVER, $_GET     │  (POST body)│  │                       │
│  ├─ $_POST, $_COOKIE    │             │  ├─ uopz captures:       │
│  └─ Request body        │             │  │  header(), setcookie() │
│                         │   stdout    │  │  http_response_code()  │
│  Reads response:        │ ◄────────── │  │                       │
│  ├─ Body from stdout    │             │  ├─ include file.php     │
│  ├─ Metadata from stderr│   stderr    │  │  ← app runs at global │
│  │  (status, headers,   │ ◄────────── │  │    scope              │
│  │   cookies as JSON)   │             │  │                       │
│  └─ Applies to response │             │  └─ Process exits (clean)│
└─────────────────────────┘             └──────────────────────────┘

What the CGI worker handles

FeatureHow
All HTTP methods$_SERVER['REQUEST_METHOD'] passed via context; request body piped to stdin (php://input)
header() / header_remove()Captured via uopz_set_return — sent back as JSON metadata
setcookie() / setrawcookie()Captured — applied to response by parent worker
http_response_code() / headers_list()Captured — status and headers returned in metadata
exit() / die()register_shutdown_function flushes output and metadata
SSE streamingDetects text/event-stream; streams via flush() like Apache
Static filesServed directly by OpenSwoole — never reaches PHP
File uploads / Sessions$_FILES via context; PHP native sessions work in CGI process

CLI Management

CLI commands
php app.php                     # Start with defaults
php app.php start -p 9501       # Start on port 9501
php app.php start -p 9501 -d   # Start daemonized
php app.php stop                # Stop the server (reads PID file)
php app.php status              # Check if server is running
php app.php start -w 8          # Start with 8 workers
php app.php --help              # Show all options

Limitations

Performance: Each PHP file request spawns a new process. Static files bypass this (served by OpenSwoole). For high-traffic production, convert hot paths to native ZealPHP routes.

Streaming: SSE works in CGI mode via flush(). WebSocket requires native ZealPHP routes (App::ws()).

Hybrid approach: Mix native routes (coroutine mode, high performance) with legacy PHP file serving (CGI mode) in the same app. Explicit $app->route() handlers run directly in the worker.