Group chat can get a bit messy, especially if it’s public and anyone can join. Making a group chat invite-only can help manage the content of the group and the sanity of those in it, in addition to preventing spam users and bots.
In this tutorial, we’ll be building an invite-only chat room where only admin users can invite other users to join a chat room. Upon joining the chat room, users will be able to chat with other users in realtime.
The complete code for this tutorial is available on GitHub.
Prerequisites
To complete this tutorial, you'll need the following tech:
- PHP
- Node.js (preferably the latest – 13.x)
- Composer
- Yarn
- Laravel installer
- A Stream Chat Account
To follow along with this tutorial, a basic understanding of Laravel, Vue.js, and JavaScript would also be helpful.
Getting Our Stream Chat Keys
To use Stream Chat in our app, we need to grab our Stream Chat API keys. Stream Chat allows you to build a fully-functional chat app in hours, instead of weeks, or even months; we'll use it to build our simple example app in minutes! To get started log in to Stream or create an account. Upon successfully entering your credentials, you'll be delivered to your dashboard, where you can create a new app by clicking the Create App button:
Once that is done, you should see an API KEY and SECRET for your newly created application:
Take note of these keys as we’ll be needing them shortly.
Getting Started
We’ll be using the Laravel installer to create a new Laravel application. Run the following command to create a new Laravel application with authentication scaffolding:
$ laravel new stream-invite-only-group-chat --auth
Once that’s done, let’s install the npm dependencies:
$ cd stream-invite-only-group-chat $ yarn install
Now, we can begin building out our application!
Building the Application
We’ll start by updating the users
table migration in our create_users_table.php
file, within database/migrations
, as shown below:
Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('username'); $table->string('email')->unique(); $table->boolean('is_admin')->default(false); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); $table->timestamps(); });
We updated the name
field to username
and added a new field called is_admin
, which will be used to determine if a user is an admin or not.
Let’s update the fillable fields in our User.php
file, in the app
directory, to have the username
field instead, as well:
protected $fillable = [ 'username', 'email', 'password', ];
Next, we’ll create an Invite
model:
$ php artisan make:model Invite -mc
In addition to creating an Invite
model, the command above will create a migration, as well as a controller for the model.
Open up the created migration and update the up
method in the create_invites_table.php
file, within database/migrations
, as shown below:
Schema::create('invites', function (Blueprint $table) { $table->id(); $table->string('email')->unique(); $table->string('token'); $table->boolean('accepted')->default(false); $table->timestamps(); });
The fields are pretty straightforward; the accepted
field will be used to determine if an invite has been accepted or not.
Also, let’s mark the fields as fillable in Invite.php
, in the app
directory:
protected $fillable = [ 'email', 'token' ];
Before we run the migration, let’s set up our database. For the purpose of this tutorial, we’ll be using SQLite. Update DB_CONNECTION
inside .env
as below:
APP_NAME=Chatroom DB_CONNECTION=sqlite
Because we are making use of SQLite, we can get rid of the other DB
environment variables.
By default, Laravel will look for a database.sqlite
file, so let’s create it:
$ touch database.sqlite
Now, we can run the migration:
$ php artisan migrate
Before we move on to adding functionality to our application, let’s add our Stream keys to our .env
file:
MIX_STREAM_API_KEY=YOUR_STREAM_API_KEY MIX_STREAM_API_SECRET=YOUR_STREAM_API_SECRET
Be sure to update "YOUR_STREAM_API_KEY" and "YOUR_STREAM_API_SECRET" to the values you grabbed from your Stream Chat dashboard.
Creating Channel and Admin Users
Now, let’s start building the group chat functionality. We’ll start by creating a new channel, which we’ll call "Chatroom
", and an admin user, to administer the group. We’ll be creating a console command to handle these. Run the command below to create a new console command:
$ php artisan make:command InitializeChannel
This will create a new InitializeChannel.php
file inside app/Console/Commands
.
Since we’ll be making use of the Stream Chat PHP SDK, let’s install it:
$ composer require get-stream/stream-chat
With that installed, open up InitializeChannel.php
, located at app/Console/Commands
, and replace the contents with the following:
<?php namespace App\Console\Commands; use Illuminate\Console\Command; use GetStream\StreamChat\Client as StreamClient; use App\User; use Illuminate\Support\Facades\Hash; class InitializeChannel extends Command { protected $signature = 'channel:init'; protected $description = 'Initialize channel with admin user'; public function __construct() { parent::__construct(); } public function handle() { $user = new User; $user->username = 'mezie'; $user->email = 'chimezie@adonismastery.com'; $user->is_admin = true; $user->password = Hash::make('password'); $user->save(); $client = new StreamClient( env('MIX_STREAM_API_KEY'), env('MIX_STREAM_API_SECRET'), null, null, 9 ); $client->updateUser([ 'id' => $user->username, 'name' => $user->username, 'role' => 'admin' ]); $channel = $client->Channel('messaging', 'Chatroom', [ 'created_by' => $user->username, ]); $channel->addMembers([$user->username]); return $this->info('Channel initialized'); } }
We define the signature of the command and give it a description. Inside the handle
method, we create an admin user
, then we create the user
on Stream as well, assigning the user an admin
role. Then, we create the channel
and make the newly created user
the creator of the channel
. Next, we add the user
as a member of the channel
. Lastly, we return a success message.
Now, we can run the channel:init
command:
$ php artisan channel:init
Since we want users to join our group chat only by invitation, let’s remove the ability to register, which was added by Laravel authentication scaffolding. We can do that by updating web.php
, in the routes
directory, with the below:
Auth::routes(['register' => false]);
Inviting Users
With the channel and admin users created, let’s add the functionality to invite users to our group chat. We’ll start by creating the routes. Add the code below inside web.php
:
Route::get('/invites/create', 'InviteController@create'); Route::post('/invites', 'InviteController@store');
Since we already have InviteController.php
, let’s open it up and add the following code in it:
public function create() { return view('invites.create'); }
Next, let’s create the view file. Inside the resources/views
directory, create a new invites
directory and create a create.blade.php
file inside it, then paste the following code in it:
@extends('layouts.app') @section('content') <div class="container"> <div class="row justify-content-center"> <div class="col-md-8"> <div class="card"> <div class="card-header">Invite User</div> <div class="card-body"> @if (session('status')) <div class="alert alert-success" role="alert"> {{ session('status') }} </div> @endif <form method="POST" action="/invites"> @csrf <div class="form-group"> <label for="email">E-Mail Address</label> <input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ old('email') }}" required> @error('email') <span class="invalid-feedback" role="alert"> <strong>{{ $message }}</strong> </span> @enderror </div> <div class="form-group"> <button type="submit" class="btn btn-primary">Send Invite</button> </div> </form> </div> </div> </div> </div> </div> @endsection
The form has just one field, which is for capturing the email address to send an invitation to. Above the form, we have in place to display flash messages, if there are any:
Let’s move on to adding the implementation for processing the form. Add the following code to the top of InviteController.php
:
use App\Invite; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; public function store(Request $request) { $invite = Invite::firstWhere('email', $request->email); if (!is_null($invite)) { if ($invite->accepted) { return back()->with('status', 'User is already a member'); } else { return back()->with('status', 'An invite has been sent to user already'); } } $invite = Invite::create([ 'email' => $request->email, 'token' => Str::random(40), ]); $mailBody = 'You\'ve been invited to join Chatroom: ' . url('invites', [$invite->token]); Mail::raw($mailBody, function ($message) use ($invite) { $message->to($invite->email); $message->from('chimezie@adonismastery.com'); $message->subject('Join Chatroom'); }); return back()->with('status', 'Invite sent!'); }
First, we get an invite
matching the submitted email address. If an invite
already exists for the email address, we flash an appropriate message depending on whether the invite
as been accepted or not. If no invite
was found, we create one for the email address, then send an email with the invitation link.
For this tutorial, we won’t be setting up a mail driver. Instead, we’ll log the mail to a file. Let’s set the MAIL_MAILER
to "log
" inside .env
as shown below:
$ MAIL_MAILER=log
Now, if we try sending an invite, it should be logged inside storage/logs/laravel.log
and look something like the below:
[2020-04-25 13:53:21] local.DEBUG: Message-ID: <f38df02fbef7a05d3b21f9d8f4f77210@swift.generated> Date: Sat, 25 Apr 2020 13:53:21 +0000 Subject: Join Chatroom From: chimezie@adonismastery.com To: johndoe@example.com MIME-Version: 1.0 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: quoted-printable You've been invited to join Chatroom: http://stream-invite-only-group-chat.test/invites/pgl0wYpuf4AdUGdM1sVa0JaRSZQBGS9PaP2eEHs4
I’m using Laravel Valet, hence my URL:
http://stream-invite-only-group-chat.test
Let’s add a link to access the invite users page. We’ll add it in the navbar (app.blade.php
) immediately after the @else
:
@if (Auth::user()->is_admin) <li class="nav-item"> <a class="nav-link" href="/invites/create">Invite</a> </li> @endif
While we are here, let’s update the navbar to show the user’s username instead of the name:
{{ Auth::user()->username }}
With these changes, your navbar should look like the below:
Joining Group Chat through Invitation Link
Once a user is sent an invitation, the user can click on the link in the email to join the group chat. To make this possible, add the following code inside web.php
:
Route::get('/invites/{token}', 'InviteController@show'); Route::post('/register', 'Auth\RegisterController@register');
Next, add the following code inside InviteController.php
:
public function show($token) { $invite = Invite::where('token', $token)->firstOrFail(); if ($invite->accepted) { return redirect()->route('home'); } return view('auth.register', compact('invite')); }
First, we retrieve the invite
matching the token
, if it exists. If the invitation has been accepted, we simply redirect the user to the home
route. Otherwise, we render the "register" view
and pass along the invite
.
The register
view is already in place; we just need to update it slightly. Replace the name
and email
fields in resources/views/auth/register.blade.php
as below:
<div class="form-group row"> <label for="username" class="col-md-4 col-form-label text-md-right">{{ __('Username') }}</label> <div class="col-md-6"> <input id="username" type="text" class="form-control @error('username') is-invalid @enderror" name="username" value="{{ old('username') }}" required autocomplete="username" autofocus> @error('username') <span class="invalid-feedback" role="alert"> <strong>{{ $message }}</strong> </span> @enderror </div> </div> <div class="form-group row"> <label for="email" class="col-md-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label> <div class="col-md-6"> <input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ $invite->email }}" required readonly> @error('email') <span class="invalid-feedback" role="alert"> <strong>{{ $message }}</strong> </span> @enderror </div> </div>
We made the email
field read-only and set the value to the email address on the invite.
We’ll still make use of the default Laravel RegisterController
, at app/Http/Controllers/Auth/
, to handle registration. Open it up and update validator
and create
methods to make use of username
instead of name
like below:
protected function validator(array $data) { return Validator::make($data, [ 'username' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 'password' => ['required', 'string', 'min:8', 'confirmed'], ]); } protected function create(array $data) { return User::create([ 'username' => $data['username'], 'email' => $data['email'], 'password' => Hash::make($data['password']), ]); }
Still inside RegisterController.php
, add the following code to the very top:
use GetStream\StreamChat\Client as StreamClient; protected function registered($user) { $invite = Invite::firstWhere('email', $user->email); $invite->accepted = true; $invite->save(); $client = new StreamClient( env('MIX_STREAM_API_KEY'), env('MIX_STREAM_API_SECRET'), null, null, 9 ); $client->updateUser([ 'id' => $user->username, 'name' => $user->username, ]); $channel = $client->Channel('messaging', 'Chatroom'); $channel->addMembers([$user->username]); }
Once a user registers successfully, the registered
method is called. Inside this method is the perfect place to mark the invite
as accepted
, create the user
on Stream, and, finally, add the user
to the "Chatroom" channel
.
Generating an Authentication Token
Let’s add the functionality for generating an authentication token, which Stream Chat will use to authenticate a user.
First, let’s create the endpoint inside routes/api.php
:
Route::post('/tokens', 'TokenController@generate');
Next, create a TokenController
:
$ php artisan make:controller TokenController
and add the following to it:
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use GetStream\StreamChat\Client as StreamClient; class TokenController extends Controller { public function generate(Request $request) { $client = new StreamClient( env('MIX_STREAM_API_KEY'), env('MIX_STREAM_API_SECRET'), null, null, 9 ); return response()->json([ 'token' => $client->createToken($request->username) ]); } }
This generates a token using the user’s username
as the ID.
Adding Chat Functionality
With the backend functionality in place, let’s move on to adding the chat functionality. We are going to rename ExampleComponent.vue
, inside the resource/js
directory, to "ChatRoom.vue
" and update its reference inside resources/js/app.js
as below:
Vue.component('chat-room', require('./components/ChatRoom.vue').default);
Now, replace the content of the ChatRoom
component with the following:
<template> <div class="container"> <div class="row"> <div class="col-md-3"> <div class="card"> <div class="card-header">Members</div> <div class="card-body"> <ul class="list-group list-group-flush"> <li class="list-group-item" v-for="(member, id) in members" :key="id" >{{ member.user.name }}</li> </ul> </div> </div> </div> <div class="col-md-9"> <div class="card"> <div class="card-header">Chats</div> <div class="card-body"> <dl v-for="message in messages" v-bind:key="message.id"> <dt :class="{ 'text-right': message.user.id === authUser.username }" >{{ message.user.name }}</dt> <dd :class="{ 'text-right': message.user.id === authUser.username }" >{{ message.text }}</dd> </dl> <hr /> <span class="help-block" v-if="status" v-text="status" style="font-style: italic;"></span> <form @submit.prevent="sendMessage" method="post"> <div class="input-group"> <input type="text" v-model="newMessage" class="form-control" placeholder="Type your message..." /> <div class="input-group-append"> <button class="btn btn-primary">Send</button> </div> </div> </form> </div> </div> </div> </div> </div> </template> <script> import axios from "axios"; import { StreamChat } from "stream-chat"; export default { name: "ChatRoom", props: { authUser: { type: Object, required: true } }, data() { return { token: null, channel: null, client: null, members: [], messages: [], newMessage: "", status: "" }; }, async created() { await this.getToken(); await this.initializeStream(); await this.initializeChannel(); }, methods: { async getToken() { const { data } = await axios.post("/api/tokens", { username: this.authUser.username }); this.token = data.token; }, async initializeStream() { this.client = new StreamChat(process.env.MIX_STREAM_API_KEY, { timeout: 9000 }); await this.client.setUser( { id: this.authUser.username, name: this.authUser.username }, this.token ); }, async initializeChannel() { this.channel = this.client.channel("messaging", "Chatroom"); const { members, messages } = await this.channel.watch(); this.members = members; this.messages = messages; this.channel.on("message.new", event => { this.messages.push({ text: event.message.text, user: event.message.user }); }); this.channel.on("member.added", event => { this.members.push(event); this.status = `${event.user.name} joined the chat`; }); }, async sendMessage() { await this.channel.sendMessage({ text: this.newMessage }); this.newMessage = ""; } } }; </script>
The template
is made up of two parts: the member
s list and the chat itself. We generate a token
for the authenticated user, then we use the token
to instantiate the Stream Chat client. We also initialize the channel
and watch for activities on the channel
, which allows us to know when a new message
is sent on the channel
as well when new user
s join the channel
. Lastly, we have the functionality for sending a new message
.
For a guide that covers these in detail, check out my previous tutorial.
Now, let’s install the Stream Chat JavaScript SDK:
$ yarn add stream-chat
Next, let’s make use of the ChatRoom
component. Update home.blade.php
as shown below:
@extends('layouts.app') @section('content') <chat-room :auth-user="{{ auth()->user() }}"></chat-room> @endsection
The ChatRoom
component accepts the authenticated user
as props, which we are passing in.
Securing Our Application
Remember we said only an admin user can invite users to join our chatroom? But, as it stands, anybody can do this, even if they're not logged in. Let’s change that! Laravel provides an Authenticate
middleware, by default, which we can use to enforce access to only authenticated users. We’ll create another middleware for enforcing an authenticated user is an admin.
Run the command below to create an Admin
middleware:
$ php artisan make:middleware Admin
and update the handle
method as shown below:
public function handle($request, Closure $next) { if (!auth()->user()->is_admin) { return redirect()->route('home'); } return $next($request); }
Here, we are simply redirecting the authenticated user
to the home
route if the user
is not an admin. If they are an admin, we allow the user
to access the requested page.
Before we can make use of the middleware, we need to register it. We’ll register it as a route middleware by adding it inside the $routeMiddleware
array, in app/Http/Kernel.php
, like so:
protected $routeMiddleware = [ 'admin' => \App\Http\Middleware\Admin::class, ];
Now, we can make use of the middleware inside web.php
using the admin
key/identifier:
Route::get('/invites/create', 'InviteController@create')->middleware(['auth', 'admin']); Route::post('/invites', 'InviteController@store')->middleware(['auth', 'admin']);
Now, not only does a user have to be authenticated to access these routes, but they must also be an admin!
Wrapping Up
In this tutorial, we have seen how to build an invite-only group chat using Laravel, Vue.js, and Stream Chat. We created multiple pages and authentication layers to our simple chat, but there is so much more you can do! To learn more about Stream Chat and its exciting features, check out the docs!
Thanks for reading, and happy coding!