Prepare Your Server

To implement the code from this tutorial you'll need to install Redis, node.js, and npm to your machine. I'm not going to explain it here, this is fairly simple, just google for instruction for your OS.

Understanding the Architecture

The whole system will consist of the following parts:

  • A traditional web server running a Laravel 5 project. The project will have the chat app's backend and frontend.
  • A Node.js server, which will handle the chat message transportation
  • A Redis server, which will be used to pass messages from Laravel's backend to the Node.js server
  • Socket.io JavaScript library, which will allow real-time, bi-directional communication between the Node.js server and Laravel's frontend

Don't worry if you don't know how to work with those or the whole thing seems confusing. That's what this lesson is primarily for. I do expect you to be familiar with Laravel and Vue.js, though (or at the very least with Laravel alone).

Creating the Project

We'll start with a traditional non-real-time Laravel app and we'll make it real-time later on. Bear with me as the most interesting part will be at the end, since I wanted to make a full working app and not just demonstrate the "real-time app with sockets" part. So go ahead and create a new Laravel project.

laravel new irc-chat

Then, create a new database and enter the required credentials into the .env file.

Migrations and Seeds

We'll have 2 tables in the database - one to store the list of available channels and one to store messages in those channels. We won't have actual users, so the chat will be anonymous. Each user will simply get a random username upon entering the chat. Anyway, run the commands below to create 2 models and 2 corresponding migration files.

php artisan make:model Channel -m
php artisan make:model Message -m

Now let's add all the required fields to the migrations. First the channels table:

Schema::create('channels', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name')->unique();
    $table->timestamps();
});

And then the messages table:

Schema::create('messages', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('channel_id');
    $table->string('author_username');
    $table->text('message');
    $table->timestamps();
});

Let's also create a seeder to seed a few random channels.

php artisan make:seed ChannelsSeeder

Now open ChannelsSeeder and edit it like this:

<?php

use Illuminate\Database\Seeder;
use App\Channel;

class ChannelsSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        Channel::truncate();

        $channels = [
            'Web Development',
            'Android Development',
            'iOS Development',
            'AI',
        ];

        foreach ($channels as $name) {
            Channel::forceCreate(['name' => $name]);
        }
    }
}

Finally, register the seeder in database/seeds/DatabaseSeeder.php:

public function run()
{
    $this->call(ChannelsSeeder::class);
}

Now you can run the migrations with the seeder:

php artisan migrate --seed

Fixing Frontend Assets

First, let's remove all the unnecessary dependencies from the devDependencies section of the package.json file:

"devDependencies": {
    "axios": "^0.18",
    "cross-env": "^5.1",
    "laravel-mix": "^2.0",
    "vue": "^2.5.7"
}

Save the file and run npm install. You've probably noticed that I've also removed Bootstrap. Let's go ahead and install Bulma instead.

npm install bulma --save-dev

Now we should clean the asset files up. First open resources/assets/js/bootstrap.js and make it look like this:

import axios from 'axios';
import Vue from 'vue';

window.axios = axios;
window.Vue = Vue;

window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

let token = document.head.querySelector('meta[name="csrf-token"]');

if (token) {
    window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
} else {
    console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token');
}

Note how we've also switched from the require() syntax to ES6's import. Next edit app.js from the same folder:

require('./bootstrap');

const app = new Vue({
    el: '#app'
});

Now to the resources/assets/sass folder. Delete the _variables.scss file and edit the app.scss file like this:

// Bulma
@import '~bulma/bulma';

Main Layout

Don't think about it too much, just copy and paste everything. This is the basic layout for our chat app.

First, edit the welcome.blade.php view file:

<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>IRC-like Chat Tutorial from Voerro</title>

    <link rel="stylesheet" href="/css/app.css">
</head>
<body>
    <div id="app">
        <vue-chat></vue-chat>
    </div>

    <script src="/js/app.js"></script>
</body>
</html>

As you can see, we're delegating all the important work to the new vue-chat component. Let's import that component in resources/assets/js/app.js.

require('./bootstrap');

// All the Vue component imports will go here
Vue.component('vue-chat', require('./components/VueChat.vue'));

const app = new Vue({
    el: '#app'
});

Then create the component at resources/assets/js/components/VueChat.vue. And here's the component's code:

<template>
    <div class="chat">
        <div class="columns main-wrapper">
            <div class="column is-2">
                channels
            </div>

            <div class="column">
                messages
            </div>

            <div class="column is-2">
                participants
            </div>
        </div>

        <div class="message-input-wrapper">
            new message input
        </div>
    </div>
</template>

<script>
export default {

}
</script>

<style>

</style>

Finally, we need some styling. Create a new file at resources/assets/sass/style.scss:

html, body {
    overflow: hidden;
    background: #000;
    color: #fff;
    font-weight: 500;
    height: 100%;
}

#app {
    height: 100%;
}

.chat {
    height: 100%;
    max-height: 100%;
    display: flex;
    flex-direction: column;
}

.main-wrapper {
    flex: 1;
    overflow: hidden;
    margin: 0 !important;
}

.message-input-wrapper {
    margin: 0;
    display: flex;
    align-items: center;
    padding: .5rem;
}

Don't forget to import it in resources/assets/sass/app.scss:

@import 'style.scss';

To see the results you need to build your assets. Either run npm run dev to build once or boot up a watcher by running npm run watch that will re-build everything on every file change.

Displaying the List of Channels

The main layout is ready and we can begin displaying the real data. Let's start with a list of channels. First, we need to pass channels from our backend to the main view. Open routes/web.php. We'll just put all the code in the routes file to keep things simple as this tutorial is not about the backend per se. In real life you'd use controllers instead.

<?php

use App\Channel;

Route::get('/', function () {
    $channels = Channel::orderBy('name')->get();

    return view('welcome', compact('channels'));
});

Let's pass this data to our main Vue component inside welcome.blade.php:

<vue-chat :channels="{{ $channels }}"></vue-chat>

We've made a bunch of placeholders in VueChat.vue. One by one we'll replace them with individual Vue components. And as you've figured out, we'll start with the channels. Open VueChat.vue and replace the div with the channels text in the template section with this:

<vue-chat-channels :channels="channels"
    :active-channel="activeChannel"></vue-chat-channels>

Then edit the script section like this:

export default {
    props: ['channels'],

    data() {
        return {
            activeChannel: this.channels[0].id,
        };
    },
}

Here we're accepting a prop with the list of channels and creating a data attribute to store the id of the currently active (open) channel. By default it'll always be the first channel on the list.

Let's import the channels Vue component in resources/assets/js/app.js:

Vue.component('vue-chat-channels', require('./components/VueChatChannels.vue'));

And here's the VueChatChannels.vue itself:

<template>
    <div class="column is-2">
        <p v-for="channel in channels"
            :key="channel.id"
            v-text="channel.name"
            class="channel"
            :class="{ 'active': channel.id == activeChannel }"></p>
    </div>
</template>

<script>
export default {
    props: ['channels', 'activeChannel'],
}
</script>

<style scoped>
.column {
    border-right: 2px dotted greenyellow;
}

.channel {
    cursor: pointer;
}

.channel.active {
    background: #666;
}
</style>

As always, nothing fancy. Just printing a list of channels while highlighting the active one.

Displaying Messages for a Channel

Next we'll display messages. Similarly we'll create a separate Vue component for that at resources/assets/js/components/VueChatMessages.vue.

<template>
    <div class="column">
        <p v-for="message in messages"
            :key="message.id"
            class="message"
        >
            <span class="datetime">{{ message.created_at }}</span>

            <span class="username"><{{ message.author_username }}></span>

            <span class="message-text">{{ message.message }}</span>
        </p>
    </div>
</template>

<script>
export default {
    props: ['messages'],
}
</script>

<style scoped>
.column {
    overflow-y: auto;
}

.message {
    background: #000;
}

.datetime {
    color: turquoise;
}

.username {
    color: green;
}
</style>

Register the component in resources/assets/js/app.js:

Vue.component('vue-chat-messages', require('./components/VueChatMessages.vue'));

Now replace the div with the messages text in resources/assets/js/components/VueChat.vue with our new component:

<vue-chat-messages :messages="messages"></vue-chat-messages>

The rest is going to be different from the channels component. We're not going to pass the list of messages all the way from the backend down to the messages component. Instead, we're going to fetch the messages via AJAX inside the VueChat.vue component. First, add a new data property to store the messages:

messages: [],

Next, create a method to fetch the messages from the backend:

methods: {
    fetchMessages() {
        let endpoint = `/channels/${this.activeChannel}/messages`;

        axios.get(endpoint)
            .then(({ data }) => {
                this.messages = data;
            });
    }
},

Finally, call this method on component's creation:

created() {
    this.fetchMessages();
},

Now let's implement the endpoint itself inside the routes/web.php file:

Route::get('/channels/{channel}/messages', function (App\Channel $channel) {
    if (!request()->wantsJson()) {
        abort(404);
    }

    $messages = App\Message::where('channel_id', $channel->id)->get();

    return $messages;
});

To see the result you need some messages in the database. We can populate our DB with some fake messages using a factory.

php artisan make:factory MessageFactory

database/factories/MessageFactory.php:

<?php

use Faker\Generator as Faker;

$factory->define(App\Message::class, function (Faker $faker) {
    return [
        'channel_id' => 4,
        'author_username' => $faker->name,
        'message' => $faker->paragraph,
    ];
});

All the fake messages will belong to the channel with id 4 by default. This is a channel called AI in our database (this channel is the first one in the alphabetical order and will be selected by default on page load). Now run php artisan tinker and generate a few messages by executing this line:

factory('App\Message', 10)->create();

Close tinker. Refresh your browser's page and you should see all the fake messages we've just created.

Switching Channels

Let's add a bit of interactivity to our chat. Switching channels is very easy. First, open the resources/assets/js/components/VueChatChannels.vue component. We're going to add a click event to each channel and a click will trigger the setChannel method. We're going to pass the channel's id to the method.

<p v-for="channel in channels"
    :key="channel.id"
    v-text="channel.name"
    class="channel"
    :class="{ 'active': channel.id == activeChannel }"
    @click="setChannel(channel.id)"></p>

And here's the method itself:

methods: {
    setChannel(id) {
        this.$emit('channelChanged', id);
    }
}

The only thing it does is emits an event with the id of the channel to be selected. This will be passed up to the VueChat.vue component, since it is our main hub for all the chat data. Open resources/assets/js/components/VueChat.vue and add an event listener:

<vue-chat-channels :channels="channels"
    :active-channel="activeChannel"
    @channelChanged="onChannelChanged"></vue-chat-channels>

The id of the channel to be selected will be passed to the onChannelChanged method. Inside this method we'll simply update the activeChannel data property and then re-fetch the messages from the backend.

onChannelChanged(id) {
    this.activeChannel = id;

    this.fetchMessages();
}

So far we've created a foundation for our chat app, although at the moment we can only read the existing messages. We will turn it into a real-time multi-user chat in the next part.