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\Message2 3public function user(): BelongsTo4{5 return $this->belongsTo(User::class);6}
1// App\Models\User2 3public function messages(): HasMany4{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 Model10{11 use BroadcastsEvents; 12 13 public function user(): BelongsTo14 {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): array2{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(): array2{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(): array12 {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 Component12{13 public $message;14 15 public $usersOnline = [];16 17 public $userTyping;18 19 public function render(): View20 {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|Collection21{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(): View2{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 toprivate
if using that, or omit it completely if using a public channel. -
chat-room
- this is the name of the Channel you specified on thebroadcastOn
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 <input10 autofocus11 class="bg-gray-800"12 id="message"13 name="message"14 type="text"15 >16 </label>17 <button18 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 Message21 </button>22 23 @error('message')24 <span class="text-red-700">{{ $message }}</span>25 @enderror26 </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 @endif39 </li>40 @endforeach41 </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 @endforeach55 </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<input2 autofocus3 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