Create a Real-Time Polling App using Twilio Sync and Laravel PHP

May 03, 2019
Written by
Christopher Vundi
Contributor
Opinions expressed by Twilio contributors are their own

Create a Real-Time Polling App using Twilio Sync and Laravel PHP.png

Real-time updates is a feature that is becoming increasingly common in modern web applications. There are several hosted API services that provide real-time functionality to web and mobile applications and Twilio Sync is one of these services.

Sync relies on the concept of state synchronization while making use of sync objects. Sync objects are the primitives you use to make your application's state discoverable and accessible at the right granularity. There are four different object primitives each with a different use case - I won't be talking about the different primitives but this document is a good place to start.

In this tutorial, we'll look at how we can add real-time functionality to a Laravel app while making use of Message Stream sync objects.

NOTE:  Sync Message Streams let you broadcast JSON messages at a high rate to an elastic group of subscribers.

I chose Sync Message Streams due to their low-latency and near-zero lag publish/subscribe tool for real-time apps; a good complement to Sync’s high-fidelity state synchronisation primitives.

What We'll be Building

We are going to build a real-time polls application with Laravel, Vue and Chart.js while making use of Twilio Syncs. The complete code for this tutorial can be found on GitHub.

Note: A basic understanding of Laravel and Vue is required for this tutorial. Let’s get started!

Laravel Events and Broadcasting

Real-time functionality in an app is achieved through a pub/sub mechanism. To publish the message, we need to broadcast the message from the backend and then listen to the broadcasted message on the frontend.

Laravel by default uses log as the Broadcasting driver but in this tutorial, we'll look at how to extend Laravel's \Broadcaster class and create our own custom driver that makes use of Twilio sync natively. Please read this tutorial before proceeding. We'll be building on top of the application we came up with at the end of the tutorial.

With a working custom driver, we can now proceed to build out the other parts of our application.

The Voting Component

When doing development work with Laravel, I prefer using Vue for any front-end work which requires that we write some Javascript. I am also a big fan of Vue's component-based approach when building out front-ends.

For this tutorial, we'll be creating a Voting component responsible for visualizing the poll results, as well as casting votes.

$ touch resources/js/components/Voting.vue

Next, we register this component inside the resources/js/app.js file:

Vue.component('voting', require('./components/Voting.vue').default);

To speed things up in terms of view design, we'll run the php artisan make:auth command which adds authentication to Laravel apps as well as provide some basic layout for blade templates. Make sure you have your database setup and defined in the .env file before running the make:auth command.

Once auth has been added to the app, a home view is created which serves as the homepage page for logged in users. We'll be displaying poll results in this newly created home.blade.php template.

Get rid of everything inside the resources/views/home.blade.php template and replace its contents with this:

@extends('layouts.app')

@section('content')
<voting></voting>
@endsection

Let's then require Chart.js and Lodash in our application before updating the contents of the Voting.vue component. Chart.js will help us with the visualization aspect of the poll results while Lodash comes with a handful of methods that make it easy to work with objects and arrays in a Javascript environment:

$ npm install chart.js lodash --save

Let's now lay out the component structure in resources/js/components/Voting.vue

<template>
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <div class="card card-default">
                    <div class="card-header">Votes</div>
                    <div class="candidates">
                      <!-- Buttons to cast votes will go in here -->
                    </div>
                    
                    <!-- Our focus right now -->
                    <div class="card-body">
                        <canvas id="myChart"></canvas>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
    import Chart from 'chart.js'
    import _ from "lodash";

    export default {
        data() {
            return {
                candidates: [],
                chart: null,
            }
        },
        methods: {
            drawChart() {
              let ctx = document.getElementById("myChart");
              this.chart = new Chart(ctx, {
                  type: 'bar',
                  data: {
                      labels: ['candidate 1', 'Candidate 2'],
                      datasets: [{
                          label: '# of Votes',
                          data: [5, 8],
                          borderWidth: 1
                      }]
                  },
                  options: {
                    scales: {
                        yAxes: [{
                            ticks: {
                                beginAtZero:true
                            }
                        }]
                    }
                  }
              });
            }
        },
        mounted() {
            this.drawChart()
        }
    }
</script>

Some breakdown on what is happening above:

<template>
    [...]
    <div class="card-body">
      <canvas id="myChart"></canvas>
    </div>
    [...]
</template>

Chart.js works by making use of the HTML5 canvas. That's why we use a <canvas> tag instead of the normal HTML <div> tags.

Let's also have a look at what is happening inside the <script> block:

import Chart from 'chart.js'
import _ from "lodash";

We first import the modules we downloaded earlier. Now with Chart.js in place, we can comfortably draw a chart.

drawChart() {
    let ctx = document.getElementById("myChart");
    this.chart = new Chart(ctx, {
        type: 'bar',
        data: {
            labels: ['candidate 1', 'Candidate 2'],
            datasets: [{
                label: '# of Votes',
                data: [5, 8],
                borderWidth: 1
            }]
        },
        options: {
          scales: {
              yAxes: [{
                  ticks: {
                      beginAtZero:true
                  }
              }]
          }
        }
    });
  }

None of the code above is Vue specific. This is actually code from the Chart.js documentation.

If you’ve never used Chart.js before, you can have multiple datasets, each with their own configuration and data. The catch is that the data size must match the size of your labels. So in the above example, we have two labels and two pieces of data to plot. We'll make the chart dynamic later on.

In this particular case, we draw the chart once the Vue instance has been mounted. Vue's official documentation is a good starting point if you want to gain a better understanding of the various life cycle hooks in Vue applications. Later on, we'll be listening to published messages inside the mounted hook.

Up to this point, you can test things out by running npm run watch to serve your application. You should be presented with a chart that looks like this:

Sample chart.js data

It's time we made this Chart dynamic.

The Backend

We'll create a Candidate model and migration as well.

$ php artisan make:model Candidate -m

Each of the candidates should have a name attribute as well as a votes_count attribute.

Let's update the up method of the newly created migration to implement these attributes. As a reminder, migrations are located in database/migrations

public function up()
{
    Schema::create('candidates', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name');
        $table->unsignedInteger('votes_count')->default(0);
    });
}

We can now run migrations to create the candidates' table:

$ php artisan migrate

For demonstration purposes, we'll only create two candidates for this tutorial. You can have as many candidates as you wish. To create a candidate, we first have to make name part of the fillable attributes in the Candidate model.

# app/Candidate.php
protected $fillable = ['name'];
public $timestamps = false; // we don't have timestamps in our migration

With this in place, let's fire up Laravel’s Psy shell and create the two candidates.

$ php artisan tinker
>> App\Candidate::create(['name' => 'X'])
>> App\Candidate::create(['name' => 'Y'])

All set. For each vote cast using this platform, we'll increment the candidate's votes count by 1.

Our next task is defining the various routes and controller actions responsible for incrementing votes. Add the following routes to routes/web.php.

Route::get('/candidates', 'HomeController@candidates');
Route::post('/candidates/{candidate}', 'HomeController@incrementVotes');

Since this is a simple app, we'll handle almost everything inside the HomeController. It’s not the best practice for your production-ready app, but it does the job for now:

*app/Http/Controllers/HomeController*

...]
use App\Candidate;
[...]
public function incrementVotes(Candidate $candidate)
{
    $candidate->increment('votes_count');
    return $this->candidates();
}

public function candidates()
{
    return response()->json([
        'data' => Candidate::all()
    ]);
}

To those who had to generate the HomeController manually via the make:controller command earlier, you'll notice the file contents have been overwritten after running make:auth. You can refer to this file and update the contents as required.

The incrementVotes() method adds the votes count for a particular candidate by 1, then returns the total votes count for all the candidates. This is made possible by the candidates() method which returns a formatted response in JSON format.

Having defined the relevant routes and controller actions, we can now update the drawChart() method inside our Voting component to take two arguments. i.e:

drawChart(names, votes) {  
  // code to draw chart goes in here
}

Remember Chart.js takes in data that is already formatted? The names argument here is an array of the candidates' names while the votes argument is an array of votes.

To appropriately visualize this data, we have to make sure the index of the candidate's name in the names array corresponds to the index of the number of votes for that particular candidate in the votes array.

We also need to replace the placeholders for the labels and data in the Chart instance with dynamic values:

drawChart(names, votes) {  
  let ctx = document.getElementById("myChart");
    this.chart = new Chart(ctx, {
        type: 'bar',
        data: {
            labels: names, // -> This,
            datasets: [{
                label: '# of Votes',
                data: votes, // -> This,
                borderWidth: 1
            }]
        },
}

We are almost done, but some GUI is needed to let users cast votes. I'll use a button approach where each candidate will be represented by a button with their name on that button.

Update the <template> section of the Voting.vue component to include buttons. Do this inside the <div class="candidates"> that sits right above the chart.

<div class="candidates">
    <ul>
        <li v-for="candidate in candidates"><button @click="incrementVotes(candidate.id)">{{ candidate.name }}</button></li>
    </ul>
</div>

Reloading the page at this point won't display any buttons since we've not supplied the candidates reactive data property with any data. To fix this, we'll fetch all the candidates in the created life cycle hook, then call drawChart() with the new data:

created() {
  axios.get('/candidates')
    .then((response) => {
      this.candidates = response.data.data;
      this.drawChart(_.map(this.candidates, 'name'), _.map(this.candidates, 'votes_count'))
    }).catch((error) => {
      console.error(error)
    })
},

Above, we make an API request to the /candidates route we defined earlier. A successful response contains all the candidates' data which we then assign to the candidates reactive data property:

 

this.candidates = response.data.data;

This is required for the buttons that let us vote to show up. After doing that, we then format the candidates' data in a way that lets us draw the chart dynamically. Try reloading the page and you should be presented with a page that looks like this:

Empty poll

So far, none of these candidates have any votes.

Casting Votes

For each button click, we'll be calling an incrementVotes() method which takes in the candidate id as the param. As the name suggests, this method will be responsible for incrementing the votes for that candidate:

methods: {
  incrementVotes(candidate_id) {
      axios.post('/candidates/' + candidate_id, {})
      .then((response) => {
        let candidates = response.data.data
        this.drawChart(_.map(candidates, 'name'), _.map(candidates, 'votes_count'))
      }).catch((error) => {
          console.error(error)
      })
  }
}

Inside the method, we are making a post request to the candidates' route we defined earlier.

Route::post('/candidates/{candidate}', 'HomeController@incrementVotes');

For successful requests, we update our chart with the new data. The code inside the response block is somewhat repetitive and could be refactored. You can do that later.

Our chart works perfectly fine at this point. Try casting a vote and you'll notice the results are correctly visualized on the current browser tab. The chart is not real-time though as someone else viewing the results from a different browser needs to refresh their browser every now and then for them to get the correct representation of the most recent poll results. This is where Twilio Sync comes in.

Real-time Updates

For real-time updates, we need to fire an event every time a vote is cast, then broadcast the event to our subscribers using the custom driver we built earlier.

Let's start by creating an incrementVotes event:

$ php artisan make:event IncrementVotes

We want to broadcast the candidates' data to a public channel each time a new vote is cast. When working with Laravel, Event classes have to implement the ShouldBroadcast interface for broadcasts to work. We also need to specify the channel the event should broadcast on.

The snippet below shows what the IncrementVotes event will look like:

...]
class IncrementVotes implements ShouldBroadcast
{
  public $candidates;
  
  public function __construct($candidates)
  {
      $this->candidates = $candidates;
  }

  public function broadcastOn()
  {
      return new Channel('votes');
  }
}

For those new to Laravel events and broadcasting, the official documentation will get you up and running. Let's then update the incrementVotes method inside the HomeController to fire our event every time a new vote is cast:

...]
use App\Events\IncrementVotes;
[...]
public function incrementVotes(Candidate $candidate)
{
    $candidate->increment('votes_count');
    // Exclude current user from the broadcast's recipients:
    broadcast(new IncrementVotes($this->candidates()))->toOthers();
    return $this->candidates();
}

The last item to implement is subscribing to the votes channel on the front-end and listening for the broadcasted events. We'll need the Twilio Sync SDK for Javascript for this:

$ npm install --save twilio-sync

Once the SDK has been installed, we need to make our Voting component aware of this SyncSDK. Let's update app/resources/js/Voting.vue.

import SyncClient from 'twilio-sync';

Ideally, we should listen to broadcasted events once our Vue instance has been mounted. Also note, a valid token is required to initialize the Sync Client in our browser. Since we already have a way to get this token inside the HomeController, we'll inject this token in our home view from the index method.

Let's start by updating the index method to this:

public function index()
{
    return view('home', ['token' => $this->getToken()]);
}

Once that is done, we pass this token to our Voting component as a prop in resources/views/home.blade.php

<voting :token="'{{ $token }}'"></voting>

Inside our voting component, we also need to declare a props property and pass token as one of the expected props. That's basically how props work:

export default {
  props: ['token'],
  data() {

Now to the fun bit! Listening for broadcasted events:

mounted() {
    // this.drawChart() -> We don't need this anymore
    let token = this.token; // -> gets us the token
    let syncClient = new SyncClient(token);

    syncClient.on('connectionStateChanged', (state) => {
      if (state === 'connected') {
          syncClient.stream('votes').then((stream) => {
              stream.on('messagePublished', (args) => {
                  let candidates = args.message.value.payload.candidates.original.data
                  this.drawChart(_.map(candidates, 'name'), _.map(candidates, 'votes_count'));
              });
          });
      }
    });
},

Nothing new here. This code is exactly the same as the code from the other tutorial. The only difference here is that we are subscribing to the votes channel. Once the broadcasted message hits the front-end, we draw our chart with the new data. This should be captured in every other tab that is open, in real-time.

Testing Things Out

To test, open up your website in two browsers. Make sure you are logged in as different users in the two browsers. Cast a vote on one tab. Notice the new results are updated in both browsers? Below is a gif showing the sample application in action:

HyHz98s.gif

 

Conclusion and Next Steps

Congratulations if you've made it to this point. I hope you learned a thing or two. We covered extending Laravel's default broadcast driver as well as visualizing data in Realtime using Chart.js and Twilio Sync.

Our application does not have a limit as to the number of times one can vote. As a recommendation, you could restrict users from voting twice as you learn some more Vue.

If you liked the tutorial, please hit the like button and don't forget to share with friends. Cheers!

Chris is a backend software developer primarily using PHP for development. When not coding Chris prefers going to concerts and travelling. You can reach him on Twitter via the handle @vickris_ and on GitHub using @vickris.