This is a simple project to demonstrate how to build realtime applications using Laravel and Pusher or SocketIO. This is a chatting app that performs the following in real-time
- Notify connected users when a user comes online
- Broadcast message to other users
- Show who is typing
- Installation and Setup
- Running Application
- Diving Deep
Clone this repository by running
$ git clone https://github.com/NtimYeboah/laravel-chatroom.git
Install the packages by running the composer install command
$ composer install
Install JavaScript dependencies
$ npm install
Set your database credentials in the .env
file
Run the migrations
$ php artisan migrate
Pusher is a hosted service that makes it super-easy to add real-time data and functionality to web and mobile applications. Since we will be using Pusher, make sure to sign up and get your app credentials. Set the Pusher app credentials in the .env
as follows
PUSHER_APP_ID=your-pusher-app-id
PUSHER_APP_KEY=your-pusher-app-key
PUSHER_APP_SECRET=your-pusher-app-secret
PUSHER_APP_CLUSTER=mt1
Set the BROADCAST_DRIVER
variable in the .env
file to pusher
BROADCAST_DRIVER=pusher
SocketIO enables realtime, bi-directional communication between web clients and servers. If you opt to use SocketIO, you need to install a Socket.io server. You can find a Socket.io server which is bundled inside laravel-echo-server . Make sure you meet the system requirements.
Install the package globally with the following command:
$ npm install -g laravel-echo-server
Run the init command in your project directory to setup laravel-echo-server.json configuration file to manage the configuration of your server.
$ laravel-echo-server init
Answer accordingly to generate the file.
Start the server in the root of your project directory
$ laravel-echo-server start
Set the BROADCAST_DRIVER
variable in the .env
file to redis
BROADCAST_DRIVER=redis
Since Laravel event broadcasting is done via queued jobs, we need to configure and run a queue listener. If you are not comfortable with Laravel queues, make sure to look at this repository to learn more. To configure the queue to user redis as the queue driver, set the credentials in the .env
file.
REDIS_HOST=your-redis-host
REDIS_PASSWORD=your-redis-password
REDIS_PORT=6379
Then set the QUEUE_DRIVER
to redis
QUEUE_DRIVER=redis
Start the queue.
$ php artisan queue:work redis
Visit the APP_URL
you set in the .env
file to see your Laravel application. Register and create a new chat room to get started.
Open up another browser, register a new user and join the chat room created. Notice the new user is added to the list of online users.
Send a message. Notice the message is added to the list of the messages in thhe other browser in realtime.
Start tying a message in one browser. Notice a typing indicator is shown below the textarea of the other browser. This makes use of client events. Be sure to enable client events when using Pusher.
This section takes a deep dive into how each of the features are implemented.
To implement this feature, a user has to create a chat room and the other users will join. When a user joins a room, his presence will be broadcasted to the other online users.
A user can create several chat rooms, and a user can join any number of chat rooms. Therefore the relationshhip between chat rooms and users is a many-to-many
. However, we will need a migration for users
, rooms
and room_user
tables.
We won't make any modifications to the default users
migration that comes with Laravel.
A room have a name and a description.
...
$table->string('name');
$table->string('description');
...
The room_user
table is the intermediary table between users
and rooms
.
...
$table->bigInteger('room_id');
$table->bigInteger('user_id');
...
The model defines the relationship and methods for adding a room, joining a room, leaving a room and checking if a user has joined a room.
This defines a many-to-many
relationship between user
and room
, a method for adding a room and another method for checking if a user has joined a room.
https://github.com/NtimYeboah/laravel-chatroom/app/User.php
Rooms relationship
Use belongsToMany
method to define the many-to-many
relationship.
...
/**
* The rooms that this user belongs to
*/
public function rooms()
{
return $this->belongsToMany(Room::class, 'room_user')
->withTimestamps();
}
...
Adding a room to user's room list
Use the attach
method on the query builder to insert into the intermediary table after creating a room.
...
/**
* Add a new room
*
* @param \App\Room $room
*/
public function addRoom($room)
{
return $this->rooms()->attach($room);
}
...
Check if a user has joined a room
Check if room exists on the list of user's rooms by using the where
eloquent method
...
/**
* Check if user has joined room
*
* @param mixed $roomId
*
* @return bool
*/
public function hasJoined($roomId)
{
$room = $this->rooms->where('id', $roomId)->first();
return $room ? true : false;
}
This defines a many-to-many
relationship between room
and user
, a method for adding a user to a room, a method for joining a room and another for leaving a room.
https://github.com/NtimYeboah/laravel-chatroom/app/Room.php
Users relationship
Use belongsToMany
method to define the many-to-many
relationship.
...
/**
* The rooms that belongs to the user
*/
public function users()
{
return $this->belongsToMany(User::class, 'room_user')
->withTimestamps();
}
...
Joining a room
Use the attach
method on the query builder to insert into the intermediary table.
...
/**
* Join a chat room
*
* @param \App\User $user
*/
public function join($user)
{
return $this->users()->attach($user);
}
...
Leaving a room
Use the detach
method on the query builder to remove a user from the intermediary table.
...
/**
* Leave a chat room
*
* @param \App\User $user
*/
public function leave($user)
{
return $this->users()->detach($user);
}
...
There is a route for listing rooms, showing a room, showing a form for creating room, storing a room and joining a room.
https://github.com/NtimYeboah/laravel-chatroom/routes/web.php
...
Route::group(['prefix' => 'rooms', 'as' => 'rooms.', 'middleware' => ['auth']], function () {
Route::get('', ['as' => 'index', 'uses' => 'RoomsController@index']);
Route::get('create', ['as' => 'create', 'uses' => 'RoomsController@create']);
Route::post('store', ['as' => 'store', 'uses' => 'RoomsController@store']);
Route::get('{room}', ['as' => 'show', 'uses' => 'RoomsController@show']);
Route::post('{room}/join', ['as' => 'join', 'uses' => 'RoomsController@join']);
});
...
The RoomsController
has methods for showing a list of rooms, showing a room, showing a form for creating a room, storing a room and joining a room.
https://github.com/NtimYeboah/laravel-chatroom/app/Http/Controllers/RoomsController.php
A room is shown with its users
...
/**
* Display a listing of the chat rooms.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$rooms = Room::with('users')->paginate();
return view('rooms.index', compact('rooms'));
}
...
The form for creating a room can be found in rooms/create.blade.php
directory
...
/**
* Show the form for creating a chat room.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
$room = app(Room::class);
return view('rooms.create', compact('room'));
}
...
When storing a room, we validate the request and try to save the room. After saving the room, we add it to the list of the user's rooms. If any exception happens, we log it and return back to the form.
...
/**
* Store a newly created room in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$this->validate($request, [
'name' => 'required'
]);
try {
$room = Room::create([
'name' => $request->get('name'),
'description' => $request->get('description')
]);
$request->user()->addRoom($room);
} catch (Exception $e) {
Log::error('Exception while creating a chatroom', [
'file' => $e->getFile(),
'code' => $e->getCode(),
'message' => $e->getMessage(),
]);
return back()->withInput();
}
return redirect()->route('rooms.index');
}
...
We load the messages when showing a room
...
/**
* Show room with messages
*
* @param mixed $room
*/
public function show(Room $room)
{
$room = $room->load('messages');
return view('rooms.show', compact('room'));
}
...
When joining a room, we make use of the join
method defined in the Room model and then emit the RoomJoined
event.
...
/**
* Allow user to join chat room
*
* @param Room $room
* @param \Illuminate\Http\Request $request
*/
public function join(Room $room, Request $request)
{
try {
$room->join($request->user());
event(new RoomJoined($request->user(), $room));
} catch (Exception $e) {
Log::error('Exception while joining a chat room', [
'file' => $e->getFile(),
'code' => $e->getCode(),
'message' => $e->getMessage(),
]);
return back();
}
return redirect()->route('rooms.show', ['room' => $room->id]);
}
...
The RoomJoined
event is emitted when a user joins a room. The event carries the data that will be sent to the connected browsers via Pusher or Socket.io. The RoomJoined
event implements the ShouldBroadcast
interface. It defines the queue the event will be placed on, for this event, it will be placed on events:room-joined
. It also defines the channel the event will be broadcasted on. The name of the channel is room.{roomId}
.
https://github.com/NtimYeboah/laravel-chatroom/app/Events/RoomJoined.php
...
use use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class RoomJoined implements ShouldBroadcast
{
...
...
/**
* The name of the queue on which to place the event
*/
public $broadcastQueue = 'events:room-joined';
...
...
use Illuminate\Broadcasting\PresenceChannel;
.
.
.
/**
* Get the channels the event should broadcast on.
*
* @return \Illuminate\Broadcasting\Channel|array
*/
public function broadcastOn()
{
return new PresenceChannel('room.' . $this->room->id);
}
...
Since RoomJoined
event defines a Presence Channel, we need to return some data about the user when authorizing the channel. When authorizing the channel, we use the hasJoined
methods to determine if the user has joined the room or not.
https://github.com/NtimYeboah/laravel-chatroom/routes/channel.php
...
**
* Authorize room.{roomId} channel
* for authenticated users
*/
Broadcast::channel('room.{roomId}', function ($user, $roomId) {
if ($user->hasJoined($roomId)) {
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email
];
}
});
...
For unit, we can test that a user can add a chat room, if a user has joined a chat room and a user able to join a chat room.
For feature, we can test if the RoomJoined
event will be broadcasted when a user joins a room and if the MessageCreated
event will be broadcasted when a user sends a message.
For UserTest
, we will test if a user can add a chat room and if a user can join a chat room.
https://github.com/NtimYeboah/laravel-chatroom/tests/Unit/UserTest.php
Test can add room
...
public function testCanAddRoom()
{
$this->user->addRoom($this->room);
$found = $this->user->rooms->where('id', $this->room->id)->first();
$this->assertInstanceOf(Room::class, $found);
$this->assertEquals($this->room->id, $found->id);
}
...
Test user has joined Room
...
public function testUserHasJoinedRoom()
{
$this->room->join($this->user);
$this->assertTrue($this->user->hasJoined($this->room->id));
}
...
For RoomTest
, we will test if a user can join a room.
https://github.com/NtimYeboah/laravel-chatroom/tests/Unit/RoomTest.php
Test user can join room
...
public function testUserCanJoinRoom()
{
$user = factory(User::class)->create();
$room = factory(Room::class)->create();
$room->join($user);
$found = $room->users->where('id', $user->id)->first();
$this->assertInstanceOf(User::class, $found);
$this->assertEquals($user->id, $found->id);
}
...
For RoomTest
, we will test if the RoomJoined
event will be broadcasted when a user joins a room.
https://github.com/NtimYeboah/laravel-chatroom/tests/Feature/RoomTest.php
Test can broadcast chat room joined event.
...
public function testCanBroadcastRoomJoinedEvent()
{
Event::fake();
$user = factory(User::class)->create();
$room = factory(Room::class)->create();
$response = $this->actingAs($user)
->post('rooms/' . $room->id . '/join');
Event::assertDispatched(RoomJoined::class, function($e) use ($user, $room) {
return $e->user->id === $user->id &&
$e->room->id === $room->id;
});
}
...
For MessageTest
, we will test if the MessageCreated
event will be broadcasted when a user sends a message.
https://github.com/NtimYeboah/laravel-chatroom/tests/Feature/MessageTest.php
Test can broadcast message created event
...
public function testCanBroadcastMessage()
{
Event::fake();
$user = factory(User::class)->create();
$room = factory(Room::class)->create();
$response = $this->actingAs($user)
->post('messages/store', [
'body' => 'Hi, there',
'room_id' => $room->id
]);
$message = Message::first();
Event::assertDispatched(MessageCreated::class, function ($e) use($message, $room) {
return $e->message->id === $message->id &&
$e->room->id === $room->id;
});
}
...
We show messages in realtime to other connected clients when a user sends a message.
A user can send many messages, therefore the relationship between users and messages is a one-to-many
.
A message will have a body, the sender, and the room
...
$table->longText('body');
$table->bigInteger('user_id')->index();
$table->bigInteger('room_id')->index();
...
The models define the relationship between a user and messages.
This defines the hasMany
relationship between user and messages.
https://github.com/NtimYeboah/laravel-chatroom/app/User.php
Messages relationship
...
/**
* Define messages relation
*
* @return mixed
*/
public function messages()
{
return $this->hasMany(Message::class, 'user_id');
}
...
This defines the reverse relationship between message and user which is belongsTo
.
User relationship
...
/**
* Define user relationship
*
* @return mixed
*/
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
...
There is only one route for storing a message
https://github.com/NtimYeboah/laravel-chatroom/routes/web.php
...
Route::post('messages/store', ['as' => 'messages.store', 'uses' => 'MessagesController@store']);
The MessagesController
has a method for storing a message.
https://github.com/NtimYeboah/laravel-chatroom/app/Http/Controllers/MessagesController.php
We validate the request and then store the message. After storing the message, we broadcast an event to the other connected to show the message.
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
try {
$message = Message::create([
'body' => $request->get('body'),
'user_id' => $request->user()->id,
'room_id' => $request->get('room_id')
]);
broadcast(new MessageCreated($message->load('user')))->toOthers();
} catch (Exception $e) {
Log::error('Error occurred whiles creating a message', [
'file' => $e->getFile(),
'code' => $e->getCode(),
'message' => $e->getMessage(),
]);
return response()->json([
'msg' => 'Error creating message',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return response()->json([
'msg' => 'Message created'
], Response::HTTP_CREATED);
}
After a message is stored, the MessageCreated
event is broadcasted to the other connected clients. The event is queued on the events:message-created
queue and authenticated on the room.{roomId}
channel.
Since the message is broadcasted to the other connected clients and the sender is excluded, we need to use broadcast
function with the toOthers
function. Also we need to use the InteractsWithSockets
trait in the event.
/**
* The queue on which to broadcast the event
*/
public $broadcastQueue = 'events:message-created';
.
.
.
/**
* Get the channels the event should broadcast on.
*
* @return \Illuminate\Broadcasting\Channel|array
*/
public function broadcastOn()
{
return new PresenceChannel('room.'. $this->message->room_id);
}
For this feature, we make use of client events. The event will be broadcasted without going the Laravel application.
To broadcast the event, we use Laravel Echo's whisper
method. To listen to the event, we use the listenForWhisper
method.
https://github.com/NtimYeboah/laravel-chatroom/resources/assets/js/app.js
We broadcast typing
event when a user begins to type along with the name of that user.
...
const whisper = function () {
setTimeout(function() {
Echo.private('message')
.whisper('typing', {
name: authUserName
});
}, 300);
}
...
Let's listen for the typing
event and prepend the name of the user to the is typing...
string.
...
const listenForWhisper = function () {
Echo.private('message')
.listenForWhisper('typing', (e) => {
$(selectors.whisperTyping).text(`${e.name} is typing...`);
setTimeout(function () {
$(selectors.whisperTyping).text('');
}, 900);
});
}
...
We need to authorize a private channel for client events. We will use a channel with name message
and authorize it for authenticated users.
https://github.com/NtimYeboah/laravel-chatroom/routes/channel.php
...
Broadcast::channel('message', function ($user) {
return Auth::check();
});
...