Real-time chat with Laravel and Pusher

Real-time chat with Laravel and Pusher Image

If you want to build the next Slack competitor, or more humbly broadcast events to multiple users, and possibly to another application like a React Native app, you may need to consider using Pusher.

Pusher is a very handy service for pub/sub and frees you from having to maintain your own WebSocket server, compared to other broadcasting drivers like Redis or SNS.

In this article, I am going to show how to publish conversation messages to Pusher and subscribe to these messages in a secure way on the frontend using private channels.

Introduction

We are going to assume that our Laravel app is just a REST API, with Passport authentication, and that we want to implement the frontend of the chat in an external JS application.

First, we are going to install the Pusher SDK using $ composer require pusher/pusher-php-server "~3.0" and configure it in config/broadcasting.php.

Now let's suppose that we have the following Conversation and Message models:

<?php

namespace App\Models;

use App\User;
use Illuminate\Database\Eloquent\Model;

class Conversation extends Model
{
    /**
     * Users associated to this conversation.
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
     */
    public function users()
    {
        return $this->belongsToMany(User::class);
    }

    /**
     * Messages in this conversation.
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function messages()
    {
        return $this->hasMany(Message::class);
    }
}

The Message model:

<?php

namespace App\Models;

use App\User;
use Illuminate\Database\Eloquent\Model;

class Message extends Model
{
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'conversation_id',
        'sender_id',
        'content',
    ];

    /**
     * Conversation associated to this message.
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function conversation()
    {
        return $this->belongsTo(Conversation::class);
    }

    /**
     * Sender of the message.
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function sender()
    {
        return $this->belongsTo(User::class);
    }
}

Somewhere in your backend code, you are going to handle the storage of conversation messages.

When a new message is added to a conversation, you are going to dispatch an event:

event(new MessageWasPosted($message));

Private Channels

The goal of the application is to broadcast conversation messages to the frontend app, in a secure way.

We don't want other users to be able to listen to messages from other conversations, so we are going to broadcast the message events to a private notification channel.

Each conversation will have it's dedicated channel called private-conversation.{id}.

The private- prefix is a convention to name private channels

In these channels, we are going to broadcast a message.posted event when a new message is posted to a conversation, so our frontend can listen to them and update the UI accordingly.

Backend

For this, we are going to implement our MessagePosted event that is triggered each time a new message is posted:

// /app/Events/MessageWasPosted.php

<?php

namespace App\Events;

use App\Models\Message;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class MessageWasPosted implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * The message.
     *
     * @var Message
     */
    public $message;

    /**
     * Constructor.
     *
     * @param Message $message
     */
    public function __construct(Message $message)
    {
        $this->message = $message;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return PrivateChannel
     */
    public function broadcastOn()
    {
        return new PrivateChannel('conversation.'.$this->message->conversation->id);
    }

    /**
     * The event's broadcast name.
     *
     * @return string
     */
    public function broadcastAs()
    {
        return 'message.posted';
    }
}

We also need to specify who can join this channel, in our case, only users belonging to the conversation.

For this, we are going to create a custom Channel class and implement the join() method:

// /app/Broadcasting/ConversationChannel.php

<?php

namespace App\Broadcasting;

use App\Models\Conversation;
use App\User;

class ConversationChannel
{
    /**
     * Authenticate the user's access to the channel.
     *
     * @param User         $user
     * @param Conversation $conversation
     *
     * @return array|bool
     */
    public function join(User $user, Conversation $conversation)
    {
        return $conversation->users->contains($user);
    }
}

Once that's done, we need to create our broadcasting channel route, using the new channel class.

// /routes/channels.php
<?php

use App\Broadcasting\ConversationChannel;

Broadcast::channel('conversation.{conversation}', ConversationChannel::class);

Brodcasting to private channels requires authentication, but thanks to the Laravel broadcasting package, the authentication controller is already handled for us.

We just need to set up these routes in the service provider:

// /app/Providers/BroadcastServiceProvider.php

Broadcast::routes(['middleware' => ['auth:api']]);

Note that I am using the auth:api middleware here, because this is the middleware I use to authenticate API users.

Using private channels requires authentication, so you need to use the appropriate middleware depending on the authentication method you use for your API.

If your web app is not on the same domain, for instance a React Native app, you need to setup a CORS middleware for these routes.

You can install https://github.com/barryvdh/laravel-cors and update your $routeMiddleware by adding:

// /app/Http/Kernel.php

'cors' => \Barryvdh\Cors\HandleCors::class,

Then, update the broadcasting routes to use the new middleware:

// /app/Providers/BroadcastServiceProvider.php

Broadcast::routes(['middleware' => ['auth:api', 'cors']]);

If the frontend app is on a different domain, you may also not have access to CSRF tokens, so you will probably disable the CSRF check for the broadcast authentication route:

// /app/Http/Middleware/VerifyCsrfToken.php

/**
 * The URIs that should be excluded from CSRF verification.
 *
 * @var array
 */
protected $except = [
    'broadcasting/auth'
];

Frontend

On the frontend, we need to give Pusher the authentication endpoint using the authEndpoint configuration key and eventually add extra headers for the authentication.

For example, if you are using Passport, you will pass an Authorization header containing the token retrieved earlier during the authentication flow.

The id of the conversation will also be retrieved earlier, for example, when the user opens the chat box.

Our pseudo code to subscribe to the conversation messages becomes:

var pusher = new Pusher('*****', {
  authEndpoint: 'https://project.local/broadcasting/auth',
  cluster: 'us2',
  encrypted: true,
  auth: {
    headers: {
      Authorization: 'Bearer ****'
    }
  }
});

var channel = pusher.subscribe('private-conversation.1');

channel.bind('message.posted', function (data) {
  alert(JSON.stringify(data));
});

Now Pusher will use the auth endpoint to perform users authentication using the headers we configured.

Only users belonging to the conversation will be able to subscribe and receive events when a new message is posted.

That's it! You can now create more events and broadcast them to this channel, for example, when a message is deleted or when the user is typing.