Real-time

WebSocket, rooms, and SSE — all built in, same port as HTTP. No Node.js, no Redis.

Traditional PHP apps need a separate Node.js server for WebSocket, Redis for pub/sub between fpm and Node, and supervisor to keep it all running. Qbix Server handles HTTP, WebSocket (with Socket.IO protocol), and SSE from the same process on the same port.

WebSocket — Process Per Connection

Each WebSocket connection gets its own PHP process. It stays alive for the entire connection. Static variables persist across messages. When the client disconnects, the process dies — all state wiped.

Browser connects to ws://localhost/ws
  → Parent forks a child process for this connection
  → Every message the client sends goes to this child
  → Child runs Q::event('chat/message', ...) for each message
  → Static variables persist between messages (same process!)
  → Client disconnects → child process exits
  → OS reclaims all memory
A simple per-connection counter
// handlers/counter/increment.php
function counter_increment(&$params, &$result) {
    static $count = 0;  // persists across messages from THIS client
    $count++;
    $result = ['count' => $count];
}
// Client
const socket = io('http://localhost', {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
});
Per-connection authentication

The per-connection process is the natural place for auth. Validate once on connect, store in a static, use for every subsequent message:

// handlers/auth/login.php
function auth_login(&$params, &$result) {
    static $user = null;

    if ($user) {
        $result = ['error' => 'already authenticated'];
        return;
    }

    $user = MyApp\Auth::validate($params['data']['token']);
    if (!$user) {
        $result = ['error' => 'invalid token'];
        return;
    }
    $result = ['userId' => $user['id'], 'name' => $user['name']];
}

Rooms — Process Per Room

For use cases where multiple connections need shared in-memory state — chat messages, game positions, cursor tracking, live vote tallies — use room processes. One process per active room. All members' messages go to the same process. When the last member leaves, the process dies.

Room lifecycle
1. Client A's handler calls $socket->join('chat/general', ['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
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
8. Client B disconnects → leave fires → room is empty
9. destroy fires → room process exits
Room configuration
{
    "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 — useful for games and animations.

Room handler files

Each event dispatches to its own handler file under the configured prefix:

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
  — message.php    "message" event from a member
  — typing.php     "typing" event from a member
Broadcasting from a room handler
// handlers/chat/room/message.php
function chat_room_message($params) {
    $room = $params['room'];
    $data = $params['data'] ?? [];
    $socketId = $room->senderId();

    $room->state['messageCount']++;

    // Broadcast to everyone except the sender
    Q_Socket::broadcastExcept([
        'message', [
            'name' => $data['name'] ?? 'anon',
            'text' => $data['text'] ?? '',
            'time' => date('H:i'),
        ]
    ], $socketId);
}

Room state lives in $room->state — an array shared across all handler calls within the room process. No database, no Redis. When the room process dies, everything is reclaimed.

Socket.IO Protocol

The server implements the full Socket.IO v5 wire protocol. The client JS is bundled — no npm needed:

<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io({transports: ['websocket']});
socket.emit('chat/join', {room: 'general', name: 'Alice'});
socket.on('chat/message', (d) => console.log(d.name + ': ' + d.text));
socket.emit('chat/message', {text: 'Hello!'});
</script>

Server-Sent Events (SSE)

Set Content-Type: text/event-stream and the server auto-detects a streaming response. Each ob_flush(); flush(); sends a chunk immediately:

// web/stream.php
Q_Response::header('Content-Type: text/event-stream');
Q_Response::header('Cache-Control: no-cache');

$words = explode(' ', 'Streaming word by word...');
foreach ($words as $word) {
    echo "data: $word\n\n";
    ob_flush(); flush();
    usleep(80000); // 80ms between tokens
}

Works for AI token streaming, log tails, live sensor data, or any push-to-browser pattern.

See the chat and stream examples →