phrity/websocket
WebSocket client and multi-connection server for PHP, supporting ws/wss, listener callbacks, close and ping/pong handling, optional deflate compression, message fragmentation, and custom opcodes.
Installation (v4 is a major release — pin the constraint explicitly):
composer require phrity/websocket:^4.0
Basic Client Usage (Request/Response):
use WebSocket\Client;
use WebSocket\Middleware\CloseHandler;
use WebSocket\Middleware\PingResponder;
$client = new Client("wss://echo.websocket.org/");
$client
->addMiddleware(new CloseHandler())
->addMiddleware(new PingResponder());
$client->text("Hello");
$response = $client->receive();
echo $response->getContent(); // "Hello"
$client->close();
Basic Server Usage (Continuous Listening):
use WebSocket\Server;
use WebSocket\Middleware\CloseHandler;
use WebSocket\Middleware\PingResponder;
$server = new Server();
$server
->addMiddleware(new CloseHandler())
->addMiddleware(new PingResponder())
->onText(function ($server, $connection, $message) {
$connection->text("Echo: " . $message->getContent());
});
$server->start();
Smoke Test Before Building Anything — connect, send, receive, close against a throwaway endpoint (e.g. wss://echo.websocket.org/) and assert you get your payload back. Config regressions from v3 surface only at connect time, so a 20-line script saves hours later.
4.0.0 is not a drop-in upgrade. The release notes are explicit that the most likely conflict is Client and Server configuration (constructor options), plus the deeper-level interfaces deprecated in 3.7/3.8 are now removed. Before upgrading:
getConnections() consumer (see Gotchas — the return type changed from array to a Connections collection).catch blocks — the exception structure was reworked in v4 (PR #162).The quickest audit is a static analysis pass plus:
composer why-not phrity/websocket 4.0.0
Connections collection.CloseHandler + PingResponder in the middleware stack of both sides from day one — retrofitting keep-alive after you have zombie connections is painful.Request/Response Workflow:
$client = new Client("wss://api.example.com/ws");
$client->text(json_encode(['action' => 'fetch_data']));
$response = $client->receive(); // Blocking
$data = json_decode($response->getContent(), true);
Event-Driven Subscription:
$client = new Client("wss://stream.example.com");
$client->onText(function ($client, $connection, $message) {
$data = json_decode($message->getContent(), true);
// process real-time updates
});
$client->start();
Reconnection Logic (exception classes changed in v4 — catch broadly first, then narrow):
$client = new Client("wss://reliable.example.com");
$client->onError(function ($client, $connection, $error) {
// v4 reworked the exception hierarchy: log the concrete class before
// matching on it, since v3 class names may no longer exist.
logger()->warning('ws error', ['class' => get_class($error), 'msg' => $error->getMessage()]);
$client->reconnect();
});
Multi-Connection Handling:
$server = new Server();
$server->onText(function ($server, $connection, $message) {
$connection->text("Processed: " . $message->getContent());
});
$server->start();
Connection-Specific Logic:
$server = new Server();
$server->onOpen(function ($server, $connection) {
$connection->setMeta(['user_id' => 123]);
});
$server->onText(function ($server, $connection, $message) {
$userId = $connection->getMeta('user_id');
// user-scoped logic
});
Broadcasting via the Connections Collection (v4 change):
$server->onText(function ($server, $connection, $message) {
// getConnections() now returns a Connections collection, not an array
$server->getConnections()
->filter(fn ($c) => $c !== $connection)
->each(fn ($c) => $c->text($message->getContent()));
});
Middleware Stack (registration order matters):
$server
->addMiddleware(new CloseHandler())
->addMiddleware(new PingResponder())
->addMiddleware(new CompressionHandler());
Service Provider Setup:
// app/Providers/WebSocketServiceProvider.php
public function register()
{
$this->app->singleton(\WebSocket\Server::class, function ($app) {
$server = new \WebSocket\Server();
$server
->addMiddleware(new \WebSocket\Middleware\CloseHandler())
->addMiddleware(new \WebSocket\Middleware\PingResponder())
->onText([$app['websocket.handler'], 'handle']);
return $server;
});
}
Keep configuration in config/websocket.php (host, port, TLS context, timeouts) and pass it into the constructor — v4 changed how Client/Server are configured, so centralising it now saves you a second migration.
Event-Driven Controllers / Handlers:
public function handle($server, $connection, $message)
{
$data = json_decode($message->getContent(), true);
event(new WebSocketMessageReceived($data));
}
Artisan Command as the Runtime:
// app/Console/Commands/WebSocketServe.php
protected $signature = 'websocket:serve';
public function handle()
{
resolve(\WebSocket\Server::class)->start();
}
Run it under Supervisor/systemd, not the scheduler (the old "schedule daily" example was never a valid way to run a long-lived socket server).
Laravel Echo (browser side) stays a Pusher-protocol concern; this package is your PHP socket layer, not the Echo backend. Only point Echo at this server if you front it with a Pusher-protocol-compatible broker.
getConnections() is no longer an array (v4 breaking change):
array_map, array_filter, count() and foreach ($x as $k => $v) semantics differ.->each(), ->filter(), ->map(), ->count()). foreach still works, but keys are no longer guaranteed to be connection ids — use the connection object itself as the identity.Exception structure rework (v4 breaking change, PR #162):
catch blocks that targeted specific classes may silently stop matching.\Throwable (or the package base exception) first, log get_class($e), then narrow. Add a test that asserts your handlers still fire.Compression failures now throw:
MessageEncodingException is raised when message compression fails, instead of failing quietly.try/catch, log the payload size, and consider omitting CompressionHandler for small-payload or binary-heavy workloads.Client/Server configuration conflicts with v3:
new Client($uri, $options)-style code may connect with different defaults (timeouts, TLS context) without erroring.Blocking Calls:
Client::receive() blocks. Offload to a queue worker or use the event-driven flow.receive() inside an HTTP request cycle.Middleware Order:
CloseHandler first for graceful shutdown.Connection Leaks:
try {
$client->start();
} finally {
$client->close();
}
SSL/TLS Issues:
$context = stream_context_create([
'ssl' => ['verify_peer' => false, 'allow_self_signed' => true],
]);
$client = new Client("wss://localhost", [], $context);
Fragmented Messages:
Message::isFinalFragment() before acting.Removed deprecated code (PR #163):
Nyholm PSR-7 is now a direct dependency (PR #164):
composer require nyholm/psr7 and switch to real PSR-7 objects; do not mix stub factories with v4.Logging:
$logger = new \Monolog\Logger('websocket');
$client->setLogger($logger);
$client->addMiddleware(new \WebSocket\Middleware\LoggingMiddleware($logger));
Log the new exception classes explicitly — with the v4 hierarchy, $logger->error(get_class($e)) is the quickest way to learn what replaced the v3 class you used to catch.
Connection Identity:
$server->onOpen(function ($server, $connection) {
$identity = $connection->getIdentity();
// store in cache/db
});
Timeouts:
start() to avoid hangs: $client->start(5.0);Custom Middleware:
namespace App\Middleware;
use WebSocket\Middleware\MiddlewareInterface;
class AuthMiddleware implements MiddlewareInterface
{
public function __invoke($next)
{
return function ($client, $connection, $message) use ($next) {
if (!$this->validateToken($message->getContent())) {
$connection->close(1008, "Unauthorized");
return;
}
return $next($client, $connection, $message);
};
}
}
Note: v4 deprecated several deeper-level interfaces — re-check that your custom middleware still implements the current contract before upgrading.
PSR-7 / Nyholm:
Nyholm\Psr7\Factory\Psr17Factory) and declare the dependency yourself:composer require nyholm/psr7
Custom Identity:
class UserIdentity implements \WebSocket\Identity\IdentityInterface
{
public function getId(): string { return $this->userId; }
}
$connection->setIdentity(new UserIdentity($userId));
Queue Integration — dispatch from handlers, never block the loop:
$server->onText(function ($server, $connection, $message) {
dispatch(new ProcessWebSocketMessage($message->getContent()));
});
Remember queue workers are separate processes: they cannot write back to $connection. Fan-out must go through the server loop (e.g. a Redis pub/sub listener inside start()).
Long-running process hygiene: the socket server is a daemon. Reset stateful services (DB::reconnect() on idle, clear scoped bindings), and avoid leaking per-connection metadata — especially now that connections live in a collection you iterate frequently.
Config caching: php artisan config:cache in deploy is fine, but the daemon caches config at boot — restart the daemon on every deploy, never reload in place.
Route/config caching does not affect the socket server, but it does affect any HTTP endpoint that pushes into it; keep those behind a queue or a shared store rather than trying to reach the Server singleton from a web request (the singleton is per-process).
How can I help you explore Laravel packages today?