mpociot/phpws
mpociot/phpws is a lightweight, event-driven WebSocket server for PHP built on ReactPHP. Simple onOpen, onMessage, and onClose callbacks make it easy to add real-time features like chat, notifications, and live updates to your apps.
Install and pin in one move. The last tag is from May 2019, so treat composer require as a negotiation with your existing dependency graph, not a formality:
composer require mpociot/phpws --dry-run # does it resolve against react/* at all?
composer require mpociot/phpws
If it resolves, immediately pin the exact version in composer.json (no ^), commit the lock, and note it in your risk register. If it doesn't resolve, stop — do not spend a day fighting composer why-not; that fight is the signal.
Read src/ before you write anything. The whole value proposition here is that it's small. Budget an hour: find the Server class, the Client class, and whatever represents a single connection. Every lifecycle question you will have later ("what object do I hand around? can it write? what identifies it?") is answered there. Class names below follow the repo's structure — confirm against src/ before pasting.
First run, before Laravel. Prove the socket works with zero framework involvement:
// scratch/server.php
require __DIR__ . '/vendor/autoload.php';
$server = new \Phpws\Server\Server('0.0.0.0', 8080);
$server->on('open', function ($connection) {
echo "open\n";
$connection->send(json_encode(['type' => 'welcome']));
});
$server->on('message', function ($connection, $message) {
echo "recv: {$message}\n";
$connection->send(json_encode(['type' => 'echo', 'payload' => $message]));
});
$server->on('close', function ($connection) {
echo "close\n";
});
$server->run();
Connect with wscat -c ws://127.0.0.1:8080 (or a browser console new WebSocket(...)) from two terminals. If two clients can both echo, you have validated the only thing this package unambiguously gives you.
First Laravel use case: wrap that script in an artisan ws:serve command and publish one real domain event to it — a TaskCompleted notification pushed to one connected client. That single end-to-end path (domain event → daemon → browser) is the whole spike; everything else is hardening.
Where to look first in the docs/source: onOpen (auth seam), onMessage (routing seam), and the connection object (state seam). Those three are the entire extension surface. There is no service provider, no config file, no broadcasting driver — do not go looking for them.
1. Abstract the transport before you touch the daemon. This is the pattern that makes everything else reversible. Define an internal contract with no phpws types in it:
// app/Broadcasting/Broadcaster.php
interface Broadcaster
{
public function publish(string $channel, string $event, array $payload): void;
public function presence(string $channel): array;
}
Then a PhpwsBroadcaster that publishes to Redis, and a LogBroadcaster for tests. Domain code calls app(Broadcaster::class); the daemon subscribes to Redis. When you swap to Reverb or Soketi, the interface and every call site stay put.
2. artisan ws:serve as the daemon entrypoint. Boot the framework, resolve the server, register handlers, run(). Keep the command thin — the handlers belong in a dedicated class so they're readable and testable in isolation:
class WsServeCommand extends Command
{
protected $signature = 'ws:serve {--host=0.0.0.0} {--port=8080}';
public function handle(ConnectionRegistry $registry, MessageRouter $router): int
{
$server = new Server($this->option('host'), (int) $this->option('port'));
$server->on('open', fn ($c) => $registry->add($c));
$server->on('message', fn ($c, $msg) => $router->route($c, $msg));
$server->on('close', fn ($c) => $registry->remove($c));
$this->info('ws daemon listening');
$server->run();
return self::SUCCESS;
}
}
3. Auth in onOpen, not in onMessage. WebSocket clients can't send custom headers, so pass a short-lived signed token in the query string and verify it immediately:
$server->on('open', function ($connection, $request) use ($registry) {
$token = parse_url($request->getUri(), PHP_URL_QUERY); // ?token=...
$user = TokenGuard::verify($token);
if (! $user) {
$connection->send(json_encode(['type' => 'error', 'code' => 'unauthenticated']));
$connection->close();
return;
}
$registry->add($connection, ['user_id' => $user->id]);
});
Never trust a user id sent in the first message — by then the socket is already open and you own the timeout logic.
4. Registry keyed by your own id, with metadata attached. Store spl_object_id($connection) or a generated UUID → {connection, user_id, channels[], last_seen}. Use your own ids when talking to browsers; the library's internal handle is not a session identifier.
5. Channel subscribe/publish as message types. A tiny envelope keeps clients dumb and stable:
{ "type": "subscribe", "channel": "user.42" }
{ "type": "event", "channel": "user.42", "event": "TaskCompleted", "payload": {} }
onMessage switches on type, checks canSubscribe($user, $channel) against a Laravel policy or gate, then adds to the channel map. Same authorization logic you'd use in an HTTP controller — no reason to invent a second model.
6. Redis as the seam between Laravel and the daemon. Laravel's event listener does Redis::publish('ws', json_encode($envelope)). The daemon, on a periodic timer or via an async Redis client, reads the payload and fans out to local subscribers. This decouples the HTTP process from the socket process entirely and is the one design decision that ports unchanged to any other daemon.
7. Use the built-in client for smoke tests. A PHPUnit test or a ws:ping command that connects to the daemon, sends a message, asserts the echo, and disconnects will save you from manually re-testing the browser after every change. It's also the fastest way to verify the handshake after you put nginx in front.
8. Heartbeat on a loop timer. Track last_seen on every inbound message; a periodic timer closes sockets silent for >2× the ping interval. Browsers behind flaky mobile networks leave half-open sockets that never fire close.
A single blocking call freezes every client. This is the one that generates incidents. User::find() on a cold connection, a Guzzle call, a sleep, a large Storage::put — all of them stall the entire event loop, not one connection. Rule: handlers dispatch to the queue and return. If you must read, read from a pre-warmed cache. Put this in the code review checklist.
Long-lived process vs. Laravel's assumptions. The framework was designed for a process that lives for one request. In a daemon, MySQL connections go stale (MySQL server has gone away), and anything you resolve() once and hold gets frozen at boot. Wrap DB access in reconnect-on-demand, or avoid Eloquent in the daemon entirely — publish from Laravel, consume from a thin daemon.
.env is read once. Config changes need a restart. If you're deploying with config:cache, remember the daemon reads the cached config at boot, so a config change without a daemon restart is silently ignored.
ReactPHP swallows exceptions inside callbacks. An uncaught error in onMessage can vanish with no output, and the client just... hangs. Wrap every handler in try/catch with Log::error() and a stack trace, and send a generic error frame back. Debugging without this is miserable.
No TLS, no origin check, no auth — all of it is yours. Terminate TLS at nginx/Caddy; validate the Origin header yourself to block cross-site socket hijacking (browsers do not enforce same-origin for WebSockets); verify tokens in onOpen. The proxy work is identical regardless of which daemon you pick, which is why it's not wasted effort here.
Proxy idle timeouts will kill your sockets and look like a bug. nginx's proxy_read_timeout defaults to 60s; if your ping interval exceeds it, connections drop on a timer. Set the proxy timeout to several times your heartbeat interval, and set proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";.
No clustering story, and none is coming. Two daemon nodes do not share connection state. A Redis fan-out where every node broadcasts to its own local connections works for messaging; presence and "who's online" do not, unless you build it in Redis yourself. Decide this before launch, not after the second node.
Memory grows unless you're deliberate. Remove connections from every channel map on close; don't accumulate closures capturing large payloads; consider recycling the process on a timer (restart after N connections or X hours) as a blunt but effective discipline.
Cap payload size and validate JSON before routing. No built-in backpressure means a slow consumer with a full TCP buffer buffers in your process's memory. Reject oversized frames and drop or disconnect consumers that fall behind.
Supervise it. systemd unit or Supervisor with Restart=always and a backoff. Never inside Octane workers, never behind FPM. Name the owner and the on-call expectation explicitly — an unsupervised daemon that dies at 2am is indistinguishable from an outage you didn't budget for.
Client reconnection is your problem. Browsers don't resubscribe on reconnect. Exponential backoff plus a resubscribe-on-open handshake belongs in your JS, and it's worth writing on day one because it's the part you'll demo most.
Keep it internal, and write the exit down. Nothing has shipped since 2019, there are no dependents, and no upstream will fix a CVE on the TLS or framing path. Timebox the spike with a written acceptance criterion and a stop date. If the value case holds, the swap is a config change because of the Broadcaster interface; if it doesn't, the daemon becomes an admin-console or demo tool where the maintenance tax is cheap. Both outcomes are fine — drifting into production without deciding is the only bad one.
How can I help you explore Laravel packages today?