workerman/phpsocket.io
PHP Socket.IO server built on Workerman for real-time WebSocket apps. Supports event handling, rooms, namespaces, and Socket.IO client compatibility, offering a practical PHP alternative to Node.js for bidirectional communication.
Install with Composer and run it as a standalone CLI process — this is not something you mount inside Laravel's HTTP stack.
composer require workerman/phpsocket.io
Pick your track deliberately. A bare require resolves to the latest stable tag; the 3.0 line is currently beta and Composer will not select it for you. Check what you actually got, and opt in explicitly if you want the beta:
composer show workerman/phpsocket.io # confirm the resolved version
# only if you want the 3.0 beta line (see Gotchas before you do):
composer require workerman/phpsocket.io:"^3.0@beta"
Minimal server (socket-server.php at the project root, next to vendor/):
<?php
use Workerman\Worker;
use PHPSocketIO\SocketIO;
require_once __DIR__ . '/vendor/autoload.php';
$io = new SocketIO(2021);
$io->on('connection', function ($socket) use ($io) {
$socket->on('chat message', function ($msg) use ($io, $socket) {
$io->emit('chat message', $msg); // everyone
$socket->broadcast->emit('typing', false); // everyone but sender
});
});
Worker::runAll();
Run it:
php socket-server.php start # foreground, logs to console — use this while developing
php socket-server.php start -d # daemonized
php socket-server.php reload # graceful reload after code changes
First use case to validate end-to-end: a broadcast counter or chat, because it exercises connection, custom events, and emit in ~20 lines.
Where to look first:
examples/ in the repo — chat, rooms, namespaces, SSL, attach() to an existing Worker are all demonstrated there.$io->on('connection'), $socket->join, $socket->to(...)->emit). Nothing in the v3.0.0-beta.2 notes indicates a protocol bump, so this guidance stands — but re-confirm against the beta.1 changelog before betting a frontend on it.SocketIO.php / Socket.php for the actual method surface (origins(), of(), attach()).tests/ — as of beta.2, RFC6455.php (raw frame parsing: length tiers, masking, fragmentation, ping/pong/close) has real coverage. It's now the fastest way to understand how bytes actually reach your handlers.Rooms as the default fan-out primitive. Don't hand-roll connection arrays; rooms are the idiomatic grouping.
$socket->on('join', function ($room) use ($socket) {
$socket->join($room);
$socket->to($room)->emit('system', "{$socket->id} joined");
});
$socket->on('disconnect', function () use ($socket, $io) {
$io->to('presence')->emit('left', $socket->id);
});
Namespaces for separation of concerns — /chat, /notifications, /admin — so a mobile client doesn't receive admin traffic:
$chat = $io->of('/chat');
$chat->on('connection', function ($socket) { /* ... */ });
Auth via the handshake query string. socket.io 1.x has no auth payload, so pass the token as a query param and validate in the connection handler:
$io->on('connection', function ($socket) use ($io) {
$token = $socket->handshake['query']['token'] ?? null;
$user = $token ? User::findByApiToken($token) : null; // if you reuse Laravel's container, bootstrap it first
if (!$user) {
$socket->emit('unauthorized', 'Bad token');
return $socket->disconnect(true);
}
$socket->join("user:{$user->id}");
});
Cache the resolved user on the socket ($socket->user = $user;) and read it in later handlers rather than re-querying per event.
Pushing from Laravel (HTTP) into the socket server. The socket server is a separate process; you cannot call $io->emit() from a controller. Two common patterns:
workerman/channel — start a Channel\Server, have the socket process subscribe, and publish from the web app over a local TCP socket.workerStart, publish from controllers/queues.$io->on('workerStart', function () use ($io) {
$channel = new Channel\Client;
$channel->on('broadcast', function ($data) use ($io) {
$io->to($data['room'])->emit($data['event'], $data['payload']);
});
});
Scheduled pushes belong in workerStart with Workerman\Timer, never sleep():
$io->on('workerStart', function () use ($io) {
Timer::add(30, function () use ($io) {
$io->emit('tick', ['t' => time()]);
});
});
Acknowledgements and binary work as in socket.io: $socket->on('ev', function ($data, $ack) { $ack(['ok' => true]); }), and server-side $socket->emit('ev', $data, function ($answer) { ... }).
Reuse an existing Workerman worker (e.g., you already run one for queues) via $io->attach($worker) instead of opening a second port/process.
TLS: terminate at nginx and proxy to ws://127.0.0.1:2021, or do it in Workerman — pass an SSL context as the second constructor arg and set $io->worker->transport = 'ssl' inside workerStart.
socket.io-client 2.x usually works; 3.x and 4.x will not connect (EIO=4). Pin the frontend client and pin the PHP package; the release cadence is sparse, so treat upgrades as a deliberate test.composer require workerman/phpsocket.io:<old-version>) ready. Don't let @beta float in production; pin the exact beta.Engine\Protocols\WebSocket::input() reading TcpConnection::$maxPackageSize as a static property when it's an instance property — a fatal on PHP 8 whenever a WebSocket handshake's headers didn't arrive in a single read (large Cookie/Authorization headers, or a proxy that splits the upgrade). On PHP 7.x the same bug silently made the check always-true, i.e. the max-package-size guard wasn't actually guarding anything. If you have oversized-payload mitigations in place, retest them on beta.2.PollingJsonp::headers() signature mismatch threw a TypeError for any JSONP client POSTing data. Fixed in beta.2. If you support legacy clients via the ?j=N transport (or just never disabled JSONP), you want this release.TcpConnection::$defaultMaxPackageSize (the static default) or override $connection->maxPackageSize per connection — and if you've copied the old TcpConnection::$maxPackageSize access pattern into your own code, you've copied the bug.$io->origins('https://app.example.com') or, in dev, $io->origins('*:*'). Cookie-based auth also needs withCredentials on the client plus an explicit origin (not *).$io->emit() are per-process. If you set $io->worker->count = 4, each process has its own room table and its own client list — broadcasts silently miss users on other workers. Keep count = 1 until you've wired Channel/Redis fan-out.sleep, heavy query, or big file_get_contents inside a handler stalls every connected client. Push slow work to a queue and emit the result asynchronously.$socket->id must be removed in on('disconnect'), and don't capture $socket in closures that outlive the connection (timer callbacks especially — check isConnected/guard before emitting).php socket-server.php start, logs to Worker::$stdoutFile / Worker::$logFile. Debugging is far easier in the foreground — -d hides the stack traces you actually need.try/catch and log; an uncaught exception in a callback can take down the process and drop all its connections.connection closures need use ($io) — forgetting it is the most common "why does nothing emit" bug.$socket->disconnect() vs disconnect(true): the latter closes the underlying transport immediately; use it for rejected handshakes, and prefer rooms/emit('unauthorized') first so the client sees a reason.Upgrade/Connection headers and a long proxy_read_timeout; load balancers need sticky sessions if you ever scale to multiple hosts (or force transports: ['websocket'] client-side to avoid polling upgrades entirely).pcntl/posix; develop on Linux or WSL.$socket->handshake in a debug emit on your first integration; its shape (headers, query, address, time) is the fastest way to confirm auth and proxy headers are arriving as expected — and it's exactly the code path beta.2 fixed.parent:: refactor. Several call_user_func([get_parent_class(...), 'method'], ...) sites were rewritten to plain parent::method(...) calls. Claimed behaviorally identical, but if you've forked or extended Engine\Protocols\* / RFC6455, your overrides now dispatch through a different (more conventional) path — one smoke test is cheap insurance.composer lint runs phpstan analyse (level 6, ramped from 0) alongside phpcs. If you vendor-patch or extend internals, running the same command locally catches a real class of signature bugs — that's how the PollingJsonp::headers() TypeError and a Decoder.php out-of-bounds read (PHP-warning-on-some-configs) were found. Note that level 6 is not level max; untyped array shapes are still unverified.How can I help you explore Laravel packages today?