Implementing Programmable Chat with Laravel PHP and Vue.js

February 28, 2020
Written by
Favour Oriabure
Contributor
Opinions expressed by Twilio contributors are their own

Implementing Programmable Chat in PHP with Laravel and Vue.js

This tutorial will teach you how to use Twilio's real-time, Programmable Chat technology. You will be able to see this in action between two or more browser sessions. At the end of this tutorial, you would have developed a chat system between users.

Installing dependencies and Requirements

Here is a list of dependencies you need to install, and steps needed to be taken before you can complete this tutorial.

If you have Laravel installed on your machine, you can proceed to creating a new project by running any of the commands below:

bash
$ laravel new project_name

or using the composer create-project command

bash
$ composer create-project --prefer-dist laravel/laravel project_name

After this process , we will need to install the Twilio package in the project directory with Composer using the commands below:

bash
$ composer require twilio/sdk && npm install vue-template-compiler

NOTE: Your version of vue-template-compiler might be a mismatch for your current version of Vue. If so, re-install with the correct version such as npm install vue-template-compiler@2.5.16 --save-dev

When you have created your Twilio account, make sure you set up the Programmable Chat service and record the Chat SID and Chat Secret.

Your Twilio account comes with some important keys that will be needed in the course of this tutorial. You should save them in your .env file.

TWILIO_ACCOUNT_SID = XXXXXXXXXXXXXXXXXXXXXXXXX
TWILIO_API_KEY = XXXXXXXXXXXXXXXXXXXXXXXXX
TWILIO_SECRET = XXXXXXXXXXXXXXXXXXXXXXXXX
TWILIO_CHAT_SID = XXXXXXXXXXXXXXXXXXXXXXXXX

NOTE: Your Twilio account keys can be found here.

NOTE: You also need to set up Vue.js. You can find instructions here.

Now these credentials can be added to your project’s config helper, available at app/config/services.php.

'twilio' => [
    'sid' => env('TWILIO_ACCOUNT_SID'),
    'key' => env('TWILIO_API_KEY'),
    'secret' => env('TWILIO_SECRET'),
    'grant' => env('TWILIO_CHAT_SID')
]

After all of these configurations have been completed, a controller needs to be created called TwilioChatController. It will house the basic code for token generation. Run the following command to create the controller.

bash
$ php artisan make:controller TwilioChatController

We now need to make our controller aware of some Twilio classes needed for functionality and setup the constructor with some properties. Add the following code to TwilioChatController.php:

   <?php
    // app/Http/Controllers/TwilioChatController.php

    namespace App\Http\Controllers;
    
    use Illuminate\Http\Request;
    use Twilio\Jwt\AccessToken;
    use Twilio\Jwt\Grants\ChatGrant;
    use Twilio\Rest\Client;
    
    class TwilioChatController extends Controller
    {
        private $twilio_account_sid;
        private $twilio_api_key;
        private $twilio_api_secret;
        private $service_sid;
        private $identity;
    
        public function __construct()
        {
          $this->twilio_account_sid = config('services.twilio')['sid'];
          $this->twilio_api_key = config('services.twilio')['key'];
          $this->twilio_api_secret = config('services.twilio')['secret'];
          $this->service_sid = config('services.twilio')['grant'];
          $this->identity = '';
        }
    }

Creating the view

We need to create a view that would house the Vue.js Component we will be interacting with. Create a blade file called chat.blade.php in the resources/views folder. Add the following code:

    <!DOCTYPE html>
    <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
        <head>
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <meta name="csrf-token" content="{{ csrf_token() }}">
    
    
            <title>Twilio Chat</title>
    
            <!-- Fonts -->
            <link href="https://fonts.googleapis.com/css?family=Nunito:200,600" rel="stylesheet">
            <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
            <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css">
            <script>
                window.Laravel = {!! json_encode([
                    'csrfToken' => csrf_token(),
                ]) !!};
            </script>
    
    
            <!-- Styles -->
            <style>
                html, body {
                    background-color: #fff;
                    color: #636b6f;
                    font-family: 'Nunito', sans-serif;
                    font-weight: 200;
                    height: 100vh;
                    margin: 0;
                }
    
                .full-height {
                    height: 100vh;
                }
    
                .flex-center {
                    align-items: center;
                    display: flex;
                    justify-content: center;
                }
    
                .position-ref {
                    position: relative;
                }
    
                .top-right {
                    position: absolute;
                    right: 10px;
                    top: 18px;
                }
    
                .content {
                    text-align: center;
                }
    
                .title {
                    font-size: 84px;
                }
    
                .links > a {
                    color: #636b6f;
                    padding: 0 25px;
                    font-size: 13px;
                    font-weight: 600;
                    letter-spacing: .1rem;
                    text-decoration: none;
                    text-transform: uppercase;
                }
    
                .m-b-md {
                    margin-bottom: 30px;
                }
            </style>
        </head>
        <body>
            <div class="flex-center position-ref full-height">
                <div id="app">
                  <twilio-chat></twilio-chat>
                </div>
            </div>
            <script src="{{ asset('js/app.js') }}"></script>
            <script src="//media.twiliocdn.com/sdk/js/common/v0.1/twilio-common.min.js"></script>
            <script src="//media.twiliocdn.com/sdk/js/chat/v3.0/twilio-chat.min.js"></script>
        </body>
    
    </html>

NOTE: We have a twilio-chat component that houses our Vue.js code. You should go ahead and create TwilioChat.vue in the resources/js/components directory.

Inside a Vue.js Component

The view component consists of a template, some styling, and the JavaScript code used to interact with Twilio’s APIs. Let's get into it.

The template basically consists of the HTML to house our "chat box and channels". Add the following code to resources/js/components/TwilioChat.vue:

<template>
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-10">
                <div class="card">
                    <div class="row">
                        <!-- Add the channel code here -->
                        <div class="col-md-8">
                            <!-- Add the chat box code here -->
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

Next, we will add the chat box, which contains a message container and input boxes for the username and the chat message. Replace the line of code <!-- Add the chat box code here --> with the following code:

<div class="card-body">
    <div class="message-box">
        <div class="message-div" v-show="showMessages">
        <div v-for="message in tc.messagesArray" :key="message.id" class="row msg">
            <div class="media-body">
                <small class="pull-right time"><i class="fa fa-clock-o"></i>{{moment(message.timestamp).fromNow()}}</small>
                <h5 class="media-heading">{{message.author}}</h5>
                <small class="col-sm-11">{{message.body}}</small>
            </div>
        </div>
        </div>
        <p v-if="notification">{{notificationMsg}}</p>
    </div>
    <input v-if="userNotJoined" class="form-control" type="text" v-model="username" v-on:keyup.13="connectClientWithUsername" placeholder="Your username">
    <input v-else class="form-control" type="text" v-model="message" v-on:keyup.13="handleInputTextKeypress" placeholder="Your message">
</div>

Observe that we have some data attributes and functions:

 - tc.messagesArray: an array which holds the messages in the current channel.

 - showMessages: a boolean value which signifies we can fetch messages for the current channel.

 - userNotJoined: a boolean which signifies whether or not the user has joined or connected to a channel.

 - connectClientWithUsername(): A function triggered when the enter key is pressed. This function fetches a token and registers the member with the Twilio chat service.

 - handleInputTextKeypress(): A function which handles the action of sending messages.

Add the following code to the bottom of the file. It will provide the functionality that we defined in the <template> above.

<script>
var moment = require('moment');

export default {
    data: function () {
        return {
        tc: {
            accessManager: null,
            messagingClient: null,
            channel: [],
            generalChannel: null,
            username: '',
            channelArray: [],
            currentChannel: null,
            activeChannelIndex: null,
            messagesArray: [],
        },
        username: '',
        connected: false,
        selected: false,
        showMessages: false,
        moment: moment,
        message: null,
        userNotJoined: true,
        newChannel: '',
        showAddChannelInput: false,
        notification: false,
        notificationMsg: '',
        }
    },
    mounted() {
        console.log('Component mounted.');
    },
    methods: {

    }
}
</script>

Let's look at the methods we defined in the HTML above and include them within the methods object of the <script>:

connectClientWithUsername(){
    if (this.username == '') {
        alert('Username cannot be empty');
        return;
    }
    this.tc.username = this.username;
    this.fetchAccessToken(this.tc.username, this.connectMessagingClient);
},
fetchAccessToken(username, handler) {
    let vm = this;
    axios.post('http://127.0.0.1:8000/token', {
        identity: this.tc.username,
        device: 'browser'
    })
    .then(function (response) {
        handler(response.data);
        vm.username = '';
    })
    .catch(function (error) {
        console.log(error);
    });
},
connectMessagingClient(token) {
    // Initialize the Chat messaging client
    let vm = this;

    this.tc.accessManager = new Twilio.AccessManager(token);

    new Twilio.Chat.Client.create(token).then(function(client) {
        vm.tc.messagingClient = client;
        vm.updateConnectedUI();
        vm.loadChannelList(vm.joinGeneralChannel);
        vm.tc.messagingClient.on('channelAdded', _.throttle(vm.loadChannelList));
        vm.tc.messagingClient.on('channelRemoved', _.throttle(vm.loadChannelList));
        vm.tc.messagingClient.on('tokenExpired', vm.refreshToken);
    });
},
updateConnectedUI(){
    this.connected = true;
},
refreshToken(){
    this.fetchAccessToken(this.tc.username, vm.setNewToken);
},
setNewToken(tokenResponse) {
    this.tc.accessManager.updateToken(tokenResponse.token);
},

The connectClientWithUsername() function fetches a token assigned to the user through the fetchAccessToken() method.

Creating the routes

In web.php (located in the routes directory), we will add the following route to fetch the token:

Route::post('token', "TwilioChatController@getToken");

In our TwilioChatController, the getToken method looks like the following code. Add it to the file located in app/Http/Controllers.

public function getToken(Request $request)
{
    $this->identity = $request->identity;
    // Create access token, which we will serialize and send to the client
    $token = new AccessToken(
    $this->twilio_account_sid,
    $this->twilio_api_key,
    $this->twilio_api_secret,
    3600,
    $this->identity
    );

    // Create Chat grant
    $chat_grant = new ChatGrant();
    $chat_grant->setServiceSid($this->service_sid);

    // Add grant to token
    $token->addGrant($chat_grant);

    // render token to string
    echo $token->toJWT();
}

Next, replace the root route / with the following:

Route::get('/', function () {
   return view('chat');
});

Back to the Vue Component

Once the token is retrieved, connectMessagingClient() connects the user to the Twilio Chat client. Then loadChannelList() gets all the channels assigned to this client.

Add the following code to methods() in TwilioChat.vue:

loadChannelList(handler){
    if (this.tc.messagingClient === undefined) {
        console.log('Client is not initialized');
        return;
    }
    this.getVisibleChannels(this.tc.messagingClient, handler);
},
getVisibleChannels(messagingClient, handler){
    let vm = this;

    messagingClient.getPublicChannelDescriptors().then(function(channels) {
        vm.tc.channelArray = vm.sortChannelsByName(channels.items);

        vm.tc.channelArray.forEach(vm.addChannel);
        if (typeof handler === 'function') {
        handler();
        }
    });
},
sortChannelsByName(channels) {
    return channels.sort(function(a, b) {

        if (a.friendlyName === 'general') {
        return -1;
        }
        if (b.friendlyName === 'General Channel') {
        return 1;
        }
        return a.friendlyName.localeCompare(b.friendlyName);
    });
},

Joining a channel

The General Channel is the default channel that would automatically be created in this process. We will add the method needed to join the general channel with the following code:

joinGeneralChannel() {
    console.log('Attempting to join "general" chat channel...');
    let vm = this;
    if (this.tc.generalChannel == null) {
        // If it doesn't exist, let's create it
        this.tc.messagingClient.createChannel({
        uniqueName: 'general',
        friendlyName: 'General Channel'
        }).then(function(channel) {
        console.log('Created general channel');
        vm.tc.generalChannel = channel;
        vm.loadChannelList(vm.joinGeneralChannel);

        });
    }else {
        console.log('Found general channel:');
        this.setupChannel(this.tc.generalChannel);
    }
},
addChannel(channel){
    if (channel.uniqueName === 'general') {
        this.tc.generalChannel = channel;
    }
},

If the general channel is not found, it creates it. If it is found, it sets up the channel by fetching the messages unique to the channel though the setupChannel() function.

Add setupChannel() to the methods as follows:

setupChannel(channel){
    let vm = this;
    return this.leaveCurrentChannel()
        .then(function() {
        return vm.initChannel(channel);
        })
        .then(function(_channel) {
        return vm.joinChannel(_channel);
        })
        .then(this.initChannelEvents);
},
leaveCurrentChannel() {
    let vm = this;
    if (this.tc.currentChannel) {
        return this.tc.currentChannel.leave().then(function(leftChannel) {
        console.log('left ' + leftChannel.friendlyName);
        leftChannel.removeListener('messageAdded', vm.addMessageToList);
        leftChannel.removeListener('typingStarted', vm.showTypingStarted);
        leftChannel.removeListener('typingEnded', vm.hideTypingStarted);
        leftChannel.removeListener('memberJoined', vm.notifyMemberJoined);
        leftChannel.removeListener('memberLeft', vm.notifyMemberLeft);
        });
    } else {
        console.log("resolving");
        return Promise.resolve();
    }
},
initChannel(channel) {
    console.log('Initialized channel ' + channel.friendlyName);
    return this.tc.messagingClient.getChannelBySid(channel.sid);
},
initChannelEvents() {
    console.log(this.tc.currentChannel.friendlyName + ' ready.');
    this.tc.currentChannel.on('messageAdded', this.addMessageToList);
    this.tc.currentChannel.on('typingStarted', this.showTypingStarted);
    this.tc.currentChannel.on('typingEnded', this.hideTypingStarted);
    this.tc.currentChannel.on('memberJoined', this.notifyMemberJoined);
    this.tc.currentChannel.on('memberLeft', this.notifyMemberLeft);
    // $inputText.prop('disabled', false).focus();
},
showTypingStarted(member) {
    console.log(member.identity + ' is typing...');
    this.notificationMsg = member.identity + ' is typing...';
    this.notification = true;
},
hideTypingStarted(member) {
    this.notificationMsg = '';
    this.notification = false;
},
notifyMemberJoined(member) {
    console.log("joining");
    console.log(member.identity + ' joined the channel');
    // notify(member.identity + ' joined the channel')
},
notifyMemberLeft(member) {
    console.log("leaving");
    console.log(member);
    console.log(member.identity + ' left the channel');
    // notify(member.identity + ' left the channel');
},
notify(message) {
    var row = $('<div>').addClass('col-md-12');
    row.loadTemplate('#member-notification-template', {
    status: message
    });
    tc.$messageList.append(row);
    scrollToMessageListBottom();
},
joinChannel(_channel) {
    console.log(_channel);
    let vm = this;
    return _channel.join()
        .then(function(joinedChannel) {
        console.log('Joined channel ' + joinedChannel.friendlyName);
        vm.updateChannelUI(_channel);
        vm.tc.currentChannel = _channel;
        vm.loadMessages();
        return joinedChannel;
        })
        .catch(function(err) {
        alert("Couldn't join channel " + _channel.friendlyName + ' because ' + err);
        });
},
updateChannelUI(selectedChannel) {
    let channelLists = this.$refs.channelList;

    let activeChannelList = channelLists.filter(function(element) {
    return element.dataset.sid === selectedChannel.sid;
    });

    activeChannelList = activeChannelList[0];
    if (this.tc.currentChannelContainer === undefined && selectedChannel.uniqueName === 'general') {

    this.tc.currentChannelContainer = activeChannelList;
    }

    this.tc.currentChannelContainer.classList.remove('selected-channel');
    this.tc.currentChannelContainer = activeChannelList;
    this.tc.currentChannelContainer.classList.add('selected-channel');
},
loadMessages() {
    let vm = this;
    this.tc.currentChannel.getMessages(50).then(function (messages) {
        vm.showMessages = true;
        vm.tc.messagesArray = messages.items;
        vm.userNotJoined = false
        // messages.items.forEach(vm.addMessageToList);
    });
},
addMessageToList(message) {
    console.log(message);
    this.loadMessages();
},
handleInputTextKeypress() {
    let vm = this;
    this.tc.currentChannel.sendMessage(this.message);
    this.message = '';
    // setTimeout(function(){
    //    vm.loadMessages();
    //  }, 3000);
},

The setupChannel() method calls a variety of methods, but we can focus on the joinChannel() for now. Through this method, the member joins the channel (general or a personally created channel).

Creating a channel

We can now add the channel section to our Vue template which will list all channels available to the client. In the <template>, replace <!-- Add the channel code here --> with the code the following code:

<div class="col-md-4 channel-list" v-show="connected">
    <ul>
        <li v-for="(channel) in tc.channelArray" :key="channel.id" ref="channelList" :data-sid="channel.sid">
        <a href="#!" @click="selectChannel(channel)"> {{channel.friendlyName}} </a>


        </li>
        <a href="#!" @click="createChannel"> Add Channel</a>
        <input v-if="showAddChannelInput" class="form-control" type="text" v-model="newChannel" v-on:keyup.13="handleNewChannelInputKeypress" placeholder="New Channel">
    </ul>
</div>

Now add the Vue function handleNewChannelInputKeypress to create a channel:

handleNewChannelInputKeypress(event) {
    let vm = this;
    if (this.newChannel == '') {
        alert('Channel name cannot be empty');
        return;
    }
    this.tc.messagingClient.createChannel({
        friendlyName: this.newChannel
    }).then(function(channel) {
        console.log('Created channel');
        vm.loadChannelList(channel);
    }).then(this.hideAddChannelInput);
    this.newChannel = '';
},

Switching channels

Based on the list of channels you have available, you can switch to any channel of choice using the selectChannel method. Add it now:

selectChannel(channel) {
    let channelSid = channel.sid;
    var selectedChannel = this.tc.channelArray.filter(function(channel) {
        return channel.sid === channelSid;
    })[0];
    if (selectedChannel === this.tc.currentChannel) {
        return;
    }
    this.setupChannel(selectedChannel);
},
hideAddChannelInput(){
    this.showAddChannelInput = false;
},
createChannel(){
    this.showAddChannelInput = true;
},

You can also delete the current channel with the following code :

deleteChannel() {
    if (this.tc.currentChannel.sid === this.tc.generalChannel.sid) {
        alert('You cannot delete the general channel');
        return;
    }
    this.tc.currentChannel.delete().then(function(channel) {
        console.log('channel: '+ channel.friendlyName + ' deleted');
        setupChannel(this.tc.generalChannel);
    });
}

You can view the full code base here.

Testing

To see the application in action, start up your development server using the command below in the terminal of your local machine.

$ npm run dev && php artisan serve

Using a web browser of your choice, visit http://127.0.01:8000. Fill in the form and submit.

Finally open up the application in two windows or browsers. You can enter a username and send a message on one of the windows or browsers. Check the other instance and also interact with it.

Conclusion

You could extend the application built in the tutorial by displaying a list of users. Now that you have completed this tutorial, you know how to:

-   Add Twilio’s Programmable Chat to your Application
-   Expose actions between chat users

 

Favour Oriabure
Email: favoriabs@gmail.com
Twitter: https://twitter.com/favoriabs
Github: https://github.com/favoriabs