# ⚑ Qbix Server A pure PHP web server. No nginx, no Apache, no php-fpm. One process serves static files, PHP scripts, WebSocket connections, and a live dashboard. ### πŸ”Ÿβœ–οΈ 10x more concurrent PHP on the same hardware The biggest bottleneck in PHP hosting is **memory**. Each php-fpm worker loads your entire framework independently β€” 30–60MB per worker. On an 8GB server, that's ~160 workers max. That's your ceiling for concurrent PHP requests. Qbix Server forks workers **after** loading your classes. Thanks to copy-on-write, all that shared code (framework, config, autoloader) uses memory only once. Each worker adds only ~5MB for its per-request data: ``` php-fpm: 8GB Γ· 50MB per worker = 160 concurrent PHP requests Qbix Server: 8GB Γ· 5MB per worker = 1,600 concurrent PHP requests ``` Same hardware. Same PHP code. **10x more users served.** ### Why it's faster than nginx + php-fpm for real apps | | nginx + php-fpm | Qbix Server | |---|---|---| | πŸš€ **PHP request speed** | 10–50ms bootstrap on *every* request | **0ms** β€” workers fork after classes are loaded | | πŸ’Ύ **Memory per worker** | 30–60MB each (duplicated) | ~5MB each (shared base via copy-on-write) | | πŸ‘₯ **Concurrent PHP** (8GB) | ~160 workers | **~1,600 workers** | | πŸ”’ **Access-controlled files** | Public URLs or hacky rewrites | `X-Accel-Redirect` β€” PHP checks access, server streams the file | | 🧩 **Cache invalidation** | Whole-page only (purge everything) | `X-Cache-Tree` β€” invalidate one component, keep the rest cached | | 🌐 **WebSocket** | Needs a separate server | Built in β€” 40K+ concurrent connections per server | | βš™οΈ **Setup** | Install nginx, configure proxy_pass, php-fpm pool, sockets... | `php qbixserver.php --port=8080` | With keep-alive (what browsers actually use), static file throughput **exceeds nginx** at 120-135%. Without keep-alive, nginx is faster on raw I/O β€” but keep-alive is the default for all modern browsers. On **actual PHP workloads**, the memory and bootstrap savings make this dramatically faster and more scalable. > πŸ’‘ You can always put nginx, a reverse proxy, or a CDN (Cloudflare, CloudFront) > in front of this for faster HTTPS and edge caching. Qbix Server handles the > PHP execution, access control, and intelligent caching behind it. ### 🎯 Drop files in folders. Get a real-time server. Three execution models β€” HTTP, WebSocket, and rooms β€” all shared-nothing, all just PHP files in folders: ``` handlers/ β”œβ”€β”€ api/users/ β”‚ β”œβ”€β”€ get.php ← HTTP: GET /api/users (fork, serve, die) β”‚ └── post.php ← HTTP: POST /api/users β”œβ”€β”€ chat/ β”‚ β”œβ”€β”€ message.php ← WebSocket: one process per connection β”‚ └── join.php ← static vars persist across messages └── game/ └── room.php ← Room: one process per room, shared state with configurable tick timer classes/ └── MyApp/ ← Preloaded and shared across all three models β”œβ”€β”€ Auth.php └── Chat.php ``` | Model | Process lifetime | State | Cleanup | |---|---|---|---| | **HTTP** | Fork β†’ handle one request β†’ die | None (shared-nothing) | Automatic β€” process exits | | **WebSocket** | Fork β†’ handle all messages from one user β†’ die on disconnect | `static` vars persist across messages | Automatic β€” process exits | | **Room** | Fork β†’ handle messages from all users in room β†’ die when empty | `static` vars shared across all members | Automatic β€” process exits | No cleanup code. No memory leaks. No state leaking between users. Every model uses `handlers/`, `classes/`, and `Q::event()`. Try it β€” one command, zero config: ```bash php qbixserver.php --root=./web --port=8080 ``` --- ## πŸ“‘ Table of Contents - [Quick Start](#-quick-start) - [Performance](#-performance) - [Why Not php-fpm?](#-why-not-php-fpm) - [vs FrankenPHP and Swoole](#️-vs-frankenphp-and-swoole) - [Features](#-features) - [Server Headers](#-server-headers--what-your-php-can-send) - [HTTP β€” Fork Per Request](#-http--fork-per-request) - [WebSocket β€” Process Per Connection](#-websocket--process-per-connection) - [Rooms β€” Process Per Room](#-rooms--process-per-room) - [Complete Example: Chat App With Rooms](#-complete-example-chat-app-with-rooms) - [Clean URL Routing](#️-clean-url-routing-optional) - [For PHP Developers](#-for-php-developers--the-micro-framework) - [Configuration](#-configuration) - [Running Legacy PHP](#running-legacy-php--wordpress-laravel-symfony) - [Three Ways to Run](#-three-ways-to-run) - [Building](#-building) - [With Qbix Platform](#-with-qbix-platform) - [Architecture](#-architecture) - [Live Dashboard](#-live-dashboard) - [HTTP/2 Support](#-http2-support) - [Requirements](#-requirements) - [Roadmap](#️-roadmap) - [The mental model](#-the-mental-model) - [License](#-license) --- ## πŸš€ Quick Start ```bash # Clone git clone https://github.com/Qbix/Server.git cd Server # Create a web directory mkdir web echo '

Hello World

' > 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, ~280KB) 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 | 36,300 req/s | **135%** | | Keep-alive (c=50) | 30,158 req/s | 36,300 req/s | **120%** | Zero failed requests across 50,000+ requests at concurrency 50. Server never crashed. > For context: 36K req/s means the server handles **1,800 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** | Socket.IO v5 compatible + bare WebSocket. Serverβ†’client RPC. Client JS served at `/Q/socket.js` and `/socket.io/socket.io.js`. | | **Dashboard** | Live dashboard at `/Q/dashboard` β€” real-time request log, throughput sparkline, top paths, response times, memory, WebSocket connections, active rooms, status breakdown. Updates live via WebSocket. | | **Health check** | JSON at `/Q/health` β€” all stats for load balancers and monitoring. Also available at `/Q/stats` with full detail. | | **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. --- ## 🌐 HTTP β€” Fork Per Request Every PHP request forks from the preloaded parent, handles the request, and dies. No cleanup needed β€” the OS reclaims everything. ### Static files Drop files in `web/`. They're served directly: ``` web/ β”œβ”€β”€ index.html ← GET /index.html β”œβ”€β”€ style.css ← GET /style.css └── app.js ← GET /app.js ``` ### PHP scripts PHP files in `web/` execute as scripts β€” same as Apache or nginx + php-fpm: ```php $count]; } ``` ```javascript // Client β€” standard socket.io-client import { io } from 'socket.io-client'; const socket = io('http://localhost:8080', {transports: ['websocket']}); socket.emit('counter/increment', {}, (res) => { console.log(res.count); // 1 }); socket.emit('counter/increment', {}, (res) => { console.log(res.count); // 2 β€” same process, same static var }); ``` ### Authentication The per-connection process is the natural place for auth. Validate once, store in a static variable, use for every subsequent message: ```php 'already authenticated']; return; } $user = MyApp\Auth::validate($params['data']['token']); if (!$user) { $result = ['error' => 'invalid token']; return; } $result = ['userId' => $user['id'], 'name' => $user['name']]; } ``` ### Joining rooms A per-connection handler decides when to join a room. This is your access control β€” the client can't join a room directly, only ask: ```php 'forbidden']; return; } // Pass user info to the room β€” the room's join handler gets this in $params['data'] $socket->join("chat/$room", [ 'userId' => $user['id'], 'name' => $user['name'], ]); $result = ['joined' => $room]; } ``` The third argument to `$socket->join()` is forwarded to the room's `join` handler as `$params['data']`. This is how the per-connection handler (which did auth) passes identity to the room process (which doesn't know who anyone is). Leaving works the same way β€” call `$socket->leave()` from a handler, or it happens automatically on disconnect: ### Config Map WebSocket event names to handler files: ```json { "Q": { "webserver": { "sockets": { "events": { "_connect": "auth/login", "_disconnect": "chat/leave", "chat/join": "chat/join", "chat/message":"chat/message", "chat/typing": "chat/typing" } } } } } ``` If no mapping is configured, the event name is used directly as the handler path. `_connect` and `_disconnect` are lifecycle events fired automatically. ### The client ```javascript import { io } from 'socket.io-client'; const socket = io('http://localhost:8080', {transports: ['websocket']}); socket.on('connect', () => { socket.emit('auth/login', {token: myToken}, (res) => { if (res.userId) { socket.emit('chat/join', {room: 'general'}); } }); }); socket.on('chat/message', (data) => { console.log(data.user + ': ' + data.text); }); socket.emit('chat/message', {text: 'hello'}, (res) => { console.log('Saved as message #' + res.id); }); ``` ### Context objects Every handler receives context objects in `$params`. Use `extract($params)` to get clean variables: ```php function chat_message(&$params, &$result) { extract($params); // $socket, $event, $data $socket->reply(['received' => true]); } ``` **Per-connection handlers** get `$socket` β€” a `Q_Socket` instance: | Method / Property | What it does | |---|---| | `$socket->id` | This socket's numeric ID | | `$socket->reply($data)` | Send to this client (fire and forget) | | `$socket->send($socketId, $data)` | Send to a specific client | | `$socket->broadcast($room, $data)` | Send to all clients in a room | | `$socket->broadcastAll($data)` | Send to ALL connected clients | | `$socket->join($room, $data)` | Join a room, forwarding `$data` to the room's join handler | | `$socket->leave($room, $data)` | Leave a room, forwarding `$data` to the room's leave handler | | `$socket->disconnect()` | Close this connection | | `$socket->anyMethod($data)` | **RPC** β€” calls a method on the client, blocks until response (5s timeout) | **Room handlers** get `$room` β€” a `Q_Room` instance: | Method / Property | What it does | |---|---| | `$room->name` | Room name (e.g. `'chat/general'`) | | `$room->socketId` | Current sender's socket ID | | `$room->params` | Pattern params (e.g. `['room' => 'general']`) | | `$room->broadcast($data)` | Send to all members (fire and forget) | | `$room->reply($data)` | Send to the member who sent the current message | | `$room->send($socketId, $data)` | Send to a specific member | All send methods (`reply`, `broadcast`, `send`, `broadcastAll`) are **fire and forget** β€” they queue the message and return immediately. Only `__call` (RPC) blocks. ### Protocol Two wire formats, auto-detected by path: **Socket.IO** (connect to `/socket.io/`) β€” full Socket.IO v5 wire protocol. The server bundles the client JS β€” no npm needed: ```html ``` Or use the npm package: ```javascript import { io } from 'socket.io-client'; const socket = io('http://localhost:8080', {transports: ['websocket']}); ``` Acks work both directions. Serverβ†’client RPC uses native ack callbacks: ```javascript socket.emit('game/score', {id: 42}, (response) => console.log(response.rank)); socket.on('getLocation', (data, callback) => callback({lat: 40.7, lng: -74.0})); ``` Supported: events, acks (both directions), namespaces, ping/pong. Not supported: HTTP long-polling, binary attachments. **Bare WebSocket** (connect to any other path) β€” plain JSON, no framing. Works with any language's WebSocket library. The server serves a minimal client at `/Q/socket.js` (~100 lines, no dependencies). Drop it in a ` ``` Same API as `socket.io-client` β€” `on()`, `emit()`, `handle()`. Auto-reconnect with backoff. Or use raw `WebSocket` directly: ```javascript const ws = new WebSocket('ws://localhost:8080/ws'); ws.send(JSON.stringify({event: 'chat/message', data: {text: 'hello'}})); ws.send(JSON.stringify({event: 'chat/message', data: {text: 'hi'}, ack: 1})); ``` ```python # Any language β€” just JSON over WebSocket import websocket, json ws = websocket.WebSocket() ws.connect("ws://localhost:8080/ws") ws.send(json.dumps({"event": "chat/message", "data": {"text": "hello"}})) ``` Handlers don't know which protocol the client is using β€” the server translates at the wire level. Same handlers, same rooms, same everything. ### Namespaces Socket.IO namespaces map to handler path prefixes. The default namespace `/` maps to the root `handlers/` directory: ``` Namespace Client emit Handler path Room "general" ───────── ──────────── ──────────── ────────────── / emit('message', ...) message general /chat emit('message', ...) chat/message chat/general /admin emit('auth', ...) admin/auth admin/general ``` ```javascript // Client connects to namespaces const main = io('http://localhost:8080'); // default / const chat = io('http://localhost:8080/chat'); // /chat const admin = io('http://localhost:8080/admin'); // /admin chat.emit('message', {text: 'hello'}); // β†’ handlers/chat/message.php admin.emit('auth', {token: '...'}); // β†’ handlers/admin/auth.php ``` Namespace connect/disconnect handlers are optional. If you define one, it runs as access control. If you don't, the namespace auto-accepts: ```php 'forbidden']; return false; // reject namespace connection } } ``` ### Serverβ†’Client RPC PHP handlers can call methods on the client using `$socket->methodName()`. The call blocks until the client responds (5s timeout): ```php getLocation(); $prefs = $socket->getPreferences(['keys' => ['theme', 'lang']]); $result = [ 'lat' => $location['lat'], 'theme' => $prefs['theme'], ]; } ``` Any method name that isn't `reply`, `send`, `broadcast`, `broadcastAll`, `join`, or `leave` goes through `__call` β†’ IPC β†’ WebSocket β†’ client β†’ response. **With `socket.io-client`** β€” serverβ†’client RPC uses native ack callbacks: ```javascript const socket = io('http://localhost:8080', {transports: ['websocket']}); socket.on('getLocation', (data, callback) => { callback({lat: 40.7, lng: -74.0}); }); socket.on('getPreferences', (data, callback) => { callback({theme: 'dark', lang: data.keys}); }); ``` **With `/Q/socket.js`** β€” use `handle()`: ```javascript var socket = new QSocket('/ws'); socket.handle('getLocation', function() { return {lat: 40.7, lng: -74.0}; }); // Async handlers work too socket.handle('getPosition', async function() { var pos = await new Promise(function(resolve) { navigator.geolocation.getCurrentPosition(resolve); }); return {lat: pos.coords.latitude, lng: pos.coords.longitude}; }); ``` **With bare WebSocket** β€” the client receives `{"event":"getLocation","data":{},"ack":7}` and responds with `{"ack":7,"data":{"lat":40.7}}`. ### App namespacing When building an app, prefix your handler functions with your app name to avoid collisions. Set the app name in config: ```json { "Q": { "app": "Chess" } } ``` ``` handlers/game/move.php β†’ function Chess_game_move(&$params, &$result) handlers/chat/message.php β†’ function Chess_chat_message(&$params, &$result) handlers/connect.php β†’ function Chess_connect(&$params, &$result) ``` Handler file paths stay the same β€” the app prefix is only on the function name. Read it at runtime with `Q::app()`. Same for classes β€” use PHP namespaces: ```php join('chat/general', ['userId'=>1, 'name'=>'Alice']) 2. Parent sees 'chat/general' matches pattern 'chat/$room' 3. Parent forks a room process β†’ init handler fires 4. Parent sends _join to room β†’ join handler fires (with socketId + data) 5. Client B joins the same room β†’ join fires again (no new fork) 6. Both clients' messages are forwarded to the room process 7. Client A disconnects β†’ leave fires (data is empty β€” unplanned disconnect) 8. Client B disconnects β†’ leave fires β†’ room is empty 9. destroy fires β†’ room process exits ``` The client never talks to the room process directly. Per-connection handlers call `$socket->join()` β€” that's the gateway. Access control lives there. User identity flows through the third argument. ### Config ```json { "Q": { "webserver": { "sockets": { "rooms": { "chat/$room": {"handler": "chat/room"}, "game/$id": {"handler": "game/room", "tick": 100}, "collab/$doc": {"handler": "collab/room", "tick": 50} } } } } } ``` The pattern uses `$name` placeholders β€” `chat/$room` matches `chat/general`, `chat/dev`, etc. The `tick` option (in ms) fires `tick` events on a timer, even when no messages arrive. The `handler` value is a path prefix. Each event dispatches to its own handler file under that prefix β€” just like HTTP handlers: ``` "chat/$room": {"handler": "chat/room"} handlers/chat/room/ β”œβ”€β”€ init.php ← room created (first user joins) β”œβ”€β”€ join.php ← user enters β”œβ”€β”€ leave.php ← user exits or disconnects β”œβ”€β”€ tick.php ← timer fired (if configured) β”œβ”€β”€ destroy.php ← room shutting down (last user left) β”œβ”€β”€ message.php ← "message" event from a member └── typing.php ← "typing" event from a member ``` Same pattern as HTTP: one file per event, function name matches the path. ### Room events | Event | Handler file | `$params` has | |---|---|---| | `_init` | `handler/init.php` | `room`, `event`, `data` | | `_join` | `handler/join.php` | `room`, `event`, `data` (from `$socket->join()`) | | `_leave` | `handler/leave.php` | `room`, `event`, `data` (from `$socket->leave()`, or empty on disconnect) | | `_tick` | `handler/tick.php` | `room`, `event`, `data` | | `_destroy` | `handler/destroy.php` | `room`, `event`, `data` | | *user event* | `handler/eventname.php` | `room`, `event`, `data` | ### Example: chat room handlers ```php socketId; $userId = $params['data']['userId'] ?? null; $name = $params['data']['name'] ?? 'anon'; ChatRoom::$names[$sid] = $name; // Track multiple sockets per user (tabs, devices) $isNew = true; if ($userId) { if (!isset(ChatRoom::$users[$userId])) ChatRoom::$users[$userId] = []; $isNew = empty(ChatRoom::$users[$userId]); ChatRoom::$users[$userId][$sid] = true; } // Send history to the new socket $room->reply([ 'event' => 'chat/history', 'data' => ['messages' => ChatRoom::$history], ]); if ($isNew) { $room->broadcast([ 'event' => 'chat/joined', 'data' => ['name' => $name], ]); } } ``` ```php socketId] ?? 'anon'; $text = $params['data']['text'] ?? ''; if (!$text) return; $msg = ['name' => $name, 'text' => $text, 'time' => date('c')]; ChatRoom::$history[] = $msg; if (count(ChatRoom::$history) > 50) array_shift(ChatRoom::$history); $room->broadcast([ 'event' => 'chat/message', 'data' => $msg, ]); $result = ['sent' => true]; } ``` ```php socketId; $name = ChatRoom::$names[$sid] ?? 'anon'; unset(ChatRoom::$names[$sid]); $reallyGone = true; foreach (ChatRoom::$users as $uid => &$sockets) { if (isset($sockets[$sid])) { unset($sockets[$sid]); if (!empty($sockets)) $reallyGone = false; else unset(ChatRoom::$users[$uid]); break; } } if ($reallyGone) { $room->broadcast([ 'event' => 'chat/left', 'data' => ['name' => $name], ]); } } ``` ```php [socketId => true, ...] static $names = []; // socketId => name static $history = []; // recent messages } ``` Why class statics instead of `static` variables inside functions? Because each handler is now a separate file. A `static $users` in `join.php` wouldn't be visible in `leave.php`. Class statics (or globals) are shared across all handlers in the same room process. Copy-on-write handles the rest: the parent's `ChatRoom::$users` starts as `[]`. When a room process forks and writes to it, only that room's pages are copied. When the room dies, the OS reclaims everything. No `unset()`, no destructors, no cleanup. ### Example: game with tick timer ```php socketId] = [ 'x' => 0, 'y' => 0, 'hp' => 100, ]; $room->reply([ 'event' => 'game/state', 'data' => ['players' => GameRoom::$players], ]); } ``` ```php socketId]['x'] = $params['data']['x']; GameRoom::$players[$room->socketId]['y'] = $params['data']['y']; $result = ['ok' => true]; } ``` ```php broadcast([ 'event' => 'game/state', 'data' => ['players' => GameRoom::$players, 'tick' => GameRoom::$tick], ]); } ``` ```php socketId]); } ``` ```php 'invalid token']; return; } // Store for later use by chat/join (same process, shared globals) $GLOBALS['user'] = $user; $result = ['userId' => $user['id'], 'name' => $user['name']]; } ``` ```php 'not authenticated']; return; } $room = $params['data']['room'] ?? 'general'; // Pass user identity to the room process $socket->join("chat/$room", [ 'userId' => $user['id'], 'name' => $user['name'], ]); $result = ['joined' => $room]; } ``` ### Room handlers ```php socketId; $userId = $params['data']['userId'] ?? null; $name = $params['data']['name'] ?? 'anon'; ChatRoom::$names[$sid] = $name; $isNew = true; if ($userId) { if (!isset(ChatRoom::$users[$userId])) ChatRoom::$users[$userId] = []; $isNew = empty(ChatRoom::$users[$userId]); ChatRoom::$users[$userId][$sid] = true; } $room->reply([ 'event' => 'chat/history', 'data' => ['messages' => ChatRoom::$history], ]); if ($isNew) { $room->broadcast([ 'event' => 'chat/joined', 'data' => ['name' => $name], ]); } } ``` ```php socketId] ?? 'anon'; $text = $params['data']['text'] ?? ''; if (!$text) return; $msg = ['name' => $name, 'text' => $text, 'time' => date('c')]; ChatRoom::$history[] = $msg; if (count(ChatRoom::$history) > 50) array_shift(ChatRoom::$history); Chat\Messages::save($name, $text, $room->name); $room->broadcast([ 'event' => 'chat/message', 'data' => $msg, ]); $result = ['sent' => true]; } ``` ```php socketId; $name = ChatRoom::$names[$sid] ?? 'anon'; unset(ChatRoom::$names[$sid]); $reallyGone = true; foreach (ChatRoom::$users as $uid => &$sockets) { if (isset($sockets[$sid])) { unset($sockets[$sid]); if (!empty($sockets)) $reallyGone = false; else unset(ChatRoom::$users[$uid]); break; } } if ($reallyGone) { $room->broadcast([ 'event' => 'chat/left', 'data' => ['name' => $name], ]); } } ``` ### The client ```javascript import { io } from 'socket.io-client'; const socket = io('http://localhost:8080', {transports: ['websocket']}); socket.on('connect', () => { socket.emit('auth/login', {token: myToken}, (res) => { if (res.userId) socket.emit('chat/join', {room: 'general'}); }); }); socket.on('chat/history', (data) => { data.messages.forEach(renderMessage); }); socket.on('chat/message', (data) => { renderMessage(data); }); socket.on('chat/joined', (data) => { showNotice(data.name + ' joined'); }); socket.on('chat/left', (data) => { showNotice(data.name + ' left'); }); document.getElementById('send').onclick = () => { socket.emit('message', {text: input.value}); }; ``` ### The three models in action ``` HTTP: GET /api/messages β†’ fork β†’ query DB β†’ respond β†’ die Per-connection: auth/login β†’ validate token β†’ store in $GLOBALS chat/join β†’ check access β†’ $socket->join() with user data Room: chat/room/join β†’ ChatRoom::$users, $names, $history chat/room/message β†’ broadcast to all, persist to DB chat/room/leave β†’ multi-tab aware departure ``` ### Run it ```bash php qbixserver.php --root=./web --port=8080 ``` One command. Static files, REST API, authentication, access-controlled rooms, multi-tab awareness, and shared real-time chat β€” all from one PHP server. --- ## πŸ›€οΈ Clean URL Routing (Optional) Add `Q.routes` to your config and the server maps clean URLs to handlers β€” same event pipeline as the [Qbix Platform](https://github.com/Qbix/Platform). No `.php` suffixes, no rewrite rules. ### Config ```json { "Q": { "routes": { "": {"module": "app", "action": "welcome"}, "$module/$action": {} } } } ``` Route patterns use `$variable` for dynamic segments. Literal segments match exactly. The matched `module` and `action` determine which handlers fire. ### Handler directory structure ``` handlers/ └── api/ └── users/ β”œβ”€β”€ validate.php ← runs first (validate input) β”œβ”€β”€ get.php ← runs on GET requests β”œβ”€β”€ post.php ← runs on POST requests β”œβ”€β”€ put.php ← runs on PUT requests β”œβ”€β”€ delete.php ← runs on DELETE requests └── response.php ← runs last (transform output) ``` ### Dispatch pipeline For `GET /api/users`, the server fires three events in order: ``` 1. api/users/validate ← validate input, check auth 2. api/users/get ← handle the GET method 3. api/users/response ← post-process, add headers ``` This is the same pipeline as `Q_Dispatcher` in the full Qbix Platform. Your handlers work identically when you upgrade. ### Example handlers ```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

``` ```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.requestTimeout` | 30 | Seconds before killing a hung HTTP worker (0 = no limit) | | `socket.io` | `"/socket.io"` | Socket.IO endpoint. Protocol detection + client JS at `{path}/socket.io.js`. `false` to disable. | | `socket.js` | `"/Q/socket.js"` | Path to serve the minimal bare-WebSocket client (3KB). `false` to disable. | | `app` | `""` | App name β€” prefixes handler function names (e.g. `"Chess"` β†’ `Chess_chat_message()`) | | `webserver.fallback` | null | Catch-all: `"index.html"`, `{"handler":"app/notfound"}`, or `{"file":"404.html"}` | | `webserver.hotReload` | `false` | Watch `classes/`, `handlers/`, `config/` for changes. Auto-restarts on class/config changes. | | `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) | ### Virtual hosts Serve multiple domains from one server. Each host can have its own document root: ```json { "Q": { "webserver": { "hosts": { "example.com": { "root": "/var/www/example/web" }, "api.example.com": { "root": "/var/www/api/web" }, "staging.example.com": { "root": "/var/www/staging/web" } } } } } ``` The `Host` header selects the root. Requests for unconfigured hosts use the default `--root` directory. WebSocket, rooms, handlers, and static files all respect the per-host root. ### Hot reload Watch `classes/`, `handlers/`, and `config/` for file changes: ```bash php qbixserver.php --root=./web --port=8080 --hotreload ``` Or via config: ```json { "Q": { "webserver": { "hotReload": true } } } ``` Handler changes take effect immediately β€” handlers are lazy-loaded, so the next request or connection picks up the new code. Class or config changes trigger a graceful restart (the server re-execs itself with the same arguments). Changes are logged to stderr: ``` 14:32:07 hot-reload: ~ handlers/chat/message.php 14:32:09 hot-reload: + classes/MyApp/NewFeature.php 14:32:09 hot-reload: restarting server... ``` Polls every 2 seconds. Recommended for development. Even without `--hotreload`, handler changes take effect naturally: HTTP requests fork fresh and load handlers on demand, so the next request gets the new file. WebSocket connections and rooms keep the old code for their lifetime β€” new connections pick up the change. A natural rolling deploy with no interruption. The `--hotreload` flag adds automatic restart for class and config changes, which are preloaded in the parent process. If `Q.handlers.preload` is `true` (production mode), handlers are also loaded in the parent β€” use `--reload` to pick up handler changes in that case. ### Scheduler Run tasks on intervals or at specific times. Handlers are forked like HTTP requests β€” they don't block the event loop and respect `requestTimeout`. ```json { "Q": { "scheduler": { "cleanup": { "handler": "tasks/cleanup", "every": 3600 }, "daily-report": { "handler": "tasks/report", "times": ["09:00"] }, "business-check": { "handler": "tasks/check", "times": ["09:00", "12:00", "17:00"], "weekdays": ["mon", "wed", "fri"] }, "monthly-invoice": { "handler": "tasks/invoice", "times": ["00:00"], "monthdays": [1] } } } } ``` | Field | What it does | |---|---| | `handler` | Handler path β€” dispatched via `Q::event()`, same as HTTP handlers | | `every` | Run every N seconds from startup | | `times` | Run at specific `HH:MM` times (24h format) | | `weekdays` | Only fire on these days: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun` | | `monthdays` | Only fire on these days of the month: `[1]`, `[1, 15]`, etc. | The handler receives `$params['task']` (the task name) and `$params['scheduled'] = true`: ```php broadcast() ``` Ephemeral state lives in RAM β€” static variables in the per-connection process. It's fast (no I/O), isolated (per-user process boundary), and self-cleaning (process dies on disconnect, OS reclaims everything). When you need durability, call your preloaded classes to write to a database. When you need to notify others, call `$room->broadcast()`. The same `handlers/` directory serves HTTP requests, WebSocket messages, and routed clean URLs. The same `classes/` directory is preloaded and shared across all of them. One server, one codebase, one mental model. ``` Static files: GET /style.css β†’ web/style.css PHP scripts: GET /page.php β†’ web/page.php Routed: GET /api/users β†’ handlers/api/users/get.php Socket.IO: 42["chat/message",{...}] β†’ handlers/chat/message.php Bare WebSocket: {"event":"chat/message"} β†’ handlers/chat/message.php Legacy: GET /wp-admin/post.php β†’ php-cgi (full compatibility) ``` When you outgrow it β€” when you need the full dispatch pipeline, Streams for real-time data synchronization, or the component-level cache invalidation with Merkle trees β€” the same handlers run on the [Qbix Platform](https://github.com/Qbix/Platform) without changes. The upgrade path is adding capability, not rewriting architecture. --- ## πŸ“„ License MIT β€” see [LICENSE](LICENSE). Part of the [Qbix Platform](https://github.com/Qbix/Platform).