' > web/index.html
# Run
php qbixserver.php --port=8080
```
Open [http://localhost:8080](http://localhost:8080). That's it.
```bash
# Or serve an existing directory
php qbixserver.php --root=/var/www/mysite --port=80
# Or use the PHAR (single file, ~250KB)
php bin/qbixserver.phar --root=./public --port=8080
```
---
## π Performance
Benchmarked against nginx on the same single-core container, PHP 8.3, Ubuntu 24.
13KB static file, best-of-3 runs, warm caches.
| Scenario | nginx | Qbix Server | Ratio |
|---|---|---|---|
| Sequential (c=1) | 10,154 req/s | 6,376 req/s | **63%** |
| Concurrent (c=10) | 12,300 req/s | 6,876 req/s | **56%** |
| High concurrency (c=50) | 12,919 req/s | 7,253 req/s | **56%** |
| Keep-alive (c=10) | 26,858 req/s | 19,700 req/s | **73%** |
| Keep-alive (c=50) | 30,158 req/s | 20,369 req/s | **67%** |
Zero failed requests across 50,000+ requests at concurrency 50. Server never crashed.
> For context: 20K req/s means the server handles **1,000 simultaneous page loads per second**
> (assuming ~20 static assets per page), all from a single PHP process.
---
## ποΈ Why Not php-fpm?
The traditional stack β nginx + php-fpm β works like this:
```
Request β nginx β FastCGI socket β php-fpm worker
β
Load PHP
Include autoloader
Boot framework
Connect to DB
Run your code
Send response
β
Worker resets or dies
```
Every PHP request pays the bootstrap cost. Even with OPcache, each php-fpm worker re-initializes your framework's class instances, config trees, and DB connections on every request. For a framework like Qbix (or Laravel, Symfony, etc.), this bootstrap takes **10β50ms** β often longer than the actual work.
**Qbix Server eliminates this entirely:**
```
Startup:
1. Load PHP
2. Include autoloader
3. Load ALL framework classes into memory
4. Parse ALL config files
5. Connect to database
6. pcntl_fork() β workers inherit everything
β
Request:
Worker already has classes, config, DB connections.
Just run your code. 0ms bootstrap.
```
The key insight is **fork after preload**. Unix `fork()` uses copy-on-write, so forked workers share the parent's memory pages for all those preloaded classes. Each worker starts with ~30MB shared (read-only) and allocates only the per-request data. Compare this to php-fpm where each worker loads everything independently, using 30MB Γ N workers of duplicated memory.
### Preloading classes
Use the `--workers=N` flag and configure which classes to preload:
```json
{
"Q": {
"webserver": {
"preload": [
"Q_Dispatcher", "Q_Request", "Q_Response",
"Q_Config", "Q_Cache", "Q_Session",
"Db", "Db_Mysql", "Db_Row", "Db_Query",
"Users", "Users_User", "Users_Session",
"Streams", "Streams_Stream", "Streams_Message"
]
}
}
}
```
```bash
# Start with 4 workers (classes loaded once, shared across all)
php qbixserver.php --app=/path/to/myapp --port=8080 --workers=4
```
The parent process loads and parses every class in the `preload` list, then forks. Workers inherit the entire loaded state β OPcache entries, class definitions, parsed config trees, autoloader maps. The first PHP request in each worker runs at full speed, no cold start.
### The numbers
| | php-fpm | Qbix Server |
|---|---|---|
| Bootstrap per request | 10β50ms | **0ms** |
| Memory per worker | 30β60MB each | 30MB shared + ~5MB per worker |
| IPC overhead | FastCGI socket + serialization | Direct function call or Unix fork |
| Static files | Separate nginx process | Same process, memory-cached, single `fwrite` |
| Config reload | Restart all workers | `SIGHUP`, zero downtime |
| WebSocket | Needs separate server | Built in |
For a Qbix app with 20 loaded plugins, the bootstrap savings alone make the server **2β5x faster** on PHP requests compared to nginx + php-fpm.
### Why it's actually faster in practice
The benchmarks above measure static file throughput, where nginx's C implementation and `sendfile()` syscall give it an inherent edge. But for **real PHP applications**, the story flips:
- **nginx + php-fpm:** 0.1ms static file + 30ms PHP bootstrap + 5ms actual work = **35ms**
- **Qbix Server:** 0.15ms static file + 0ms bootstrap + 5ms actual work = **5ms**
The 0.05ms you lose on static files, you gain back 30ms on every PHP request. And you can always put nginx or a CDN in front for the static file edge.
---
## βοΈ vs FrankenPHP and Swoole
If you're looking beyond php-fpm, you've probably seen FrankenPHP and Swoole. Here's how they compare:
| | FrankenPHP | Swoole | Qbix Server |
|---|---|---|---|
| **Language** | Go + C (embeds PHP) | C extension for PHP | Pure PHP |
| **Install** | Download Go binary or Docker | `pecl install swoole` (compiles C) | `php qbixserver.php` β nothing to install |
| **Architecture** | Worker mode (persistent) | Coroutine-based (persistent) | Shared-nothing with fork-after-preload |
| **State leaks** | β οΈ Possible β workers persist between requests | β οΈ Possible β must manage globals carefully | β Impossible β each request gets a clean fork |
| **PHP compatibility** | Most code works, some edge cases | Many extensions incompatible, blocking I/O breaks coroutines | β 100% β standard PHP, nothing unusual |
| **Memory safety** | Go runtime + PHP = complex interaction | C extension = segfault risk | PHP only = memory-safe by default |
| **Access control** | No X-Accel-Redirect equivalent | Manual implementation | β Built-in X-Accel-Redirect |
| **Component cache** | No | No | β X-Cache-Tree β sub-page invalidation |
| **Early hints / 103** | β Yes | No | Via amphp |
| **HTTP/2** | β Built-in (Caddy) | β Built-in | β Via amphp |
| **WebSocket** | Via Mercure | β Built-in | β Built-in |
### The shared-nothing advantage
FrankenPHP and Swoole keep PHP workers alive across requests. This is fast, but it means global state, static variables, database connections, and in-memory caches **persist between unrelated requests**. This causes subtle bugs:
```php
// This leaks between requests in FrankenPHP/Swoole:
class UserService {
private static ?User $cached = null;
public static function current(): User {
if (!self::$cached) {
self::$cached = User::fromSession();
}
return self::$cached; // Returns previous user's data!
}
}
```
Every PHP framework, library, and snippet that uses static variables, singletons, or global state becomes a potential security hole. You have to audit everything.
Qbix Server avoids this entirely. Workers fork from a preloaded parent, so they inherit loaded classes and parsed config (read-only, shared via copy-on-write). But each request runs in its own process β when it's done, everything is gone. No state leaks. No audit needed. Your existing PHP code works exactly as it does on php-fpm.
### The "just PHP" advantage
FrankenPHP requires Go tooling to build or a pre-built binary that bundles Caddy. Swoole requires compiling a C extension, which can conflict with other extensions and doesn't work on all hosting environments.
Qbix Server is a PHP file. If you can run `php -v`, you can run the server. It uses standard PHP extensions (`sockets`, `pcntl`) that come pre-installed on most systems. There's no compilation step, no foreign runtime, no binary compatibility issues.
```bash
# FrankenPHP
docker pull dunglas/frankenphp # 150MB+ image, or build from Go source
# Swoole
pecl install swoole # compiles C, may fail on some systems
# Then edit php.ini, restart php...
# Qbix Server
php qbixserver.php --port=8080 # done
```
### When to choose what
**Choose FrankenPHP** if you want Caddy's ecosystem (automatic HTTPS, HTTP/3) and don't mind Go as a dependency. Good for Laravel projects that already use Octane.
**Choose Swoole** if you need coroutines for high-concurrency I/O (thousands of simultaneous HTTP client requests, database queries). Good for async-heavy microservices.
**Choose Qbix Server** if you want shared-nothing safety, zero-install deployment, access-controlled file serving, component-level cache invalidation, and full compatibility with existing PHP code. Good for apps that serve pages (not just APIs), need fine-grained caching, and want the simplest possible deployment.
---
## β¨ Features
| Category | What you get |
|---|---|
| **Static files** | ETag, 304 Not Modified, Last-Modified, MIME type detection, in-memory response cache |
| **Keep-alive** | HTTP/1.0 and 1.1, TCP_NODELAY, configurable limits |
| **HTTP/2** | Via amphp β multiplexed streams, header compression, TLS (optional) |
| **PHP execution** | `.php` files in document root run in-process or via pre-fork worker pool |
| **Compression** | On-the-fly gzip/brotli + pre-compressed `.gz`/`.br` siblings |
| **WebSocket** | RFC 6455 upgrade on any path |
| **Dashboard** | Live stats at `/Q/dashboard` β request rates, memory, status codes |
| **Health check** | JSON at `/Q/health` β for load balancers and monitoring |
| **Control panel** | Password-protected at `/Q/panel` β manage apps and scripts |
| **Rate limiting** | Per-IP with configurable windows and burst limits |
| **Security** | Path traversal blocked, dotfiles blocked, 431 for oversized headers, 400 for malformed requests |
| **Graceful shutdown** | SIGTERM/SIGINT drain in-flight requests before closing |
| **TLS** | Optional HTTPS with auto-certbot or manual certs |
| **Logging** | Colored terminal output + file-based access logs |
| **Access control** | X-Accel-Redirect support β PHP enforces access, server serves the file |
| **Component cache** | X-Cache-Tree headers β invalidate parts of a page, not the whole thing |
---
## π Server Headers β What Your PHP Can Send
Qbix Server understands special response headers from your PHP scripts. These are
the same headers nginx understands (like `X-Accel-Redirect`) plus new ones for
component-level caching. Your PHP sends them with `Q::header()`, the server acts on them.
### Quick reference
| Header | What it does | Example |
|---|---|---|
| `Cache-Control` | Server caches the response, serves without running PHP | `Q::header('Cache-Control: public, max-age=300');` |
| `X-Accel-Redirect` | Server streams a file after PHP checks access | `Q::header('X-Accel-Redirect: /uploads/private/doc.pdf');` |
| `X-Cache-Tree` | Registers page components with content hashes | `Q::header('X-Cache-Tree: ' . json_encode([...]));` |
| `X-Cache-Deps` | Maps components to data dependency keys | `Q::header('X-Cache-Deps: ' . json_encode([...]));` |
| `X-Cache-Invalidate` | Marks dependency keys as stale | `Q::header('X-Cache-Invalidate: ' . json_encode([...]));` |
| `X-Cache-Stale` | Marks specific components as needing re-render | `Q::header('X-Cache-Stale: feed,sidebar');` |
All of these use `Q::header()` instead of PHP's `header()`. This is because
the server runs in CLI SAPI where `header()` calls are silently discarded β
same as FrankenPHP worker mode and Workerman. `Q::header()` has the same
signature as `header()` but captures the values for the server to send.
The server strips internal headers before sending the response to the client.
### Access-controlled static files
With a typical server, your uploaded files sit at public URLs. Anyone with the link can
access them β and share the link with others. The usual workaround is "unguessable" URLs,
which are just security through obscurity.
`X-Accel-Redirect` lets your PHP check access, then tells the server to serve the file.
By convention, private files live in `files/` β a sibling of `web/`, outside the document root:
```
myproject/
βββ web/ β public (accessible via URL)
β βββ download.php β checks access, sends X-Accel-Redirect
βββ files/ β private (NOT accessible via URL)
βββ private/
βββ doc.pdf β served only through download.php
```
```php
[
'feed' => md5($feedHtml),
'sidebar' => md5($sidebarHtml),
'members' => md5($membersHtml),
]
]));
Q::header('X-Cache-Deps: ' . json_encode([
'feed' => ["community/{$communityId}/feed"],
'sidebar' => ["community/{$communityId}/about"],
'members' => ["community/{$communityId}/participants"],
]));
Q::header('Cache-Control: public, max-age=300');
echo $feedHtml . $sidebarHtml . $membersHtml;
```
**Step 2: Invalidate when data changes**
```php
true]);
```
The server maintains a Merkle tree of component hashes. When a dependency key is
invalidated, it walks the tree to find exactly which components on which pages are
affected. Everything else is served from the in-memory cache.
### Even more powerful with Qbix Platform
These headers work with `Q::header()` calls as shown above. But with the
[Qbix Platform](https://github.com/Qbix/Platform), it becomes automatic:
```php
// Tools call this during rendering β the framework handles the rest
Q_Response::setCacheComponent('Streams/feed', $hash, [$depKey]);
Q_Response::invalidateCacheDeps($publisherId . '/' . $streamName);
// X-Accel-Redirect for access-controlled files
Q_Response::redirect(['uri' => $internalPath, 'accel' => true]);
// Cache-Control with semantic options
Q_Response::cacheFor(300);
```
The Platform's Streams plugin automatically invalidates cache dependencies when
stream data changes β posts, relations, participant joins β so cached pages
update themselves without any manual invalidation calls.
---
## π WebSocket β Real-Time PHP
Each WebSocket connection gets **one PHP process** β forked from the preloaded
parent, stays alive for the entire connection. The server dispatches each message
to a handler via `Q::event()`. Static variables in handlers persist across
messages. When the client disconnects, the process exits β all state wiped.
Same mental model as HTTP handlers, same `handlers/` directory, same `Q::event()`.
The only difference: the process lives longer.
### Handlers
```php
'chat/message',
'data' => ['user' => $userId, 'text' => $text],
]);
$result = ['count' => $messageCount];
}
```
```php
$params['data']['room']];
}
```
```php
'invalid token']);
return;
}
Q_Socket::join($params['_socketId'], "user/$userId");
$result = ['authenticated' => true];
}
```
### Config
Map event names to handlers. Also supports `_connect` and `_disconnect` lifecycle events:
```json
{
"Q": {
"webserver": {
"sockets": {
"events": {
"_connect": "auth/connect",
"_disconnect": "chat/leave",
"chat/message": "chat/message",
"chat/join": "chat/join",
"chat/typing": "chat/typing"
}
}
}
}
}
```
If no mapping is configured, the event name is used directly as the handler path.
### The JS client (qbix-socket.js)
```html
```
Auto-reconnects with exponential backoff. Ack callbacks for request-response.
### Q_Socket API
| Method | What it does |
|---|---|
| `Q_Socket::reply($data)` | Send to this connection's client |
| `Q_Socket::send($socketId, $data)` | Send to a specific client |
| `Q_Socket::broadcast($room, $data)` | Send to all clients in a room |
| `Q_Socket::broadcastAll($data)` | Send to ALL connected clients |
| `Q_Socket::join($socketId, $room)` | Subscribe a client to a room |
| `Q_Socket::leave($socketId, $room)` | Unsubscribe from a room |
### Protocol
```
Client β Server: {"event": "chat/message", "data": {...}, "ack": 42}
Server β Client: {"ack": 42, "data": {...}} (callback)
Server β Client: {"event": "chat/message", "data": {...}} (broadcast)
```
### Architecture
```
Browser ββWebSocketββ Parent (event loop)
β
connect: fork child, create IPC pipe
message: parent writes to child's pipe
child runs Q::event() handler
child calls Q_Socket::broadcast()
parent reads pipe, sends to sockets
disconnect: parent signals, child exits
```
One process per connection. Each handler is a thin wrapper calling preloaded
class methods β the per-connection COW delta is typically ~40-200KB (just
static variables, call stack, and IPC buffer). The 30MB+ class base is shared.
On an 8GB server, that's **40,000+ concurrent WebSocket users**. HTTP requests
fork separately β both run simultaneously from the same preloaded parent.
---
## π Example: A Complete Chat App
Everything below fits in one small project. HTTP handles pages and REST.
WebSocket handles real-time messaging. Both use the same `classes/` and
`handlers/` directories.
### Project structure
```
chat/
βββ qbixserver.php
βββ config/
β βββ server.json
βββ web/
β βββ index.html β static: the chat UI
β βββ qbix-socket.js β static: WebSocket client
β βββ api/
β β βββ messages.php β HTTP: GET recent messages
β β βββ login.php β HTTP: POST authenticate, return token
β βββ style.css
βββ classes/
β βββ Chat/
β βββ Auth.php β shared: token validation
β βββ Messages.php β shared: DB read/write
β βββ Rooms.php β shared: room membership
βββ handlers/
βββ chat/
βββ connect.php β socket: authenticate on connect
βββ disconnect.php β socket: set user offline
βββ message.php β socket: broadcast a message
βββ join.php β socket: join a room
βββ typing.php β socket: broadcast typing indicator
```
### Config
```json
{
"Q": {
"webserver": {
"preload": {
"classes": ["Chat\\Auth", "Chat\\Messages", "Chat\\Rooms"]
},
"sockets": {
"events": {
"_connect": "chat/connect",
"_disconnect": "chat/disconnect",
"chat/message":"chat/message",
"chat/join": "chat/join",
"chat/typing": "chat/typing"
}
}
}
}
}
```
### HTTP scripts β pages and REST
```php
'Invalid credentials']);
exit;
}
Q::header('Content-Type: application/json');
echo json_encode([
'token' => Chat\Auth::createToken($user['id']),
'userId' => $user['id'],
'name' => $user['name'],
]);
```
```php
'not authenticated']);
return;
}
$userId = $user['id'];
$userName = $user['name'];
}
$text = $params['data']['text'] ?? '';
if (!$text) return;
// Save to database
$id = Chat\Messages::save($userId, $params['data']['room'] ?? 'general', $text);
// Broadcast to everyone in the room
Q_Socket::broadcast($params['data']['room'] ?? 'general', [
'event' => 'chat/message',
'data' => [
'id' => $id,
'user' => $userName,
'text' => $text,
'time' => date('c'),
],
]);
$result = ['id' => $id]; // ack back to sender
}
```
```php
$room];
}
```
```php
'chat/typing',
'data' => ['user' => $params['data']['user']],
]);
}
```
```php
'Authentication required']);
exit; // safe β forked process
}
}
```
```php
$_SERVER['REQUEST_URI']]);
}
```
**Static 404 page** β serve a file without invoking PHP:
```json
{ "Q": { "webserver": { "fallback": {"file": "404.html"} } } }
```
### The full symmetry
```
Static files: GET /style.css β web/style.css
PHP scripts: GET /page.php β web/page.php (direct execution)
HTTP routed: GET /api/users β handlers/api/users/get.php
WebSocket: {"event":"chat/message"} β handlers/chat/message.php
All four use classes/ (preloaded, shared)
The last three use handlers/ (loaded on demand)
```
Drop files. They work. No framework to learn, no boilerplate to write.
When you outgrow it, the same handlers run on the full Qbix Platform.
---
## π For PHP Developers β The Micro-Framework
Qbix Server isn't just a static file server with PHP bolted on. It's a micro-framework
where you **drop files into conventional directories** and things just work β classes
autoload, events fire handlers, views render templates. No configuration needed for
the basics.
### Project layout
```
myproject/
βββ qbixserver.php β server entry point (or use the PHAR)
βββ config/
β βββ server.json β server + app configuration
βββ web/ β document root (publicly accessible)
β βββ index.html β static files served directly
β βββ style.css
β βββ api.php β PHP scripts executed on request
β βββ uploads/
βββ classes/ β your PHP classes (autoloaded when first used)
β βββ MyApp/
β β βββ User.php β MyApp\User or MyApp_User
β β βββ Feed.php
β β βββ Auth.php
β βββ vendor/
β βββ autoload.php β Composer autoloader (optional)
βββ handlers/ β event handlers (loaded on demand)
β βββ MyApp/
β βββ feed/
β βββ post.php β handles "MyApp/feed/post" event
β βββ validate.php β handles "MyApp/feed/validate" event
βββ views/ β PHP templates for Q::view()
βββ MyApp/
βββ feed/
βββ page.php
βββ item.php
```
Only `web/` is accessible via HTTP. Everything else is server-side only.
**Your PHP scripts don't need to `require` or `include` anything.** The server
has already loaded the `Q` class, the autoloader, and the event system before
your script runs. Classes from `classes/`, events via `Q::event()`, views via
`Q::view()` β all available immediately. Just write your code:
```php
$user->id]);
Q::header('Content-Type: application/json');
echo json_encode($feed);
```
### The `Q` class β available in every script
The server injects the `Q` class into every PHP script automatically. Here's
what you get:
| Method | What it does |
|---|---|
| `Q::event($name, $params)` | Fire an event β runs the handler from `handlers/` |
| `Q::canHandle($name)` | Check if a handler exists for an event |
| `Q::header($str, $replace, $code)` | Set a response header (use instead of `header()`) |
| `Q::view($name, $params)` | Render a PHP template from `views/` |
| `Q::ifset($arr, 'key1', 'key2', $default)` | Safe nested array/object access without isset chains |
| `Q::getObject($data, ['path', 'to', 'key'], $default)` | Deep access into nested arrays/objects |
| `Q::setObject(['path', 'to', 'key'], $value, $data)` | Deep set into nested arrays, creating intermediates |
| `Q::json_encode($value)` | `json_encode` with unescaped slashes |
| `Q::json_decode($json, true)` | `json_decode` wrapper |
| `Q_Config::get('section', 'key', $default)` | Read from `config/server.json` |
| `Q_Config::set('section', 'key', $value)` | Set a config value at runtime |
| `Q_Config::expect('section', 'key')` | Read config or throw if missing |
| `Q_Request::method()` | HTTP method: GET, POST, PUT, DELETE |
| `Q_Request::input()` | Raw request body (replaces `php://input`) |
| `Q_Request::json()` | Request body parsed as JSON |
| `Q_Request::header('X-Custom')` | Get any request header |
| `Q_Request::ip()` | Client IP (proxy-resolved) |
| `Q_Request::files('avatar')` | Uploaded files from `$_FILES` |
| `Q_Request::isAjax()` | True if X-Requested-With: XMLHttpRequest |
| `Q_Request::isJson()` | True if Content-Type is application/json |
| `Q_Request::isInternal()` | True if genuine CLI, false if server-dispatched |
| `Q_Response::setHeader($name, $value)` | Set a response header |
| `Q_Response::code(201)` | Set HTTP status code |
| `Q_Response::setCookie($name, $val, ...)` | Set a cookie (prevents duplicates) |
| `Q_Response::redirect($url)` | 302 redirect (or 301 with `permanently`) |
```php
$_SESSION['user_id'],
'theme' => $_POST['theme'],
]);
// Render a view
echo Q::view('MyApp/settings/page.php', [
'result' => $result,
'theme' => $theme,
]);
```
### Why `Q::header()` instead of `header()`?
The server runs PHP in CLI SAPI (same as FrankenPHP worker mode and Workerman).
PHP's built-in `header()` is silently discarded in CLI mode. `Q::header()` has
the exact same signature but captures headers so the server can send them:
```php
Q::header('Content-Type: application/json'); // same as header() but works
Q::header('HTTP/1.1 201 Created', true, 201); // status code
Q_Response::setHeader('X-Custom', 'value'); // named method
Q_Response::code(201); // status code
Q_Response::setCookie('session', $id); // cookies
Q_Response::redirect('/login'); // redirect
```
For existing code that calls `header()` directly, use CGI carveout mode β
configure URL patterns in `server.json` under `Q.webserver.cgi.patterns` to run
those scripts via `php-cgi` where native `header()` works (see Configuration).
When you upgrade to the full [Qbix Platform](https://github.com/Qbix/Platform),
the `Q` class expands with hundreds more methods β but everything above
continues to work identically. Your scripts don't need to change.
### Classes β autoloaded and optionally preloaded
Drop a PHP file in `classes/` and it's **autoloaded** β found automatically the first
time your code references it. No `require` needed. Both naming conventions work:
```php
$id, 'title' => $title, 'saved' => true];
return $result;
}
```
Fire it from anywhere:
```php
$_POST['title'],
'userId' => $_SESSION['user_id'],
]);
Q::header('Content-Type: application/json');
echo json_encode($result);
```
The handler file is `include`'d the first time the event fires, then the function
stays in memory. If the event never fires, the file is never loaded. This is ideal
for things like webhooks, admin actions, and error handlers β code that runs rarely
but needs to be available.
**Check if a handler exists:**
```php
if (Q::canHandle('MyApp/feed/post')) {
Q::event('MyApp/feed/post', $params);
}
```
### Before/after hooks
You can attach hooks to any event via config β useful for validation, logging,
access control, or cross-cutting concerns:
```json
{
"Q": {
"handlersBeforeEvent": {
"MyApp/feed/post": ["MyApp/feed/validate"]
},
"handlersAfterEvent": {
"MyApp/feed/post": ["MyApp/feed/notify"]
}
}
}
```
```php
'Title required'];
return false; // stops the event chain β main handler won't fire
}
}
```
```php
= htmlspecialchars($title) ?>
= htmlspecialchars($body) ?>
```
```php
$html]);
```
Views are just PHP files β full language access, no template DSL to learn.
### The philosophy
| | Loaded when | Lives in | Purpose |
|---|---|---|---|
| **Classes** | Startup (preloaded) | `classes/` | Models, services, utilities β your core code |
| **Handlers** | First event fire (on demand) | `handlers/` | Actions, hooks, webhooks β code that responds to events |
| **Views** | When rendered | `views/` | Templates β HTML with PHP |
| **Scripts** | When requested via HTTP | `web/` | Entry points β the "controller" layer |
| **Config** | Startup | `config/` | Settings, handler hooks, preload lists |
Classes are **eager**. Handlers are **lazy**. Scripts are **per-request**.
Views are **on-demand**. This gives you the right loading strategy for each
kind of code without thinking about it β just put files in the right directory.
### Workers: fork-per-request (truly shared-nothing)
Each worker handles exactly **one request**, then exits. The parent immediately
forks a replacement. This means:
- Static variables β **wiped** (process dies)
- Global state β **wiped** (process dies)
- Memory leaks β **impossible** (OS reclaims everything)
- Secrets in memory β **gone** (no persistence between requests)
This is safer than php-fpm, which reuses workers across requests and relies on
`pm.max_requests` to periodically recycle them. With Qbix Server, every request
gets a clean process. The fork cost (~0.5ms) is negligible compared to the
bootstrap savings (~10β50ms).
### How PHP requests are handled
On Linux and macOS (where `pcntl_fork` is available), **every PHP request
is forked** β even without `--workers`. The server forks a child, the child
handles the request and exits, the parent continues serving. This means:
- `exit()` / `die()` in a script only kills the child β the server survives
- Long-running scripts don't block static file serving
- Each request is truly isolated
The `--workers=N` flag pre-forks N idle workers for faster dispatch (no fork
latency per request). Without it, the server forks on demand. Both modes are
shared-nothing.
**Windows** doesn't have `pcntl_fork`, so PHP scripts run in a subprocess
via `proc_open`. This is safe β `exit()` can't crash the server β but each
subprocess starts a fresh PHP interpreter (~50ms), so you don't get the
preload speed benefit. Static files, WebSocket, caching, and everything
else work identically. Good for development; use Linux/macOS for the full
10x performance advantage.
### Growing into the full Qbix Platform
The conventions above β `classes/`, `handlers/`, `views/`, `config/` β are
the same ones the [Qbix Platform](https://github.com/Qbix/Platform) uses.
When your project outgrows the micro-framework and you need user accounts,
real-time streams, access control, payments, or a plugin system, you switch
to `--app` mode and everything you've written keeps working. Your classes
stay in `classes/`, your handlers stay in `handlers/`, your views stay in
`views/`. You just gain access to Streams, Users, Assets, and the rest of
the plugin ecosystem β without rewriting anything.
---
## βοΈ Configuration
Create `config/server.json` next to your `web/` directory, or pass `--config=path/to/config.json`:
```json
{
"Q": {
"webserver": {
"keepAlive": {
"max": 100,
"timeout": 15
},
"maxConnections": 1024,
"fileCache": {
"maxSize": 67108864,
"maxFile": 1048576,
"checkInterval": 1
},
"rateLimit": {
"enabled": true,
"requests": 100,
"window": 60
}
}
}
}
```
| Key | Default | What it does |
|---|---|---|
| `keepAlive.max` | 100 | Max requests per keep-alive connection |
| `keepAlive.timeout` | 15 | Seconds before closing idle connection |
| `maxConnections` | 1024 | Max simultaneous connections |
| `fileCache.maxSize` | 64MB | Total memory for cached file responses |
| `fileCache.maxFile` | 1MB | Largest file to cache in memory |
| `fileCache.checkInterval` | 1 | Seconds between file modification checks |
| `rateLimit.enabled` | false | Enable per-IP rate limiting |
| `rateLimit.requests` | 100 | Requests per window |
| `rateLimit.window` | 60 | Window in seconds |
| `webserver.fallback` | null | Catch-all: `"index.html"`, `{"handler":"app/notfound"}`, or `{"file":"404.html"}` |
| `webserver.cgi.patterns` | [] | Regex patterns for scripts that use php-cgi (legacy compatibility) |
| `webserver.cgi.binary` | auto | Path to php-cgi binary (auto-detected if not set) |
### CGI carveout mode β legacy PHP compatibility
Scripts matching `Q.webserver.cgi.patterns` run via `php-cgi` subprocess instead
of fork. Native `header()`, `setcookie()`, `session_start()` all work β full
compatibility with WordPress, Laravel, or any PHP code that calls `header()` directly.
```json
{
"Q": {
"webserver": {
"cgi": {
"patterns": [
"#^/wp-admin/.*\\.php$#",
"#^/wp-login\\.php$#",
"#^/legacy/.*\\.php$#"
]
}
}
}
}
```
The tradeoff: CGI mode starts a fresh PHP interpreter per request (~50ms), so you
don't get the preload speed benefit. Static files, caching, and everything else
still work at full speed. Use this for third-party code you can't modify β your
own code should use `Q::header()` and the fork path for 10x performance.
The server auto-detects `php-cgi` on your system. Override with `cgi.binary`:
```json
{ "Q": { "webserver": { "cgi": { "binary": "/usr/bin/php-cgi8.3" } } } }
```
### Running legacy PHP β WordPress, Laravel, Symfony
You can run existing PHP applications on Qbix Server without modifying their code.
The key: put the framework's public directory as `web/`, and use CGI carveout
patterns to match all PHP files.
**WordPress:**
```
wordpress-site/
βββ qbixserver.php β copy here
βββ src/ β copy here
βββ config/
β βββ server.json
βββ web/ β symlink or copy of WordPress root
βββ wp-admin/
βββ wp-content/
βββ wp-includes/
βββ wp-login.php
βββ index.php
βββ wp-config.php
```
```json
{
"Q": {
"webserver": {
"cgi": {
"patterns": ["#\\.php$#"]
},
"fallback": "index.php"
}
}
}
```
The pattern `#\\.php$#` sends all PHP files through `php-cgi`. The fallback
sends unmatched URLs to `index.php` (WordPress permalink routing). Static
files (images, CSS, JS) are served directly at full speed.
**Laravel:**
```
laravel-app/
βββ qbixserver.php
βββ src/
βββ config/
β βββ server.json
βββ web/ β symlink to Laravel's public/
β βββ index.php
β βββ .htaccess β ignored (no Apache)
βββ app/
βββ routes/
βββ storage/
βββ vendor/
```
```json
{
"Q": {
"webserver": {
"cgi": {
"patterns": ["#\\.php$#"]
},
"fallback": "index.php"
}
}
}
```
All requests that don't match a static file go to `index.php`. Laravel's
router takes over from there. The `app/`, `vendor/`, and `storage/`
directories are outside `web/` β inaccessible via URL by default.
**Symfony:**
```
symfony-app/
βββ qbixserver.php
βββ src/
βββ config/
β βββ server.json
β βββ ... β Symfony config files
βββ web/ β symlink to Symfony's public/
β βββ index.php
βββ src/ β Symfony source (separate from Qbix src/)
βββ var/
βββ vendor/
```
Same config pattern. Symfony's front controller (`public/index.php`) handles
all routing internally.
**Porting your own legacy code:**
For code you control, you have three options β from least effort to best performance:
**Option 1: Full CGI (zero changes, slower)**
```json
{ "Q": { "webserver": { "cgi": { "patterns": ["#\\.php$#"] } } } }
```
Every PHP file runs through `php-cgi`. Native `header()`, `setcookie()`,
`session_start()` all work. No code changes. Performance is comparable
to nginx + php-fpm (no preload benefit).
**Option 2: Targeted carveouts (minimal changes, mostly fast)**
```json
{
"Q": {
"webserver": {
"cgi": {
"patterns": [
"#^/admin/.*\\.php$#",
"#^/legacy/.*\\.php$#"
]
}
}
}
}
```
Only specific paths use CGI. New code and simple scripts use fork mode
(10x performance). Legacy code that calls `header()` directly stays
in CGI mode.
**Option 3: Find-replace (one-time effort, full performance)**
In your PHP files, replace:
```
header( β Q::header(
setcookie( β Q_Response::setCookie(
```
Two find-replaces. Your code now uses fork mode everywhere β 10x concurrent
performance, preloaded classes, shared-nothing safety.
### Installing php-cgi
CGI carveout mode requires the `php-cgi` binary:
```bash
# Ubuntu/Debian
sudo apt install php-cgi
# macOS
brew install php # includes php-cgi
# CentOS/RHEL
sudo yum install php-cgi
# Verify
php-cgi --version
```
---
## π¦ Three Ways to Run
### 1. From source (needs PHP 8.1+)
```bash
php qbixserver.php --root=./web --port=8080
```
### 2. PHAR β single ~250KB file (needs PHP)
```bash
php bin/qbixserver.phar --root=./web --port=8080
# Or make it executable
chmod +x bin/qbixserver.phar
./bin/qbixserver.phar --port=8080
```
### 3. Static binary β no PHP needed
```bash
# Download from GitHub Releases
chmod +x qbixserver-linux-x86_64
./qbixserver-linux-x86_64 --root=./web --port=8080
```
The binary bundles PHP 8.3 + extensions into a single ~15MB executable.
Copy it to any Linux or macOS machine and run. No dependencies.
---
## π¨ Building
### Build the PHAR
```bash
php -d phar.readonly=0 build-phar.php
# Output: bin/qbixserver.phar
```
### Build the static binary
```bash
# With Docker (easiest):
./build-binary.sh --docker
# With static-php-cli installed locally:
./build-binary.sh
# Output: bin/qbixserver (~15MB)
```
The binary is built using [static-php-cli](https://github.com/crazywhalecc/static-php-cli),
which compiles PHP + extensions into a statically linked binary.
GitHub Actions automatically builds binaries for **Linux x86_64**, **Linux ARM64**,
**macOS x86_64**, and **macOS Apple Silicon** on every tagged release.
---
## π With Qbix Platform
Qbix Server is extracted from the [Qbix Platform](https://github.com/Qbix/Platform) β a full-stack
framework for building social apps with real-time streams, user management, and plugin architecture.
When you have a Qbix app, the server uses the full framework:
```bash
php qbixserver.php --app=/path/to/myapp --port=8080
```
In this mode:
- Requests route through `Q_Dispatcher` β the full Qbix event pipeline
- Plugins load automatically (Users, Streams, Assets, etc.)
- Clean URLs work (`/community/123` β module routing)
- Static files still use the fast path (no framework overhead)
- The dashboard shows Qbix-specific stats
The standalone mode (without `--app`) runs as a plain web server β no framework, no plugins.
PHP files execute directly, static files serve from memory. Use this for simple sites,
APIs, or any project that doesn't need the full Qbix stack.
### Qbix Platform scripts
The full Platform includes additional server scripts like `static.php` for
CDN-style static file serving with versioned URLs. See the
[Platform repository](https://github.com/Qbix/Platform) for details.
---
## ποΈ Architecture
```
ββββββββββββββββββββ
HTTP request βββββ β Event Loop β stream_select (zero deps)
β (single thread) β or amphp/revolt (optional)
ββββββββββ¬ββββββββββ
β
βββββββββββββββββΌββββββββββββββββ
β β β
ββββββΌββββββ ββββββΌββββββ ββββββΌββββββ
β Static β β PHP β β WebSocket β
β Files β β Dispatch β β Upgrade β
β β β β β β
β In-memoryβ β In-proc β β RFC 6455 β
β response β β or fork β β frames β
β cache β β pool β β β
ββββββββββββ ββββββββββββ ββββββββββββ
```
**Static files** are served from an in-memory response cache. The full HTTP response
(headers + body) is pre-built and sent in a single `fwrite()` call. The cache is
mtime-validated with configurable check intervals. Combined with `TCP_NODELAY`,
this delivers sub-millisecond response times.
**PHP scripts** run in-process (single-threaded, suitable for lightweight APIs)
or in a pre-fork worker pool (`--workers=N`) for concurrent PHP execution.
Workers are forked after class preloading, so they share the base memory footprint
via copy-on-write pages.
**The remaining gap** versus nginx (55β73%) is inherent: nginx uses
`sendfile()` (kernel-space fileβsocket copy), `epoll` (O(1) event notification),
and compiled C. PHP's `stream_select` is `select(2)`, file serving goes through
userspace, and every operation has interpreter overhead. Getting to 55β73% of C
performance from pure interpreted PHP is about as good as it gets.
---
## π HTTP/2 Support
The built-in event loop uses `stream_select` β zero dependencies, works everywhere.
But if you install [amphp](https://amphp.org/), the server upgrades to a full
HTTP/2 server with no code changes:
```bash
composer require amphp/http-server amphp/socket
php qbixserver.php --port=8443
```
The server detects amphp automatically and switches to its event loop and HTTP
driver. You get:
| | HTTP/1.1 (built-in) | HTTP/2 (amphp) |
|---|---|---|
| Connections per page load | ~6 parallel | 1 multiplexed |
| Header overhead | Full headers per request | HPACK compressed |
| Event loop | `stream_select` (portable) | `epoll`/`kqueue` via Revolt |
| TLS | `stream_socket_enable_crypto` | amphp native TLS |
| Server push | No | Yes (push static assets before browser asks) |
### How it works
The server has a clean two-layer architecture. `Q_WebServer::route()` handles
all request logic (static files, PHP dispatch, cache, access control) and returns
a `[status, headers, body]` array. The transport layer is pluggable:
```
Built-in: stream_select β accept β fread β route() β fwrite
amphp: Revolt loop β amphp HTTP server β route() β amphp response
```
All the server's features β response cache, X-Accel-Redirect, component cache
invalidation, keep-alive, compression β work identically on both transports.
The `Q_Evented` facade abstracts the event loop, so timers, signals, and socket
watchers work the same way whether you're on `stream_select` or Revolt.
### When to use which
**Built-in (default):** Zero dependencies. Works on any PHP 8.1+ installation.
Good for development, small-to-medium sites, and environments where you can't
install Composer packages.
**amphp:** Better performance under high concurrency thanks to `epoll`/`kqueue`.
HTTP/2 multiplexing reduces connection overhead for asset-heavy pages.
Required if you need server push or HTTP/2-only clients.
**Either way:** You can always put Cloudflare, CloudFront, or nginx in front
as a reverse proxy. The CDN terminates HTTP/2 (and HTTP/3) for you, forwarding
HTTP/1.1 to the backend. In that configuration, the built-in transport is all
you need β the CDN handles the protocol upgrade.
---
## π Requirements
**Linux / macOS (recommended):**
- PHP 8.1 or later
- Extensions: `sockets`, `pcntl` (for signals + workers), `openssl` (for HTTPS)
```bash
# Check
php -m | grep -E 'sockets|pcntl|openssl'
# Install on Ubuntu/Debian
sudo apt install php-cli php-sockets
```
**For the static binary:**
- Nothing. The PHP runtime is included.
**Windows:** The server works without `pcntl`. Static files, PHP scripts,
WebSocket, caching, compression, access control β everything works. PHP
scripts run in isolated subprocesses via `proc_open`, so `exit()` and
crashes won't bring down the server. You lose the preload speed benefit
(each subprocess starts fresh) and signal-based graceful shutdown. For
the full 10x performance advantage, use Linux or macOS (or WSL).
---
## πΊοΈ Roadmap
**Coming next:**
- **Virtual hosts** β `Q.web.hosts.$hostname` config overrides for multi-domain serving
- **Hot reload** β watch `classes/`, `handlers/`, `config/` for changes, auto-restart workers
- **Scheduler** β cron-like timed events from config, executed by the event loop
- **Request timeout** β kill workers that exceed N seconds
---
## π License
MIT β see [LICENSE](LICENSE).
Part of the [Qbix Platform](https://github.com/Qbix/Platform).