How to Create a Vue.js Group Chat App in PHP with Symfony

February 01, 2019
Written by
Onwuka Gideon
Contributor
Opinions expressed by Twilio contributors are their own

vue-js-group-chat-app-symfony.png

If you have an app or products where users need to communicate with one another, adding a real-time means of communication will greatly increase customer satisfaction, and as a result, makes your business more credible.

In this tutorial, I will walk you through how you can easily add chat to your Symfony and Vue.js application using the Twilio Programmable Chat API. We'll build a group chat where every user can converse in the same channel.

Here is a preview of what we’ll be building:

preview of chatx

Prerequisites

This tutorial uses the following:

To follow along with this tutorial, you should have a basic understanding of the PHP language. Knowledge of the Symfony framework is not entirely required.

I will assume you have some knowledge of Vue.js, otherwise, you can go through the basics on the docs to get you up to speed in a couple of minutes.

Create a Single Page Application

To get started, follow this tutorial to create the project structure or grab the complete file from GitHub. If you didn’t follow the tutorial, you may need to update your project directory name anywhere symfony-vue appears.

Next, change your current directory to the root folder of the project:

$ cd symfony-vue

Next, start up the backend server if it’s not already running:

$ php -S localhost:9030 -t public

Next, open a new terminal, then add the Twilio JavaScript library using:

$ yarn add twilio-chat
$ yarn add babel-runtime

Finally, start up the frontend server if it's not up already so that the changes we make to the Vue files will be automatically compiled:

$ yarn encore dev-server --hot

Note: Make sure the two terminal windows are running while you follow along with this tutorial.

Generate a Chat Token

The first step to using the Twilio Programmable Chat API is to generate an access token. Twilio will generate the token using your API Keys and use it to make sure people accessing your app are authorized to do so.

First, generate the following API keys:

Once you have the keys, update the values in the .env file:

SERVICE_INSTANCE_SID='Service Instance SID'
ACCOUNT_SID='Account SID'
API_KEY='API Key'
API_SECRET='API Secret'

Then import the Twilio SDK we installed via Composer to the src/Controller/HomeController.php file at the header section:

<?php
// ...
use Twilio\Jwt\AccessToken;
use Twilio\Jwt\Grants\ChatGrant;

// ..

Now, create a route for generating a token by adding the function below to the HomeController.php file:

<?php
    // src/Controller/HomeController.php
     
     // …

     /**
     * @Route("/token", name="get_token", methods={"POST"})
     */
    public function authenticate( Request $request ) {

        $identity = $request->request->get( 'email' );

        // Required for all Twilio access tokens
        $twilioAccountSid = getenv(  'ACCOUNT_SID' );
        $twilioApiKey     = getenv(  'API_KEY' );
        $twilioApiSecret  = getenv(  'API_SECRET' );

        // Required for Chat grant
        $serviceSid = getenv(  'SERVICE_INSTANCE_SID' );

        // Create access token, which we will serialize and send to the client
        $token = new AccessToken(
            $twilioAccountSid,
            $twilioApiKey,
            $twilioApiSecret,
            3600,
            $identity
        );

        // Create Chat grant
        $chatGrant = new ChatGrant();
        $chatGrant->setServiceSid( $serviceSid);

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

        // render token to json
        return $this->json([
            "status" => "success",
            "token" => $token->toJWT()
        ]);
    }

  // ...

To generate a token via the Chat API, we need a way to uniquely identify all users. In the code above, our route provides the support we need for generating these tokens.

First, we capture the email that will be sent along with the POST request. This will serve as the unique identifier for that user. Then we use the API keys we saved in the .env file to generate a token.

Create the Vue Components for Our Chat Interface

First, let's divide our UI into components so we can build each component separately.

For brevity, we'll divide the components into:

  • Login.vue that will render the login form.
  • Channels.vue which will list all channels created.
  • Messages.vue that will render all messages sent to channels.

The App.vue file located in the /assets/js/components folder exists by default and is the main component that will house every other component we'll create.

Now create these component files as Login.vue, Channels.vue, and Messages.vue in the assets/js/components folder.

Add the App.vue code

Using the single-file components approach of creating a Vue component, we’ll place our HTML markup in the <template> section, our JavaScript code in the <script> section, and our styles will be in the <styles> section.

Now, update the template section of the assets/js/components/App.vue file with the code below:

<template>
 <div class="wrapper">
      <div class="chat-container">
          <div class="head">
              <div style="justify-self: center;">
                  Chatx
              </div>
          </div>
          <div class="chat-box">
              <Login
                  v-if="!authenticated"
                  v-on:authenticated="setAuthenticated"
              />
              <div class="container-chat" v-else>
                  <Channels
                     :channels="channels"
                     v-on:show-message="showMessages"
                     v-on:new-channel="addChannel"
                  />
                  <Messages
                      v-on:new-message="addMessage"
                      :messages="messages"
                      :userData="userData"
                  />
              </div>
          </div>
      </div>
 </div>
</template>

In the mark-up above, we imported the component's files we created earlier:

  • <Login …/>. This will render the Login component. An important thing to note here is the v-if directive we used. We are using the v-if to check if the authenticated state is set to true or false. We'll render the login component if the authenticated state is set to false, else we’ll render the chat area. We use the v-on directive to listen for an event named authenticated that will be triggered once the user is successfully logged in so that we can call the setAuthenticated function to initialize the chat. We haven't created the setAuthenticated function yet. We'll create the function later.
  • <Channels ../>. This will render the channels component that we created. We’ll utilize the :channels="channels" attribute to pass the channels array to the component so it can be rendered within the component.
  • <Messages ../>. This will render messages sent to a channel.

Next, update the script section of the assets/js/components/App.vue file with the code below:

<script>
  import Login from './Login';
  import Messages from './Messages';
  import Channels from './Channels';
  const Chat = require('twilio-chat');

  let chatChannel;
  let Client;

   export default {
      components: {
         Messages,
         Login,
         Channels
      },
      data: function () {
          return {
            authenticated: false,
            messages: [],
            userData: {},
            token: null,
            channels: [],
            active_channel: null
          }
      },
      methods: {}
  }
</script>

In the code above, we imported the components we created earlier and defined two global variables chatChannel and Client which will hold a value from the Twilio API when we initialize the SDK. Finally, we added a number of states in the data method, which will hold some states for the app.

Next, we registered the imported components, as seen in the components property components: { so that Vue will know about them.

Finally, update the script section of the assets/js/components/App.vue file with the code below:

<style scoped>
  .wrapper {
      display: grid;
      grid-template-columns: 50px 1fr 50px;
      height: 90vh;
      z-index: 100000;
      box-sizing: border-box;
      padding: 0px;
      margin: 0px;
  }
  .chat-container {
      grid-column-start: 2;
      z-index: 100000;
      box-sizing: border-box;
  }
  .head {
      padding: 9px;
      display: grid;
      background-color: rgb(48, 13, 79);
      color: white;
      border-top-left-radius: 5px;
      border-top-right-radius: 5px;
  }
  .chat-box {
      border-left: 1px solid rgb(48, 13, 79);
      border-right: 1px solid rgb(48, 13, 79);
      background: lightgray;
      height: 100%;
 }
 .container-chat {
      display: grid;
      grid-template-columns: 1fr 4fr;
      min-height: 100%;
 }
</style>

Even though they’re empty, by importing the components now, Vue will allow us to observe the changes as we continue to develop them.

This is our entire chat UI. If you refresh the page, you will notice that the page is empty, except for our header and wrapper. This is because the contents of the components are still empty. Once we update the component files, you will see the page come to life.

The page should look like this:

empty Chatx view

Create the Login.vue component

Now, let's create our Login component. Add the code below to the assets/js/components/Login.vue file:

<template>
   <div class="login-container chat-box">
       <div class="inputs">
           <div class="" v-if="message"> {{message}} </div>
           <input type="text" placeholder="Your name" v-model="name">
           <input type="text" placeholder="Your email" v-model="email">
           <button type="submit" class="submit" v-on:click="login"> Submit </button>
       </div>
   </div>
</template>

<script>
   export default {
       data: function () {
           return {
             name: "",
             email: "",
             message: ""
           }
       },
       methods: {
           login: function() {
               // Do your real authentication here...
               if ( !!(this.name && this.email) ) {
                   this.$emit("authenticated", {email: this.email, name: this.name});
               } else {
                   this.message = "All inputs are required."

                    // clear the message after 2 sec.
                   setTimeout(() => {
                       this.message = ""
                   }, 2000);
               }
           }
       }
   }
</script>

<style scoped> 
   .login-container {
       display: grid; 
   }
   input[type="text"] {
       padding: 10px 8px;
       margin-top: 10px;
       border-radius: 2px;
       border: 1px solid darkgray;
       font-size: 16px;
       box-sizing: border-box;
       display: block;
       width: 300px;
   }
   .inputs {
       text-align: center;
       align-self: center;
       justify-self: center;
   }
   .submit {
       width: 300px;
       margin-top: 9px;
       padding: 10px 60px;
       background: rgb(99, 99, 212);
       color: white;
       font-size: 16px;
   }
</style>

Here, we created a form that will authenticate our users before proceeding to the chat room. For this demonstration, we are not actually doing any real authentication. We are just checking if the form inputs are populated before submitting.

If both fields are completed, we emit an event named authenticated, passing along the user login data, which we'll act upon in the main component to show the chat area. If any of the inputs are empty, we show the user an error message.

NOTE: You should do your actual authentication within the login method before you make this application public.

If you check the page now, you will see something like below:

login view of Chatx

Create the Messages.vue component

Now add the messages component to the assets/js/components/Messages.vue file:

<template> 
       <div class="messages-container">
           <div id="messages">
               <div
                   class="chat-message"
                   v-for="message in messages" 
                   v-bind:key="message.id"
                   v-bind:class="[(message.author == userData.email) ? 'to-right' : 'to-left']"
               >
                   {{ message.body }}
               </div>
           </div>
          
          <div class="input-container">
            <input
               class="chat-input" 
               type="text"
               placeholder="Enter your message..." 
               v-model="message"
               v-on:keyup.enter="addMessage"
            >
          </div>
      </div>
</template>

<script>
   export default {
       props: {
           messages: Array,
           userData: Object
       },
       data() {
           return {
               message: ""
           }
       },
       methods: {
           addMessage() {
               this.$emit('new-message', this.message);
               this.message = "";
           }
       }
   }
</script>

<style scoped>
   .messages-container {
       display: grid;
       grid-template-areas:
           "messages"
           "input";
       grid-template-rows: 1fr 40px;
       border-left: 1px solid rgb(48, 13, 79); ;
   }
   .chat-message {
       width: 70%;
       margin-bottom: 8px;
       padding: 5px;
   }
   .to-left {
       background: rgb(191, 202, 204);
       color: rgb(39, 37, 37);
       float: left;
   }
   .to-right {
       background: rgb(48, 13, 79);
       color: white;
       float: right;
   }
   .input-container {
       grid-area: input;
   }
   .chat-input {
       width: 100%; 
       height: 100%;
       border-radius: 2px;
       padding: 10px 8px;
       border: 1px solid darkgray;
       font-size: 16px;
       box-sizing: border-box;
   }
   #messages {
       overflow-y: scroll; 
       max-height: -webkit-fill-available;
   }
</style>

Here, we have defined the messages props as an array. We will pass the messages in a channel so we can render the messages in the component using the v-for directive.

The v-bind:class... directive allows us to check if a message was sent by the current logged in user so that we can format the message to the left. Else, we format it to the right.

Add the Channels.vue component

Finally, let's add the component for listing the channels. Add the code below to the assets/js/components/Channels.vue file:

<template>
       <div class="channels-container">
           <div class="channels">
               <div
                   class="channel"
                   v-for="channel in channels"
                   v-bind:key="channel.id"
                   v-on:click="showMessages(channel)"
                   v-bind:class="[(active_channel == channel.uniqueName) ? 'active' : '']"
               >
                   {{ channel.uniqueName }}
               </div>
           </div>

          <div class="input-container">
            <input
               class="channel-input"
               type="text"
               placeholder="Add a channel"
               v-model="channel_name"
               v-on:keyup.enter="addChannel"
            >
          </div>
      </div>
</template>

<script>
   export default {
       props: {
           channels: Array,
       },
       data() {
           return {
               channel_name: "",
               active_channel: null
           }
       },
       methods: {
           addChannel() {
               this.$emit('new-channel', this.channel_name);
               this.channel_name = "";
           },
           showMessages(channel) {
               this.active_channel = channel.uniqueName;
               this.$emit('show-message', channel);
           }
       }
   }
</script>

<style scoped>
 .channels-container {
   display: grid;
   grid-template-areas:
       "channels"
       "input";
   grid-template-rows: 1fr 40px;
   border-left: 1px solid rgb(48, 13, 79);
 }
 .channels {
   overflow-y: scroll;
   max-height: -webkit-fill-available;
   grid-area: channels;
 }
 .channel {
   padding: 8px;
   margin: 3px;
   background: azure;
   cursor: pointer;
 }
 .channel:hover {
   background: rgb(66, 85, 85);
   color: white;
 }
  .active {
   background: rgb(66, 85, 85);
   color: white;
 }
 .input-container {
   grid-area: input;
   bottom: 0;
 }
 .channel-input {
   width: 100%;
   height: 100%;
   border-radius: 2px;
   padding: 10px 8px;
   border: 1px solid darkgray;
   font-size: 16px;
   box-sizing: border-box;
  }
</style>

The code is similar to the code in Messages.vue. We pass in the channels as an array to the component, so that we can loop through the channels and list them on the page.

We also created a form for adding a new channel. When the user types a channel name and submits, we call the addChannel function to trigger an event that we'll listen to in App.vue to create the new channel.

Finally, when a channel is clicked, we want to queue the messages in that channel. Using the v-on:click="showMessages(channel)" line, we listen to click events for a channel from a user. When the user clicks on a channel, we emit an event named show-message. Then from the App.vue, we listen to this event to display messages contained in that particular channel.

Initialize the Chat Window

To start communicating with Twilio using the JavaScript SDK, we need to generate a token. Now, add the function below to the methods:{} block in assets/js/components/App.vue file:

setAuthenticated(userData) {
	fetch('/token', {
		method: "POST",
		body: email=${userData.email}&name=${userData.name},
		headers: {'Content-Type':'application/x-www-form-urlencoded'},
	})
	.then( (response) => response.json() )
	.then( (response) => {
		if (response.status == 'success') {
			this.token = response.token;
			this.userData = userData;
			this.authenticated = true;

                          
			this.initializeChat();
    	}
	});
},

In this function, we are making a request to the /token endpoint to generate a token using the email the user provided. Once we have the token, we save the token and userData to the state of the component.

Also, we change the  authenticated state to true so that the chat area will be visible and the login form will be hidden. Now try logging in, you will see the chat page show up if you fill in login details and submit (Remember, any information should work since we are not doing real authentication.):

demonstration of Chatx authentication

Finally, we call the this.initializeChat(); function to initialize the chat. We'll be writing this function next.        

Next, add the function that will initialize the chat to the methods:{} block in assets/js/components/App.vue file:

initializeChat() {
	Chat.Client.create( this.token )
		.then( (client) => {
			client.getPublicChannelDescriptors()
				.then( channels => {

					this.channels = channels.state.items

				});

			Client = client;
		});
},

In the code above:

  • We have the Chat object which is available to us from the Twilio JavaScript SDK we imported to this file using const Chat = require('twilio-chat');
  • Then, using the Chat object, we initialize a chat using the token we generated - this.token.
  • If the token is valid, we'll now have access to the client object which is the starting point to accessing Twilio Chat functionality.
  • Next, we use the client object to fetch all public channels that have been created. Once we have the channels, we save them to the state of the component and then pass them as props to the Channels.vue component. Then finally, we set the client object globally.

Next, add a function to setup channels to the methods:{} block in assets/js/components/App.vue file:

setupChannel(channel) {
	channel.decline()
		.then( (channel) => {
			// Then join the channel
			channel.join().then( (channel) => {
				chatChannel = channel; // Set it global
				channel.getMessages().then( messages => {
					this.messages = messages.items;
				});


				// Listen for new messages sent to the channel
				channel.on('messageAdded', (message) => {
					this.messages.push(message.state);
				});
			}).catch( (err) => {
				// If there is error joining the room,
				// get all messages on the channel
				channel.getMessages().then( messages => {
					this.messages = messages.items;
				});
			});
		});
},

We'll call this function once a user wants to view a channel by clicking on it. The function accepts a channel object as a parameter. Using the channel object, we, first of all, unsubscribe from the channel's events, then rejoin the channel. On successfully joining the channel, we get all the messages in the channel and also listen for new events on the channel.

Next, add a function to add a message to a channel. Once a user types a message and hits enter, we'll call the below function to add a new message to the channel:

 

addMessage(message) {
	if (chatChannel) {
		chatChannel.sendMessage(message);
	}
},

Next, add a function for creating a new channel:

addChannel(uniqueName) {
	Client.createChannel({
		uniqueName: uniqueName,
		// friendlyName: 'The homie channel'
	}).then((channel) => {
			this.channels.push(channel.state)
		});
},

We'll call this function when a user submits a channel name they want to create.

Finally, when a user changes a channel, we want to fetch messages for the channel and display it to the user. Add a function to do that:

showMessages(channel) {
	Client.getChannelByUniqueName(channel.uniqueName)
		.then( channel => {
			this.setupChannel(channel)
		});
},

Test the App

Our chat is ready! To test it,

  • Open two different tabs in your browser and load the application.
  • In both tabs, enter a username and password and then log in. Any username and password should work.
  • Create a channel, then start exchanging messages.

Conclusion

And that is it! In this tutorial, we have explored some of the Twilio Programmable Chat API. We have used the API to build a simple group chat app using Symfony and Vue. There are lots of the API features which we did not explore that would make the chat more lively. You can explore more of these features on the documentation page.

You can also get the complete code of the project on GitHub.