Home
chat
tall-stack
websockets

Build a Chat Room using TALL Stack and WebSockets

Posted by Chris Mellor Chris Mellor on January 15th, 2023

This tutorial will help you build your own Chat Room. We will be using the TALL Stack (Tailwind CSS, AlpineJS, Laravel and Livewire) along with Echo that will use Pusher for some WebSocket goodness.

There is an assumption here that you’re familiar with these tools and have them installed.

While we're using Pusher in this tutorial, you're free to use any drive you desire. Check here for a list of supported drivers.

This tutorial assumes you are familiar with, or already have the client and server side tools installed and configured to get WebSockets working in your application. If not, please refer to the documentation to do this.

Scaffolding with Breeze

Ideally, you'll only want registered users to be able to use the application. If you're starting from scratch, I recommend installing Laravel Breeze which will give you everything you need to allow Users' to login.

If your app is already built, nothing further is required as you just need the User Model, which comes out of the box. This tutorial will use Blade components from the Breeze package though, so keep that in mind if following along.

Set-up Migrations

You will need a messages table to store the messages in. Create a new migration

1php artisan make:migration "create messages table"

and populate the migration:

1Schema::create('messages', function (Blueprint $table) {
2 $table->id();
3 $table->foreignIdFor(\App\Models\User::class)->constrained();
4 $table->string('message');
5 $table->timestamps();
6});

This shows that a Message is linked to a User.

Set-up Models and Relationships

All of the messages sent in the chat room will be stored in the messages table. Create a Message Model

1php artisan make:model Message

As we're linking a message to a User, add a One To Many relationship between the Message and User Models

1// App\Models\Message
2 
3public function user(): BelongsTo
4{
5 return $this->belongsTo(User::class);
6}
1// App\Models\User
2 
3public function messages(): HasMany
4{
5 return $this->hasMany(Message::class);
6}

Model Broadcasting

Normally, you might think that you need to create an Event which handles the Broadcasting -- and while you can do that, this tutorial will not be using Events to broadcast but instead using the Models to broadcast, using Model Broadcasting.

A quick summary of this feature

When sending a Message, you’d normally have an Event called MessageSent and dispatch that Event when sending the Message

1\App\Events\MessageSent::dispatch($message);

Then you would listen for that event in your JavaScript and perform some action

1Echo.channel('chat-room')
2 .listen('MessageSent', event => console.log(event));

Or with Livewire:

1protected $listeners = [
2 'echo:chat-room,MessageSent' => 'render',
3];

With Model Broadcasting, you can use the events of your Model to listen against, meaning you can eliminate the need for separate Event classes.

So for example: as you may know, a Model fires an Event based on an action it performs, like when you create a new Model, creating and created Events get fired, the same with updating, deleting etc. You can read more about Model Events and what Events get fired here.

How to use Model Broadcasting

The methods needed to use Model Broadcasting are basically the same as if you were putting them in an Event class, but you must add a Trait to get these working. In your App\Models\Message Model, add the Trait Illuminate\Database\Eloquent\BroadcastsEvents;

1<?php
2 
3namespace App\Models;
4 
5use Illuminate\Database\Eloquent\BroadcastsEvents;
6use Illuminate\Database\Eloquent\Model;
7use Illuminate\Database\Eloquent\Relations\BelongsTo;
8 
9class Message extends Model
10{
11 use BroadcastsEvents;
12 
13 public function user(): BelongsTo
14 {
15 return $this->belongsTo(User::class);
16 }
17}

Now you can define the broadcastOn method to give your channel a name. As we’re just building a simple chat room, we just need the one channel. Have a look at Model Broadcasting conventions to see about how to use your Model instances to create channels.

1public function broadcastOn($event): array
2{
3 return [new PresenceChannel(name: 'chat-room')];
4}

Private or Presence?

You’ll notice above it’s using a Presence channel. For a chat room, you’d want to use a Presence channel — it’s the same as a Private channel, except it comes with a few extra goodies like seeing when a User joins or leaves the channel and has Client Events which can be used for things like tracking if a User is typing or not. A Private channel would mostly be used for when you just want one-to-one communication.

We’ve given the Presence channel the simple name of chat-room — you can give it any name you desire, but it will be required later on.

By default, the Model will broadcast all of its data. You might not want this if you have a lot of data to send over. Luckily, you can customise the payload you sent over, just add a broadcastWith method to your Message Model

1public function broadcastWith(): array
2{
3 return [
4 'message' => $this,
5 'user' => $this->user->only('name'),
6 ];
7}

For out chat rooms’ sake, we’re only interested in the message and the User who sent it.

Now when you send a message, this is the payload that is sent to the Broadcast driver.

Authorising Presence Channels

Authorising a channel is the key to making Event Broadcasting work.

Make sure you have defined the authorisation routes correctly.

Within the routes folder, you’ll find a channels.php route file. Here you can define your Channel routes. A route is authorised when the callback defined returns true.

Channel Classes

You can put your authorisation logic in its own Channel Class. We’re gonna use this method moving forward.

More info on defining Channel Classes can be found here.

Create the class via the Artisan command:

1php artisan make:channel ChatChannel

Now change the route in channels.php

1use App\Broadcasting\ChatChannel;
2 
3Broadcast::channel('chat-room', ChatChannel::class);

Because we’re using a Presence channel, we do authorisation a bit differently than how we would with a Private channel. Instead of performing some logic to check if a User is authorised, we should just return an array of data about the User.

This data is then made available to the Presence channel’s Event Listeners, used on the client side. It is advised to still check for if a User is authorised or not, but instead of returning true, return false. If this is a bit confusing, consult the docs for more info.

1<?php
2 
3namespace App\Broadcasting;
4 
5class ChatChannel
6{
7 public function __construct()
8 {
9 }
10 
11 public function join(): array
12 {
13 if (! auth()->check()) {
14 return false;
15 }
16 
17 return [
18 'id' => auth()->id(),
19 'name' => auth()->user()->name,
20 ];
21 }
22}

Above, we’re checking if a User is authorised to join the channel, if not return false. We’re then returning the data of the User — in this case, just their ID and name, but you can pass any data.

Set-up Livewire Components

Lets create a Livewire component to build up the visual side of the chat room.

Create a new component — I’ve called it Chat.

1php artisan livewire:make Chat

This creates the class and the view for the component.

We’ll start with the Class configuration first.

Livewire Configuration

Add some properties to the class that we will utilise throughout.

1<?php
2 
3namespace App\Http\Livewire;
4 
5use App\Models\Message;
6use Illuminate\Contracts\View\View;
7use Illuminate\Support\Collection;
8use Illuminate\Support\Facades\Auth;
9use Livewire\Component;
10 
11class Chat extends Component
12{
13 public $message;
14 
15 public $usersOnline = [];
16 
17 public $userTyping;
18 
19 public function render(): View
20 {
21 return view('livewire.chat')
22 }
23}

$message is used to store the Message the User is sending in the chat room.

$usersOnline stores the Users who have joined the channel.

$userTyping stores the status if the User is typing or not.

Sending and Receiving Messages

We’ll add a couple of methods now to retrieve and send a message.

1use Illuminate\Support\Facades\Auth;
2 
3protected array $rules = [
4 'message' => ['required', 'string'],
5];
6 
7public function sendMessage(): void
8{
9 $this->validate();
10 
11 Auth::user()
12 ->messages()
13 ->create([
14 'message' => $$this->message,
15 ]);
16 
17 $this->message = '';
18}
19 
20public function getMessagesProperty(): array|Collection
21{
22 return Message::with('user')
23 ->latest()
24 ->get();
25}

The getMessagesProperty is a Computed Property which is an API offered by Livewire for accessing dynamic properties. Now you can use $this->messages to get the results.

You can then update the render method to pass this data to the view

1public function render(): View
2{
3 return view('livewire.chat')
4 ->with('messages', $this->messages);
5}

Echo Integration

In Livewire, to listen for Events, it’s super simple — no need to generate a Listener class, it is all done in the component.

You can read more about Event Listeners in Livewire here.

In the Chat component, add a $listener property:

1protected $listeners = [
2 //
3];

Using Echo with Livewire requires a special kind of syntax to be used. You can read more about Listeners in an Echo integration here.

Update the $listeners property

1protected $listeners = [
2 // Special Syntax:
3 // echo:{channel},{event}' => '{method}'
4 'echo-presence:chat-room,.MessageCreated' => 'render',
5];

and let’s break it down

  • echo-presence - tells Livewire it’s using a Presence channel. You would change to private if using that, or omit it completely if using a public channel.
  • chat-room - this is the name of the Channel you specified on the broadcastOn method in the Message Model class.
  • .MessageCreated - this is the Event you’re listening for. You’ll see here it is prefixed with a period. I will explain why this is important below.

Because we’re using Model Broadcasting, we haven’t actually specified an Event to use. This is because Model Broadcasting uses conventions to create the Events for us. I mentioned above about how the Events from a Model are used. In this scenario, we are creating a new Message Model, which means it emits the created event, and so Model Broadcasting will use conventions and creates the MessageCreated Event for us that we can listen for.

Model Broadcasts aren’t actually associated with a real Event, so to combat this you have to prefix the Event name with a . (period). You can read more about listening for Model Broadcasts here

In our example, we’re using the render method to be hit when a MessageCreated Event is emitted. All this does it re-render the component, giving the effect of real-time updating, which is what you’d want it a chat room after someone sends a Message.

Laravel Echo gives you three Events out of the box that you can subscribe to and listen for:

  • joining
  • leaving
  • here

Let’s add them to our $listeners property:

1protected $listeners = [
2 'echo-presence:chat-room,.MessageCreated' => 'render',
3 'echo-presence:chat-room,here' => 'here',
4 'echo-presence:chat-room,joining' => 'joining',
5 'echo-presence:chat-room,leaving' => 'leaving',
6];

Here, we’re calling a method with the same name as the Event, but you can use any method name you desire.

I’ll explain here what each method does

here

This Event is is executed as soon as the User joins the channel successfully and can retrieve the data of all the currently subscribed Users’ in the channel.

1public function here($users)
2{
3 $this->usersOnline = collect($users);
4}

The list of all the Users’ in the channel is stored in the $users variable returns as an array. In this scenario, we’re just wrapping the data into a Collection and storing it in the $usersOnline property of the component.

joining

This Event is executed once a new User joins the channel.

1public function joining($user)
2{
3 $this->usersOnline->push($user);
4}

We get an array of data with the single Users’ data, and we’re using it to push onto the $usersOnline property, which is now a Collection.

leaving

This Event is executed when a User leaves the channel.

1public function leaving($user)
2{
3 $this->usersOnline = $this->usersOnline->reject(
4 fn ($u) => $u['id'] === $user['id']
5 );
6}

Again we get the array of data of the User that left, and we remove it from the Collection of User data.

Listening for Client Events

These types of Events are a bit different and require using JavaScript to listen for them, but we can still add them as a Listener to perform some logic on them. We’ll come back to these shortly as we still need to emit them from somewhere.

A Client Event name can be anything you desire. For our case of checking if a User is typing or not, we’re going to call them typing and stoppedTyping. To listen for these, another special kind of syntax is needed in the $listeners property.

Similar to a Model Broadcast Event, it must be prefixed with a . (period) but then further prefixed with client- and then add your custom Event name after

1protected $listeners = [
2 'echo-presence:chat-room,.MessageCreated' => 'render',
3 'echo-presence:chat-room,here' => 'here',
4 'echo-presence:chat-room,joining' => 'joining',
5 'echo-presence:chat-room,leaving' => 'leaving',
6 'echo-presence:chat-room,.client-typing' => 'typing',
7 'echo-presence:chat-room,.client-stopped-typing' => 'stoppedTyping',
8];
1public function typing($event)
2{
3 $this->usersOnline->map(function ($user) use ($event): void {
4 if ($user['id'] === $event['id']) {
5 $user['typing'] = true;
6 
7 $this->userTyping = $user['id'];
8 }
9 });
10}
11 
12public function stoppedTyping($event)
13{
14 $this->usersOnline->map(function ($user) use ($event): void {
15 if ($user['id'] === $event['id']) {
16 unset($user['typing']);
17 
18 $this->userTyping = null;
19 }
20 });
21}

When a User is typing, the typing method is hit and looks for the User who is typing and adds more data implying the User is typing. The reverse is done with the stoppedTyping where it will remove that data.

Front-end and AlpineJS

Here is a super basic, ugly looking excuse for a chat room UI

1<div>
2 <x-app-layout>
3 <div>
4 <form
5 class="mt-4 mb-8 ml-12 space-x-2"
6 wire:submit.prevent="sendMessage"
7 >
8 <label for="message">
9 <input
10 autofocus
11 class="bg-gray-800"
12 id="message"
13 name="message"
14 type="text"
15 >
16 </label>
17 <button
18 class="py-2 px-3 text-white bg-blue-500 rounded-lg text-white bg-blue-900 hover:bg-blue-600"
19 type="submit"
20 >Send Message
21 </button>
22 
23 @error('message')
24 <span class="text-red-700">{{ $message }}</span>
25 @enderror
26 </form>
27 </div>
28 <div>
29 <div>
30 <h2 class="text-xl font-bold tracking-wider uppercase">Users online: <strong>{{ count($usersOnline) }}</strong></h2>
31 <ul>
32 @foreach ($usersOnline as $user)
33 <li>
34 {{ $user['name'] }}
35 
36 @if ($userTyping === $user['id'])
37 <span class="text-sm"> is typing...</span>
38 @endif
39 </li>
40 @endforeach
41 </ul>
42 </div>
43 </div>
44 
45 <div>
46 <ul class="ml-12 space-y-4">
47 @foreach ($messages as $message)
48 <li>
49 <span class="block font-bold text-green-700">{{ $message->user->name }}</span>
50 <span class="block">
51 {!! $message->message !!}
52 </span>
53 </li>
54 @endforeach
55 </ul>
56 </div>
57 </x-app-layout>
58</div>

Lets add some interactivity to it with AlpineJS.

We’ve already added a wire:submit directive to send the Message when a User has typed it into the field (assuming it passes validation).

I’m a fan of keeping the JS separate from the HTML, so not filling the HTML with a load of code and keeping it looking clean. Alpine lets you do this by utilising what it calls Globals, specifically the one we’re using is the data() Global, which is just a way to re-use it easier in the app.

Start by adding this to the end of the view:

1<script>
2 window.addEventListener("alpine:init", () => {
3 Alpine.data("chat", () => ({
4 {{----}}
5 }));
6 });
7</script>

Here we’re waiting for Alpine to initialise then passing everything we want to do in to a component called chat.

Normally you might add this into the x-data attribute in your HTML, but now you can just define the directory directly onto it.

Replace the opening div in the HTML with:

1<div x-data="chat">

You can read more about extracting properties and methods into a component here.

Now we’ll add some props and methods.

We need to get the message that the User is typing into the input field. We’re gonna use data binding to get this value so we can manipulate it.

Update the HTML to add it to the input

1<input
2 autofocus
3 class="bg-gray-800"
4 id="message"
5 name="message"
6 type="text"
7 wire:model="message"
8>

Because we’re using Livewire to bind the data, we have to share the state between Livewire and Alpine to get the data. Livewire allows us to do this with a feature called @entangle, which means when the $message property is updated, Alpine also knows it has been changed and can use that exact data. You can find more info about this here.

Update the Alpine component:

1window.addEventListener("alpine:init", () => {
2 Alpine.data("chat", () => ({
3 message: @entangle('message'),
4 }));
5});

Now lets circle back to the Client Events and see how we need to call these to determine if a User is typing.

Alpine offers an init() method which will be executed before the component is fully rendered. More info about this can be found here.

We’re also going to use what Alpine calls a “magic method” — $watch — this will watch a property for changes and execute a callback when it is detected.

We’re going to watch the message property. If there is text in the field (indicating a User is typing) then we perform an action. And vice-versa when the field is empty.

Update the chat component:

1window.addEventListener("alpine:init", () => {
2 Alpine.data("chat", () => ({
3 message: @entangle('message'),
4 
5 init() {
6 this.$watch("message", value => {
7 const whisperEventName = value === "" ? "stopped-typing" : "typing";
8 
9 Echo.join("chat-room").whisper(whisperEventName, {
10 id: {{ auth()->id() }}
11 })
12 });
13 }
14 }));
15});

Here, we’re watching for changes on the message property and assigning the value to a constant. If the message is empty, the const equals stopped-typing and if the value has content, it equals typing, indicating a User is typing.

Laravel Echo uses a whisper method to broadcast client events. We’re using the two custom Event names we added earlier and passing along an ID with the value of the logged in Users’ ID. We can use this to indicate that the User is typing.

The Event listeners we added earlier in the Livewire component will now pick this up and run the methods given to it.

Calling JavaScript with Livewire

One final thing I want to show off.

With the UI I’ve presented above, you would have to scroll down the page to view the newest messages (I know you could just reverse the message order, but that makes this section irrelevant)

In Livewire, you can actually emit JavaScript to run by emitting an Event and listening for that Event in the JavaScript code. The example here is that when a User sends a Message, the page will scroll down to the bottom of the viewport.

On the Chat Livewire component, just emit an Event in the code, the Event name can be anything

1public function sendMessage(): void
2{
3 $this->validate();
4 
5 Auth::user()
6 ->messages()
7 ->create([
8 'message' => $this->transform($this->message),
9 ]);
10 
11 $this->emitSelf('scrollToBottom');
12 
13 $this->message = '';
14}

Now in the view, within the chat component, add this:

1<script>
2 window.addEventListener("alpine:init", () => {
3 Alpine.data("chat", () => ({
4 message: @entangle('message'),
5 
6 init() {
7 this.$watch("message", value => {
8 const whisperEventName = value === "" ? "stopped-typing" : "typing";
9 
10 Echo.join("chat-room").whisper(whisperEventName, {
11 id: {{ auth()->id() }}
12 })
13 });
14 
15 @this.on("scrollToBottom", () => {
16 window.scrollTo({
17 top: document.body.scrollHeight,
18 behavior: "smooth"
19 });
20 });
21 },
22 }));
23 });
24</script>

The “magic” here is the @this.on() method that is listening for that emitted Event and performing the JS logic, which in this case is just scrolling to the end of the viewport. You can find out more about this feature here.

If you have got this far, thanks for taking the time to read this and I hope you find some use out of it.

You can find me on Twitter @cmellor

+