/laravel-chatroom

Learn how to build real-time web applications with Laravel

Primary LanguagePHP

Learn how to build realtime applications in Laravel

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

  1. Notify connected users when a user comes online
  2. Broadcast message to other users
  3. Show who is typing

Table of contents

Installation and Setup

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

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

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 Server

Install the package globally with the following command:

$ npm install -g laravel-echo-server

Setup configuration file

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.

Run server

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

Broadcasting Events

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

Running Application

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.

Notify connected users

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.

Update messages

Send a message. Notice the message is added to the list of the messages in thhe other browser in realtime.

Show who is typing

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.

Diving Deep

This section takes a deep dive into how each of the features are implemented.

Notify connected users when a user comes online

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.

Migrations

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.

User migration

We won't make any modifications to the default users migration that comes with Laravel.

Room migration

A room have a name and a description.

https://github.com/NtimYeboah/laravel-chatroom/database/migrations/2018_06_09_080445_create_rooms_table.php

...

$table->string('name');
$table->string('description');

...
Room User migration

The room_user table is the intermediary table between users and rooms.

https://github.com/NtimYeboah/laravel-chatroom/database/migrations/2018_06_09_211834_create_room_user_table.php

...

$table->bigInteger('room_id');
$table->bigInteger('user_id');

...

Models

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.

User Model

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;
}
Room model

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);
}
...

Routes

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']);
});

...

Controller

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

Show list of rooms

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'));
}

...
Show form to create a room

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'));
}

...
Store 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');  
}
...
Show a room

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'));
}
...
Join a 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]);
}
...

Event

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

Implement shouldBroadcast interface
...
use use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class RoomJoined implements ShouldBroadcast 
{

...
Define queue
...
/**
 * The name of the queue on which to place the event
 */
public $broadcastQueue = 'events:room-joined';

...
Define channel
...
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);
}
...

Channel

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

Authorizing channel
...
**
 * 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
        ];
    }
});
...

Tests

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.

Unit

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);  
}

...
Feature

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;
    });
}

...

Broadcast message to other users

We show messages in realtime to other connected clients when a user sends a message.

Migrations

A user can send many messages, therefore the relationship between users and messages is a one-to-many.

Messages migration

A message will have a body, the sender, and the room

https://github.com/NtimYeboah/laravel-chatroom/database/migrations/2018_06_09_110334_create_messages_table.php

...

$table->longText('body');
$table->bigInteger('user_id')->index();
$table->bigInteger('room_id')->index();

...
Models

The models define the relationship between a user and messages.

User model

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');
}
...
Message model

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');
}
...

Routes

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']);

Controller

The MessagesController has a method for storing a message.

https://github.com/NtimYeboah/laravel-chatroom/app/Http/Controllers/MessagesController.php

Store a message

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);   
}
Event

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);
}

Show who is typing.

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

Broadcast typing event

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);  
}
...

Listen to typing event

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);
        });
}
...

Define message channel

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();
});
...