How I’m Using Twilio and Laravel to Have a Eurovision Party in Lockdown

May 17, 2021
Written by
Matthew Davis
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

How I’m Using Twilio and Laravel to Have a Eurovision Party in Lockdown

I've always been a fan of Eurovision. Every year one of our group hosts a Eurovision party that is themed around the host country's food and drink. The evening is spent marking each of the acts giving points for categories such as entertainment value, set, staging and props, and not forgetting any costume changes á la Bucks Fizz in 1981. We then tally them all up to come up with our overall winner and see if the jury agrees.

Here in the UK, due to the Coronavirus pandemic, 2021's Eurovision party isn't able to take place as we'd like, so I decided to take matters into my own hands and replicate the voting experience using Twilio and Laravel. Here's how I did it.

The Initial Idea

I decided to create an admin dashboard where I could add my friends as Voters. I wanted to “invite” them after they’d been added to ensure that their details had been entered correctly and that there were no issues sending SMS messages to them. This meant sending them a message to which they could respond with "OK", confirming that they wanted to participate.

I wanted a list of countries in my app and, when each country was due to play, I'd "announce" the country to the voters. When the country had finished performing, I'd open voting, and the app would then allow voters to send a single number from 0 - 30 as their vote’s combined value. I'd then close voting and move on to the next country.

At the end of the process, I could tally up the votes and declare our overall winner. If you don't want to follow along and just want to have this deployed, the full code is available on my Github page.

Setting up Laravel

I knew that I wanted the functionality behind a login as the app would be hosted on a public-facing server, so I spun up a new Laravel app and installed Laravel Breeze. This gave me some boilerplate for an admin dashboard for free.

I next added my Twilio credentials to the environment file and services configuration file and installed the Twilio SDK.

To make sure everything was set up properly, I created a test route:

Route::get('/test', function () {
    $sid = config('services.twilio.sid');
    $token = config('services.twilio.token');
    $messagingService = config('services.twilio.messaging-service');

    $twilio = new Twilio\Rest\Client($sid, $token);

    $twilio->messages->create("+441234567890", [
        'body' => 'Hello from Laravel.',
        'from' => $messagingService,
    ]);
});

With five lines of code, I’d sent my first SMS and confirmed that everything was working as it should. Super!

Making Sending Easier

Whilst the above code was super easy, I also knew that I was going to be sending messages in a number of different places throughout the app. In order to do this, I wrapped the Twilio Client in my own client that I could bind into the container and use everywhere via dependency injection.

My client looked like this:

<?php

namespace App\Client;

use Twilio\Rest\Client;
use Twilio\Rest\Api\V2010\Account\MessageInstance;

class Twilio
{
    /** @var Client */
    private $client;

    public function __construct(string $sid, string $token)
    {
        $this->client = new Client($sid, $token);
    }

    public function sendSms(string $number, string $text, ?string $from = null): MessageInstance
    {
        return $this->client->messages->create(
            $number,
            [
                'body' => $text,
                'from' => $from ?? config('services.twilio.messaging-service'),
            ]
        );
    }
}

And, to bind it into the container, I did the following:

<?php

namespace App\Providers;

use App\Client\Twilio;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Twilio::class, function ($app) {
            return new Twilio(
                config('services.twilio.sid'),
                config('services.twilio.token')
            );
        });
    }
}

This meant that I could now inject my own client and send messages like this:

public function handle(Twilio $twilio): void
{
    $twilio->sendSms('+441234567890', 'Here is a message!');
}

Registering Voters

Now that I had a solid foundation on which to build, I started the process of building the registration flow for voters. My first step was to add some basic CRUD, with the following information per voter:

  • Name
  • Number

In addition to this data, I also added a confirmed_at timestamp so that I could tell who had responded to the invitation and who hadn’t. This enabled me to only send messages to people who had indicated that they wanted to take part.

My Voters administration area now looked like this:

Admin page with button to add voters

Initially, I was going to send an invitation as soon as someone had been added, but I decided instead to have a button on the Dashboard that would allow me to invite everyone at the same time:

Admin page with results

In order to do this, I wired up a controller as follows:

<?php

namespace App\Http\Controllers;

use App\Models\Voter;
use App\Jobs\SendInvitation;
use Illuminate\Http\RedirectResponse;

class SendInvitationsController extends Controller
{
    public function __invoke(): RedirectResponse
    {
        $voters = Voter::notConfirmed()->get();

        $voters->each(function (Voter $voter) {
            SendInvitation::dispatch($voter);
        });

        return redirect()->route('account.dashboard')->with('status', 'Invitations sent!');
    }
}

This dispatches a queued job for each voter, which handles sending the actual invitation to the voter:

<?php

namespace App\Jobs;

use App\Models\Voter;
use App\Client\Twilio;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class SendInvitation implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Voter $voter
    ) {}

    public function handle(Twilio $twilio): void
    {
        $message = <<<EOD
Hi, {$this->voter->name}!

You've been invited to vote on the Eurovision Song Contest final.

In order to play along, please reply with "OK" to this message.

It's going to be fun! 🎶
EOD;

        $twilio->sendSms(
            $this->voter->number,
            $message
        );
    }
}

SMS can look a little plain, so I added an emoji at the bottom of the message. It looks like this:

SMS Invitation to the context

Now that I could invite people, I needed to handle their response.

Twilio offers webhook functionality that will ping your app every time a number (or in my case, a messaging service) receives an SMS. Because I was running this locally for testing, I needed to use ngrok so that Twilio could communicate with my local machine. Because I was using Laravel Valet, this was as simple as typing valet share in the Terminal and copying the URL into my Twilio console.

As I knew this was going to be on a publicly accessible server, I wanted to make sure that any requests coming to the webhook were from Twilio. To do that, I wrote a custom middleware that could be applied to the webhook route:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Twilio\Security\RequestValidator;

class FromTwilio
{
    public function handle(Request $request, Closure $next): mixed
    {
        $requestValidator = new RequestValidator(env('TWILIO_TOKEN'));
        $requestData = $request->toArray();

        // If this is a JSON request, switch to the body content.
        if (array_key_exists('bodySHA256', $requestData)) {
            $requestData = $request->getContent();
        }

        $isValid = $requestValidator->validate(
            $request->header('X-Twilio-Signature'),
            $request->fullUrl(),
            $requestData
        );

        if ($isValid) {
            return $next($request);
        }

        return new Response('Requests to this URL must come from Twilio. :-(', 403);
    }
}

I also needed to disable CSRF protection for the webhook URL, and then I was ready to write the webhook itself.

To enable voters to confirm their participation, I looked for “OK” as an incoming message and if I found it, update the voter with the corresponding phone number to be confirmed. The code for that looks like this:

<?php

namespace App\Http\Controllers\Webhook;

use App\Models\Voter;
use Illuminate\Support\Str;
use App\Jobs\ConfirmVoter;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Client\Twilio as TwilioClient;

class Twilio extends Controller
{
    public function __invoke(Request $request, TwilioClient $twilio)
    {
        if (! $voter = Voter::with('votes')->where('number', '=', $request->From)->first()) {
            return $twilio->sendSms($request->From, 'Sorry. You must be invited to vote. :-(');
        }

        if ('ok' === Str::lower($request->Body)) {
            ConfirmVoter::dispatch($voter);

            return;
        }
    }
}

I made sure to lowercase the incoming body from the request to cater for people responding “OK”, “ok”, “Ok”, and “oK”.

In addition to dispatching a queued job to confirm the voter, I also checked to ensure that the voter existed in the database.

The queued job is straightforward and looks like this:

<?php

namespace App\Jobs;

use App\Models\Voter;
use App\Client\Twilio;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class ConfirmVoter implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Voter $voter
    ) {}

    public function handle(Twilio $twilio): void
    {
        $this->voter->update([
            'confirmed_at' => now(),
        ]);

        $message = <<<EOD
Thanks for confirming, {$this->voter->name}!

Look out for texts during the Eurovision final, and get ready to cast your votes!
EOD;

        $twilio->sendSms(
            $this->voter->number,
            $message
        );
    }
}

The response to confirming registration looks like this:

SMS confirmation to the context

Now that voters could confirm their participation, the next step was to add countries.

Adding Countries

To add countries, I knew that I wanted a basic CRUD setup that had the following pieces of data:

  • Name
  • Flag (an emoji)
  • Song Title
  • Artist

To know which country any incoming votes were for, I also decided to add a currently_voting boolean. I planned to toggle this to true when opening voting for a country and toggle it to false when closing voting.

I also created a seeder to add all of the country and song details to the database. Once all of the country data had been added, my country admin page looked like this:

Countries Dashboard

I added buttons for actions that I knew needed to be performed on the country detail screen. That looked like this:

Editing country from dashboard

I was now ready to announce countries.

Announcing Countries

When a country is being introduced at Eurovision, they usually play an introductory film about it. I decided that during this film I would press the “Announce” button for the corresponding country which would send a text to the voters letting them know the song details.

I hooked up the button to a controller that dispatches a queued job to send a message to each confirmed voter like this:

<?php

namespace App\Http\Controllers;

use App\Models\Voter;
use App\Models\Country;
use App\Jobs\AnnounceCountry;
use Illuminate\Http\RedirectResponse;

class AnnounceCountryController extends Controller
{
    public function __invoke(Country $country): RedirectResponse
    {
        $voters = Voter::confirmed()->get();

        $voters->each(function (Voter $voter) use ($country) {
            AnnounceCountry::dispatch($country, $voter);
        });

        return redirect()->route('account.countries.show', $country)->with('status', 'Country announced!');
    }
}

This dispatches the jobs, that look like this:

<?php

namespace App\Jobs;

use App\Models\Voter;
use App\Client\Twilio;
use App\Models\Country;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class AnnounceCountry implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Country $country,
        public Voter $voter
    ) {
    }

    public function handle(Twilio $twilio): void
    {
        $message = <<<EOD
Next to sing is {$this->country->flag} {$this->country->name} with "{$this->country->song_title}" by {$this->country->artist}.

Stand by for voting to open!
EOD;

        $twilio->sendSms(
            $this->voter->number,
            $message
        );
    }
}

So, for example, when announcing Denmark, the text that the voters receive looks like this:

SMS telling you about what the next song is

Any votes cast during this time don’t count until the voting has been opened, so I decided to tackle that next.

Opening Voting

When opening voting, several steps needed to happen.

First, any other countries that had voting open would need to be closed. Second, the currently_voting flag needed to be set on the database. And third, voters needed to be told that voting was now open.

To encapsulate the first two steps, I added the following method to my Country model:

public function openVoting()
{
    self::where('currently_voting', '=', true)->update(['currently_voting' => false]);

    $this->update(['currently_voting' => true]);
}

Next, I hooked up the “Open Voting” button on the Country detail screen to a controller that looked like this:

<?php

namespace App\Http\Controllers;

use App\Models\Voter;
use App\Models\Country;
use App\Jobs\OpenVoting;
use Illuminate\Http\RedirectResponse;

class OpenVotingController extends Controller
{
    public function __invoke(Country $country): RedirectResponse
    {
        $country->openVoting();

        $voters = Voter::confirmed()->get();

        $voters->each(function (Voter $voter) use ($country) {
            OpenVoting::dispatch($country, $voter);
        });

        return redirect()->route('account.countries.show', $country)->with('status', 'Voting opened!');
    }
}

Again, this dispatches jobs that tell the voters that voting is now open for the country:

<?php

namespace App\Jobs;

use App\Models\Voter;
use App\Client\Twilio;
use App\Models\Country;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class OpenVoting implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Country $country,
        public Voter $voter
    ) {}

    public function handle(Twilio $twilio): void
    {
        $max = config('eurovision.voting.max');

        $message = <<<EOD
Voting for {$this->country->name} is now open!
Reply with a number from 0 - {$max} to cast your vote.
EOD;

        $twilio->sendSms(
            $this->voter->number,
            $message
        );
    }
}

This should look familiar as it’s essentially the same idea that I used to announce a country to the voters.

The message received looks like this:

SMS telling you the voting is now open and you can reply with anything between 0 and 30

Once voting was opened, it was time to accept some votes!

Handling Votes

To handle incoming votes, I knew that I needed a Vote model. I added one with the following properties:

  • Voter ID (so I knew who had cast the vote)
  • Country ID (so I knew which country it was for)
  • Value

I planned to parse anything that looked like a number in the webhook and then cast a vote for the voter.

I also wanted to handle a couple of edge cases:

  • If voting wasn’t open, I wanted to respond by telling the voter that
  • If their vote was out of the allowed value range I wanted to inform them
  • If someone tried to vote twice for the same country, I wanted to stop them

To do that, I whipped up some basic guard clauses in the webhook controller. Then, if the code made it past all of those, I would dispatch a job to cast the vote.

The finished webhook controller looked like this:

<?php

namespace App\Http\Controllers\Webhook;

use App\Models\Voter;
use App\Jobs\CastVote;
use App\Models\Country;
use App\Jobs\ConfirmVoter;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Client\Twilio as TwilioClient;

class Twilio extends Controller
{
    public function __invoke(Request $request, TwilioClient $twilio)
    {
        if (! $voter = Voter::with('votes')->where('number', '=', $request->From)->first()) {
            $twilio->sendSms($request->From, 'Sorry. You must be invited to vote. :-(');

            return;
        }

        if ('ok' === Str::lower($request->Body)) {
            ConfirmVoter::dispatch($voter);

            return;
        }

        if (0 === Country::currentlyVoting()->count()) {
            $twilio->sendSms($request->From, 'Sorry. Voting is currently closed. :-(');

            return;
        }

        if (! is_numeric($request->Body) || (int) $request->Body < 0 || (int) $request->Body > config('eurovision.voting.max')) {
            $twilio->sendSms($request->From, sprintf('You must vote with a value between 0 - %d. Try again.', config('eurovision.voting.max')));

            return;
        }

        $country = Country::currentlyVoting()->first();
        if ($voter->votes->where('country_id', $country->id)->isNotEmpty()) {
            $twilio->sendSms($request->From, "You've already voted for {$country->name}. This vote won't be counted!");

            return;
        }

        CastVote::dispatch($country, $voter, (int) $request->Body);
    }
}

The job to cast a vote looks pretty much the same as the other jobs, with the exception that it creates a vote record in the database:

<?php

namespace App\Jobs;

use App\Models\Vote;
use App\Models\Voter;
use App\Client\Twilio;
use App\Models\Country;
use Illuminate\Foundation\Bus\Dispatchable;

class CastVote
{
    use Dispatchable;

    public function __construct(
        public Country $country,
        public Voter $voter,
        public int $value
    ) {}

    public function handle(Twilio $twilio): void
    {
        Vote::create([
            'voter_id' => $this->voter->id,
            'country_id' => $this->country->id,
            'value' => $this->value,
        ]);

        $twilio->sendSms(
            $this->voter->number,
            "Thanks for your vote for {$this->country->name}!"
        );
    }
}

This job was also set up to be synchronous. If this job was queued, if someone happened to cast a vote, and then cast another vote before the job had been processed, they could potentially cast two votes.

It would probably be a better solution to check for an existing vote in the job and just ignore subsequent votes, but that’s an exercise for next year!

Sending a vote looks like this:

SMS validating that you&#x27;ve already voted

The next step was to close voting before announcing the next country.

Closing Voting

Closing voting needed to take a few steps. First, it needed to close the voting for a country, and second, notify the voters that voting had been closed.

Following the same pattern as the other functionality, I hooked up the button to a controller that closed voting, and then dispatched several queued jobs to notify the voters:

<?php

namespace App\Http\Controllers;

use App\Models\Voter;
use App\Models\Country;
use App\Jobs\CloseVoting;
use Illuminate\Http\RedirectResponse;

class CloseVotingController extends Controller
{
    public function __invoke(Country $country): RedirectResponse
    {
        $country->closeVoting();

        $voters = Voter::confirmed()->get();

        $voters->each(function (Voter $voter) use ($country) {
            CloseVoting::dispatch($country, $voter);
        });

        return redirect()->route('account.countries.show', $country)->with('status', 'Voting closed!');
    }
}

The queued job looks pretty much the same as all of the others:

<?php

namespace App\Jobs;

use App\Models\Voter;
use App\Client\Twilio;
use App\Models\Country;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class CloseVoting implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Country $country,
        public Voter $voter
    ) {}

    public function handle(Twilio $twilio): void
    {
        $message = <<<EOD
Voting for {$this->country->name} is now closed!
EOD;

        $twilio->sendSms(
            $this->voter->number,
            $message
        );
    }
}

The resulting text looks like this:

SMS telling you the voting is now closed

Adding voting being closed completed all of the functionality and I could now run the Eurovision party successfully.

To make things a bit more user-friendly for me during the contest, though, I added some more information to the dashboard.

Displaying Results

Initially, the dashboard didn’t do anything, but I decided to make it a little more useful.

The first thing I did was to add a list of countries that had received votes, ordered by their score. I did this by only selecting countries that had received votes, ordered by a sum of the value of their votes.

Also, in order to keep an eye on this during the contest, but also be able to get back to the currently active country, I added a link to the country at the top of the dashboard. This allowed me to close voting really easily.

The dashboard looks like this:

Admin page with active country

It’s great to be able to see the results as they come in and the order of the countries change as countries change their position in the results.

Summary

Twilio, in combination with Laravel, made writing this Eurovision voting application easy and quick. I had the majority of the code written in a few hours, and I’ve tested it with several friends already who all think it’s awesome.

There are still some features that I’d like to add, such as notifying everyone of the results and some statistics about who scored the countries in the most “correct” order, but they’re features that I can add over time.

Even when we return to having parties, I think we’ll still use this app so that we can also include people who can’t be there, and some of our friends in different parts of the world.

I’m really happy with how it turned out.

Matthew Davis is the Technical Lead at mumsnet.com. Trained at Birmingham Conservatoire of Music in the UK, he used to be a professional musician touring the world on cruise ships and with theatre shows before turning his passion for software development into his career over 15 years ago. Since then, he’s built software for small independent businesses as well as for global companies and regularly contributes to open source.

He can be found on Twitter at @mdavis1982.