laravel-websockets is a Laravel package that can handle the server side of WebSockets entirely. It completely replaces the need for a service like Pusher or a JavaScript-based laravel-echo-server. It has extensive documentation and a demo application you can play with. Marcel, developer and co-owner at beyondcode, and I have been working on this together for the past couple of weeks. In this blog post, we'd like to introduce the package to you.

console

What are WebSockets? #

Simply said, a WebSocket connection is a persistent connection between a browser and the server. It allows two-way communication: the server can send messages to the browser and the browser - the client - can respond back via the same connection. This differs from regular Ajax, which is only one-way communication: only the client can ask stuff from the server.

WebSockets are mainly used for real-time apps, such as chat applications. Such a chat application could work like this.

  1. User A points the browser to the chat application. The browser creates a WebSocket connection to the server. Remember that the server keeps the connection open.
  2. User B, C, D, ... do the very same thing. The server now has multiple open connections.
  3. The first user transmits a message through the open WebSocket connection to the server.
  4. The server sees a message coming in via the first connection and send that message to all other connections it has open.
  5. The browsers of user B, C, D, ... get an incoming coming message via the WebSockets and can do something with it (in case of chat application: display it).

All this happens in real-time, there's no need for polling. If there are a lot of messages the payload is also a lot smaller. There's no need to send headers and such through the connection.

What is laravel-websockets? #

Our laravel-websockets package can handle the serverside of WebSockets for you. It contains a webserver, implemented in PHP, that can handle incoming WebSocket connections.

laravel-websockets has been built on top of Ratchet, a low-level package to handle WebSockets. Working with Ratchet directly is doable, but there's some research and setup required to make it work. Our package takes all of that away: you'll be able to get a WebSockets server running in a minute.

Marcel and I added a lot of nice features, that would take you quite a while to implement when using Ratchet itself. There is support for multi-tenancy, so you could set up a webSockets server and use it for many different applications. We also added a real-time debug dashboard, so you can inspect what is being transmitted through the webSockets. All this is being powered with a real-time chart that gives you key-insights into your WebSocket application metrics, like the number of peak connections, the amount of messages sent and the amount of API messages received.

We also implemented the pusher message protocol. By doing this, all existing packages and applications out there that support Pusher will work with our package as well. Laravel's broadcasting capabilities will just work it. laravel-echo, a JavaScript library which handles webSockets clientside, is also 100% compatible. You can use all the features Pusher offers, such as private channels, presence channels and even the Pusher HTTP API.

Besides being a free alternative for Pusher, this package also gives Laravel package developers a big advantage. It's now a lot easier to add WebSocket capabilities into your application or packages - since our package completely removes the need for a third-party application or server being installed. Pusher also has a fairly conservatie maximum payload size. In our package you specify your own maximum payload size.

How to use it #

Before diving into the details of how this all works under the hood, let's first see how we can actually use it.

First up, require it with Composer

composer require beyondcode/laravel-websockets

This package comes with a migration to store statistic information while running your WebSocket server. You can publish the migration file using:

php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="migrations"

Run the migrations with:

php artisan migrate

Next, you must publish the config file.

php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="config"

This is the file that will be published.

return [

    /*
     * This package comes with multi tenancy out of the box. Here you can
     * configure the different apps that can use the webSockets server.
     *
     * Optionally you can disable client events so clients cannot send
     * messages to each other via the webSockets.
     */
    'apps' => [
        [
            'id' => env('PUSHER_APP_ID'),
            'name' => env('APP_NAME'),
            'key' => env('PUSHER_APP_KEY'),
            'secret' => env('PUSHER_APP_SECRET'),
            'enable_client_messages' => false,
            'enable_statistics' => true,
        ],
    ],

    /*
     * This class is responsible for finding the apps. The default provider
     * will use the apps defined in this config file.
     *
     * You can create a custom provider by implementing the
     * `AppProvider` interface.
     */
    'app_provider' => BeyondCode\LaravelWebSockets\Apps\ConfigAppProvider::class,

    /*
     * This array contains the hosts of which you want to allow incoming requests.
     * Leave this empty if you want to accept requests from all hosts.
     */
    'allowed_origins' => [
        //
    ],

    /*
     * The maximum request size in kilobytes that is allowed for an incoming WebSocket request.
     */
    'max_request_size_in_kb' => 250,

    /*
     * This path will be used to register the necessary routes for the package.
     */
    'path' => 'laravel-websockets',

    'statistics' => [
        /*
         * This model will be used to store the statistics of the WebSocketsServer.
         * The only requirement is that the model should extend
         * `WebSocketsStatisticsEntry` provided by this package.
         */
        'model' => \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry::class,

        /*
         * Here you can specify the interval in seconds at which statistics should be logged.
         */
        'interval_in_seconds' => 60,

        /*
         * When the clean-command is executed, all recorded statistics older than
         * the number of days specified here will be deleted.
         */
        'delete_statistics_older_than_days' => 60
    ],

    /*
     * Define the optional SSL context for your WebSocket connections.
     * You can see all available options at: http://php.net/manual/en/context.ssl.php
     */
    'ssl' => [
        /*
         * Path to local certificate file on filesystem. It must be a PEM encoded file which
         * contains your certificate and private key. It can optionally contain the
         * certificate chain of issuers. The private key also may be contained
         * in a separate file specified by local_pk.
         */
        'local_cert' => null,

        /*
         * Path to local private key file on filesystem in case of separate files for
         * certificate (local_cert) and private key.
         */
        'local_pk' => null,

        /*
         * Passphrase for your local_cert file.
         */
        'passphrase' => null
    ],
];

The last step is to fill some environment variables. Make sure that APP_NAME, PUSHER_APP_ID, PUSHER_APP_KEY, PUSHER_APP_SECRET are filled in your .env. You might wonder why we're using PUSHER_ prefixes here. More on that later.

And with that out of the way, you can start the server by executing this command.

php artisan websockets:serve

Now you can use a library like laravel-echo to connect to the server. I'm not going through the entire process in detail. To get started with the client side check out our docs, the laravel-echo docs and this demo application.

demo

How does it work under the hood? #

If you're not interested in the nitty gritty details of how all of this work, but just want to use it, you can skip ahead to the section on the debug dashboard.

When executing php artisan websockets:serve you'll notice that the command never ends. That because inside that command we start a Ratchet server that starts listing for connections (by default on port 6001).

protected function startWebSocketServer()
{
    $this->info("Starting the WebSocket server on port {$this->option('port')}...");

    $routes = WebSocketsRouter::getRoutes();

    /** 🛰 Start the server 🛰 */
    (new WebSocketServerFactory())
        ->useRoutes($routes)
        ->setHost($this->option('host'))
        ->setPort($this->option('port'))
        ->setConsoleOutput($this->output)
        ->createServer()
        ->run();
}

One of the things we give to the server is the $routes. That variable is an instance of \Symfony\Component\Routing\RouteCollection. That's being created in our Router class.

Because in our StartWebSocketsCommand we've called the echo method, the routes will contain these routes.

public function echo()
{
    $this->get('/app/{appKey}', WebSocketHandler::class);
    
    $this->post('/apps/{appId}/events', gerEventController::class);
    $this->get('/apps/{appId}/channels', hChannelsController::class);
    $this->get('/apps/{appId}/channels/{channelName}', hChannelController::class);
    $this->get('/apps/{appId}/channels/{channelName}/users', hUsersController::class);

}

Now here's where it gets interesting. The first route is the route that will handle the incoming webSockets. The route URL /app/{appKey} is that specific URL because that is what Pusher uses. Using that URL is important to make all Pusher compatible JavaScript clients work.

The other ones listen for incoming HTTP connections. If you think of it, this is pretty amazing: our little server can listen for both WebSockets and HTTP connections. Isn't PHP awesome?

Incoming WebSocket connections #

Let's dive a little bit deeper into how WebSocket connections are handled.

$this->get('/app/{appKey}', WebSocketHandler::class);

That get method in the Router will call the getRoute method. In that method, we wrap the instantiated WebSocketHandler in a WsServer class. That WsServer class is provided by Ratchet. It provides the code that does the nit gritty work to work with WebSockets.

// inside the `BeyondCode\LaravelWebSockets\Server\Router` class

public function get(string $uri, $action)
{
    $this->addRoute('GET', $uri, $action);
}

protected function getRoute(string $method, string $uri, $action): Route
{
    /**
     * If the given action is a class that handles WebSockets, then it's not a regular
     * controller but a WebSocketHandler that needs to converted to a WsServer.
     *
     * If the given action is a regular controller we'll just instanciate it.
     */
    $action = is_subclass_of($action, MessageComponentInterface::class)
        ? $this->createWebSocketsServer($action)
        : app($action);

    return new Route($uri, ['_controller' => $action], [], [], null, [], [$method]);
}

protected function createWebSocketsServer(string $action): WsServer
{
    $app = app($action);

    if (WebsocketsLogger::isEnabled()) {
        $app = WebsocketsLogger::decorate($app);
    }

    return new WsServer($app);
}

Now that you know how the routing is set up for the WebSockets part, let take a look at what the WebSocketHandler actually does.

You can think of the WebSocketHandler class as a controller, but for WebSockets instead of HTTP. It contains various lifecycle methods:

  • onOpen: will be called when a new client opens up a webSockets connection
  • onMessage: will be called when the client sends a new message through the webSocket.
  • onClose: will be called when the client disconnects (usually by navigation to another page, or closing the tab or browser window)

onOpen #

This is the code of the onOpen method.

public function onOpen(ConnectionInterface $connection)
{
    $this
        ->verifyAppKey($connection)
        ->generateSocketId($connection)
        ->establishConnection($connection);
}

Three things happen we a new connection comes in. The first thing is the app verification. Remember that route that's being used to handle webSocket connections?

$this->get('/app/{appKey}', WebSocketHandler::class);

The verifyAppKey function will check if the given appKey is correct. In a default installation App::findByAppKey will if there is an app defined with this key in the websockets.php config file.

protected function verifyAppKey(ConnectionInterface $connection)
{
    $appKey = QueryParameters::create($connection->httpRequest)->get('appKey');

    if (!$app = App::findByKey($appKey)) {
        throw new UnknownAppKey($appKey);
    }

    $connection->app = $app;

    return $this;
}

We store a reference to app on the connection itself so we can use this on subsequent events on the connection (for example when a message comes in).

Secondly, we're going to generate a socket id.

protected function generateSocketId(ConnectionInterface $connection)
{
    $socketId = sprintf("%d.%d", random_int(1, 000000), random_int(1, 1000000000));

    $connection->socketId = $socketId;

    return $this;
}

The socketId has to follow a specific format. Two numbers separated by a dot. We are going to store this number on the connection itself. We'll use that id to identify this particular connection. Remember our server process never ends so this id will stay on the connection as long as it is open.

The last thing that happens on opOpen is to send back a response to the browser that the connection has succeeded. Again, we follow the pusher protocol here and send out a pusher:connection_established event to be compatible with all existing Pusher packages out there. We'll also log this event on the dashboard.

protected function establishConnection(ConnectionInterface $connection)
{
    $connection->send(json_encode([
        'event' => 'pusher:connection_established',
        'data' => json_encode([
            'socket_id' => $connection->socketId,
            'activity_timeout' => 30,
        ])
    ]));

    DashboardLogger::connection($connection);

    return $this;
}

And all that work done, we have an open connection.

Let's now talk a look at what happens when a message is sent over this connection.

onMessage #

Let's take a look at the onMessage function in the WebSocketHandler class

public function onMessage(ConnectionInterface $connection, MessageInterface $message)
{
    $message = PusherMessageFactory::createForMessage($message, $connection, $this->channelManager);

    $message->respond();
}

Here we are getting the message and respond to it. Let' dive a deeper in the createForMessage method in the PusherMessageFactory class.

use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use Ratchet\ConnectionInterface;
use Ratchet\RFC6455\Messaging\MessageInterface;

class PusherMessageFactory
{
    public static function createForMessage(
        MessageInterface $message,
        ConnectionInterface $connection,
        ChannelManager $channelManager): PusherMessage
    {
        $payload = json_decode($message->getPayload());

        return starts_with($payload->event, 'pusher:')
            ? new PusherChannelProtocolMessage($payload, $connection, $channelManager)
            : new PusherClientMessage($payload, $connection, $channelManager);
    }
}

In the class above we are going to pick the right message type according to the event name in the payload. PusherChannelProtocolMessage handles the messages that are mostly focused around the subscription on channels. PusherClientMessage will handle messages that are being sent by clients that have subscribed to the channels, more on that later.

Let's see what happens in PusherChannelProtocolMessage. More specifically we'll take a look in it's respond method since that is what was called in the WebSocketHandler class

public function respond()
{
    $eventName = camel_case(str_after($this->payload->event, ':'));

    if (method_exists($this, $eventName)) {
        call_user_func([$this, $eventName], $this->connection, $this->payload->data ?? new stdClass());
    }
}

$this->payload->event will contains a string like pusher:subscribe. So in the code above $eventName will contain subscribe.

Let's talk a bit about subscribing first. Pusher works with different channels. You can think of a channel like a channel on the radio. If you turn your radio to the specific channels you'll hear the messages that are sent on that channel. According the Pusher protocol a client that wants to subscribe to a channel must use the subscribe method.

That call_user_func call in the respond function above will try to call a function named liked the event. So for the event subscribe, the subscribe method will be called.

protected function subscribe(ConnectionInterface $connection, stdClass $payload)
{
    $channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel);

    $channel->subscribe($connection, $payload);
}

Here we make our first contact with the ChannelManager. This class is responsible for keeping track which connections are subscribed to which channels.

Let's take a look at the called findOrCreate on the ChannelManager

public function findOrCreate(string $appId, string $channelName): Channel
{
    if (!isset($this->channels[$appId][$channelName])) {
        $channelClass = $this->determineChannelClass($channelName);

        $this->channels[$appId][$channelName] = new $channelClass($channelName);
    }

    return $this->channels[$appId][$channelName];
}

In this function, we are going to look up a Channel instance for the given appId and channelName. If it doesn't exist we'll new it up and store it in the channels instance variable. Because our server process never ends and is the same for all incoming connections, storing it in memory is enough, we don't need to persist it somewhere else.

Let's step into determineChannelClass and discuss the different channel types as defined by Pusher.

protected function determineChannelClass(string $channelName): string
{
    if (starts_with($channelName, 'private-')) {
        return PrivateChannel::class;
    }

    if (starts_with($channelName, 'presence-')) {
        return PresenceChannel::class;
    }

    return Channel::class;
}

Pusher has three different channel types:

  • private channels: an authentication check will be performed before a client can actually subscribe itself to the channels
  • presence channels: the same as private channels but with extra functionalities to determine who is on the channel (this is handy if you're building something like a chat room)
  • public channels: these are channels where anyone can subscribe to, no authentication check is performed.

These three channel types are implemented in our code as PrivateChannel, PresenceChannel and Channel. In determineChannelClass we are newing up and return a channel of the right type.

Let's go a bit back to the aforementioned subscribe method in the PusherChannelProtocolMessage class. Here's the code again.

protected function subscribe(ConnectionInterface $connection, stdClass $payload)
{
    $channel = $this->channelManager->findOrCreate($connection->client->appId, $payload->channel);

    $channel->subscribe($connection, $payload);
}

So now we know that $channel holds an instance of the right channel class, let's look at the implementation of subscribe. Let's see what goes on in the regular Channel class first.

public function subscribe(ConnectionInterface $connection, stdClass $payload)
{
    $this->saveConnection($connection);

    $connection->send(json_encode([
        'event' => 'pusher_internal:subscription_succeeded',
        'channel' => $this->channelName
    ]));
}

protected function saveConnection(ConnectionInterface $connection)
{
    $hadConnectionsPreviously = $this->hasConnections();

    $this->subscriptions[$connection->socketId] = $connection;

    if (! $hadConnectionsPreviously) {
        DashboardLogger::occupied($connection, $this->channelName);
    }

    DashboardLogger::subscribed($connection, $this->channelName);
}

Subscribing to a channel is nothing more than just storing the socketId of the connection (remember we set this id in the onOpen method? in the subscriptions instance variable of the channel?). saveConnection also sends a message back of the connection to let the client now that the subscription succeeded. That pusher_internal:subscription_succeeded event name is specified by the Pusher protocol.

And with that, the client is subscribed and the message passed to onMessage is handled.

But what would have happened if we were not working with a regular channel put with PrivateChannel? Let's take a look at PrivateChannel.

namespace BeyondCode\LaravelWebSockets\WebSockets\Channels;

use Ratchet\ConnectionInterface;
use stdClass;

class PrivateChannel extends Channel
{
    public function subscribe(ConnectionInterface $connection, stdClass $payload)
    {
        $this->verifySignature($connection, $payload);

        parent::subscribe($connection, $payload);
    }
}

So the subscribe method will do exactly the same as in the regular channel, but before actually subscribing the signature will be verified. As this blog post is quite long I’m not going into the actual verification. If you want to know more about that check out the implementation of the method and the Pusher docs on authentication.

And with that, a client is subscribed to a channel. Let's now review what happens what happens when a client sends a message to the channel.

According to the pusher protocol clients can send messages to each other using messages that contain an event name starting with client-. Let's take another look at the PusherMessageFactory.

return starts_with($payload->event, 'pusher:')
    ? new PusherChannelProtocolMessage($payload, $connection, $channelManager)
    : new PusherClientMessage($payload, $connection, $channelManager);

We already covered the PusherChannelProtocolMessage, let's now head into the respond method of the PusherClientMessage.

public function respond()
{
    if (!starts_with($this->payload->event, 'client-')) {
        return;
    }
    
    if (! $this->connection->app->clientMessagesEnabled) {
        return;
    }
    
    DashboardLogger::clientMessage($this->connection, $this->payload);
    
    $channel = $this->channelManager->find($this->connection->app->id, $this->payload->channel);
    
    optional($channel)->broadcastToOthers($this->connection, $this->payload);
}

Let's go through that code If the event name of the message doesn't start with client-, it isn't a client to client message and we are going to bail out. The second if checks if client to client messaging is enabled (it can be disabled on a per-app basis). Next we're going to log the message on our debug dashboard.

With that out of the way the real work can start. We are gonna ask the ChannelManager to find the right Channel for the message. If it can find the right Channel we're going to call broadcast on it.

The implementation of broadcastToOthers on Channel is not that hard. The code below loops through every connection subscribed to the channel and sends the connection the payload. The client that sent to original message does not need to receive it. We filter out the connection of the sender using the socketId we set earlier.

public function broadcastToOthers(ConnectionInterface $connection, $payload)
{
    $this->broadcastToEveryoneExcept($payload, $connection->socketId);
}

public function broadcastToEveryoneExcept($payload, ?string $socketId = null)
{
    if (is_null($socketId)) {
        return $this->broadcast($payload);
    }

    foreach ($this->subscribedConnections as $connection) {
        if ($connection->socketId !== $socketId) {
            $connection->send(json_encode($payload));
        }
    }
}

And with that we've send a message to all clients present on the channel.

onClose #

The third lifecycle method the WebSocketHandler takes care of is onClose. It is being called when a client goes away, for example when the tab or browser window is closed. The implementation is pretty easy.

public function onClose(ConnectionInterface $connection)
{
    $this->channelManager->removeFromAllChannels($connection);
}

Inside the removeFromAllChannels method of ChannelManager, we'll remove the connection from all channels. We'll also remove channels and apps that have no connections anymore, so we don't leak memory.

public function removeFromAllChannels(ConnectionInterface $connection)
{
    if (!isset($connection->app)) {
        return;
    }

    /**
     * Remove the connection from all channels.
     */
    collect(array_get($this->channels, $connection->app->id, []))->each->unsubscribe($connection);

    /**
     * Unset all channels that have no connections so we don't leak memory.
     */
    collect(array_get($this->channels, $connection->app->id, []))
        ->reject->hasConnections()
        ->each(function (Channel $channel, string $channelName) use ($connection) {
            unset($this->channels[$connection->app->id][$channelName]);
        });

    if (count(array_get($this->channels, $connection->app->id, [])) === 0) {
        unset($this->channels[$connection->app->id]);
    };
}

Incoming HTTP connections #

Above we already explained that php artisan websockets:serve we start a Ratchet server. If you're reading this post from top to bottom you'll already know how that Ratchet server handles webSockets. Now let's focus on the HTTP part.

Laravel has excellent support for broadcasting events. Have you ever wondered what happens under the hood when Laravel broadcasts something? Simply said it will perform an HTTP call with a certain payload to a certain endpoint.

Our docs specify that you should change host of the pusher configuration to '127.0.0.1' (or the hostname of your server where our package runs). That will make Laravel send that HTTP call to our package instead of Pusher.

Remember those routes in our server? Well, when Laravel broadcast an event it will send an POST request to this route:

$this->post('/apps/{appId}/events', TriggerEventController::class);

Let's see the code of that TriggerEventController

class TriggerEventController extends Controller
{
    public function __invoke(Request $request)
    {
        $this->ensureValidSignature($request);

        foreach ($request->json()->get('channels', []) as $channelName) {
            $channel = $this->channelManager->find($request->appId, $channelName);

            optional($channel)->broadcastToEveryoneExcept([
                'channel' => $channelName,
                'event' => $request->json()->get('name'),
                'data' => $request->json()->get('data'),
            ], $request->json()->get('socket_id'));

            DashboardLogger::apiMessage(
                $request->appId,
                $channelName,
                $request->json()->get('name'),
                $request->json()->get('data')
            );
        }

        return $request->json()->all();
    }
}

We don't want this endpoint to be callable by just anyone. When sending the request Laravel will add a hashed version of the app signature together with some other parameters to the request. In ensureValidSignature method we validate if that signature is correct.

If the signature is correct we are going to let the ChannelManager find all the channels the incoming request is intended for. This is interesting. How is this channel manager instantiated? How does it know the channels? We'll our server is a process that never ends. It handles both WebSockets and HTTP requests. The ChannelManager is bound as a singleton, so there can be only one instance of it. So this ChannelManager holds all connections of the clients currently subscribed to a channel. So within handling this HTTP request, we can send out messages through the connected WebSockets. This is pretty amazing in our book!

On every channel found by the ChannelManager we are going to call the broadcastToEveryoneExcept method. Here is the implementation:

public function broadcastToEveryoneExcept($payload, ?string $socketId = null)
{
    if (is_null($socketId)) {
        return $this->broadcast($payload);
    }

    foreach ($this->subscribedConnections as $connection) {
        if ($connection->socketId !== $socketId) {
            $connection->send(json_encode($payload));
        }
    }
}

Like seen previously a channel holds all connections subscribed to it. If Laravel wasn't instructed by a specific client to broadcast a message, then the message probably was initiated by the server itself and it should be broadcasted to all subscribed connections.

If Laravel's broadcast was initiated by a specific client, Laravel will have sent the socketId of that connection along with the payload. If that's the case we are going to send a message to all connected clients except that one where the message originated.

And that's how the HTTP side works!

What if you don't want to use the Pusher protocol? #

While our package's main purpose is to make the usage of either the Pusher Javascript client or Laravel Echo as easy as possible, you are not limited to the Pusher protocol at all. There might be situations where all you need is a simple, bare-bone, websocket server where you want to have full control over the incoming payload and what you want to do with it - without having "channels" in the way.

You can easily create your own custom WebSocketHandler class. All you need to do is implement Ratchets Ratchet\WebSocket\MessageComponentInterface.

Once implemented, you will have a class that looks something like this:

namespace App;

use Ratchet\ConnectionInterface;
use Ratchet\RFC6455\Messaging\MessageInterface;
use Ratchet\WebSocket\MessageComponentInterface;

class MyCustomWebSocketHandler implements MessageComponentInterface
{

    public function onOpen(ConnectionInterface $connection)
    {
        // TODO: Implement onOpen() method.
    }
    
    public function onClose(ConnectionInterface $connection)
    {
        // TODO: Implement onClose() method.
    }

    public function onError(ConnectionInterface $connection, \Exception $e)
    {
        // TODO: Implement onError() method.
    }

    public function onMessage(ConnectionInterface $connection, MessageInterface $msg)
    {
        // TODO: Implement onMessage() method.
    }
}

In the class itself you have full control over all the lifecycle events of your WebSocket connections and can intercept the incoming messages and react to them.

The only part missing is, that you will need to tell our WebSocket server to load this handler at a specific route endpoint. This can be achieved using the WebSocketsRouter facade.

This class takes care of registering the routes with the actual webSocket server. You can use the websocket method to define a custom WebSocket endpoint. The method needs two arguments: the path where the WebSocket handled should be available and the fully qualified classname of the WebSocket handler class.

This could, for example, be done inside your routes/web.php file.

WebSocketsRouter::webSocket('/my-websocket', \App\MyCustomWebSocketHandler::class);

Once you've added the custom WebSocket route, be sure to restart our WebSockets server for the changes to take place.

The debug dashboard #

One of the many great features of Pusher is the “Debug Console”. A dashboard that allows you to see all WebSocket connections, events and API requests as they happen in real-time. We wanted to bring this feature to our package as well. That’s why it also contains a debug dashboard with the same features as Pusher.

This is what it looks like: The debug dashboard

The default location of the WebSocket dashboard is at /laravel-websockets. The routes get automatically registered. If you want to change the URL of the dashboard, you can configure it with the path setting in your config/websockets.php file.

To access the debug dashboard, you can visit the dashboard URL of your Laravel project in the browser. Since your WebSocket server has support for multiple apps, you can select which app you want to connect to and inspect.

By pressing the "Connect" button, you can establish the WebSocket connection and see all events taking place on your WebSocket server from there on in real-time.

By default, access to the WebSocket dashboard is only allowed while your application environment is set to local.

However, you can change this behavior by overriding the Laravel Gate being used. A good place for this is the AuthServiceProvider that ships with Laravel.

public function boot()
{
    $this->registerPolicies();

    Gate::define('viewWebSocketsDashboard', function ($user = null) {
        return in_array([
        	// 
        ], $user->email);
    });
}

If you quickly want to try out an event you can also use the debug dashboard to send out an event to a specific channel.

Simply enter the channel, the event name and provide a valid JSON payload to send it to all connected clients in the given channel.

Another feature that you can find on the debug dashboard is the ability to see key metrics of your WebSocket server in real-time.

Under the hood, the WebSocket server will store a snapshot of the current number of peak connections, the amount of received WebSocket messages and the amount of received API messages defined in a fixed interval. The default setting is to store a snapshot every 60 seconds - but you can change this interval in the configuration file as well.

Real-Time statistics on the dashboard

Does it scale? #

This is not a question with an easy answer as your mileage may vary. But with the appropriate server-side configuration your WebSocket server can easily hold a lot of concurrent connections.

This is an example benchmark that was done on the smallest Digital Ocean droplet, that also had a couple of other Laravel projects running. On this specific server, the maximum number of concurrent connections ended up being ~15,000.

Graph

Here is another benchmark that was run on a 2GB Digital Ocean droplet with 2 CPUs. The maximum number of concurrent connections on this server setup is nearly 60,000.

Graph

Pair programming is fun! #

We really had fun creating this package. The idea was born in a conversion about the Dusk Dashboard the Marcel was making. That dashboard uses webSockets. We both found that there had to be an easier way to work with webSockets. We wanted to make something like Route::websocket just work.

Things went fast from there. Marcel already had some experience with Ratchet from building the Dusk dashboard so he created a proof of concept that demonstrated that we could, in theory, replace all functionalities provided by Pusher. Freek did some polishing of the code and implemented some minor features. Meanwhile, Marcel also added the cool debug dashboard and worked on the docs.

On a couple of evenings, we used Slack to share screens and programmed together. The multi-tenancy support and some functionality around the pusher messages was done via pair programming. It was really a good experience for us both.

When the package was feature complete we stayed in touch and daily polished both the package and the docs together. Near the end, a big refactoring was done. A lot of the namespaces and directory structure was changed to make the entire package more clear.

While writing the blog post we went through all the code again and still polished some code and even added some minor features. We shortly considered creating an organization named beyond-spatie to put the package in, but decided it would be better if it was on an existing organization and picked beyondcode, Marcel's company.

We've put a lot of love in the package and we're proud of the work we've done together.

In closing #

If you read all of the above, congrats, we know that it's quite a lot to take in. Even though a lot happens behind the screen we think the package is easy to use. If you want to know more about the package be sure to read the extensive documentation we wrote. Head over to this repo on GitHub to read the source code.

If you're afraid to use it in production, don't be! As a matter of fact, the package is already running a week on Oh Dear!, Freek's monitoring SaaS. The service makes extensive use of WebSockets to make each screen display real-time info. All of this is backed by our laravel-websockets package, where it handles 50000+ broadcasted events daily.

Even our own docs uses WebSockets backed by our package. In the right top corner of each page, you can see how many other users are also reading the docs. If you click that bell it'll not only animate on in your browsers, it'll animate for all other readers too. Fun!

If you want to run the package in production, be sure to check out the sections on how to run it using SSL, and some deployment gotchas.

We both had a lot of prior experience writing packages. If you like laravel-websockets be sure to check out our other work as well. You'll find a list of Marcel's previous packages on Beyondcode's organization on GitHub. You'll find the packages that Freek and his team have created in the open source section of the Spatie website.