Build a live game updates site with Laravel

Introduction

In this tutorial, we’ll build a site that allows users to follow the progress of an ongoing sports match. There’ll be a “back office” where site admins can post details about match events as they happen. These events will be shown in realtime on the frontend. Here’s what the completed app looks like in action:

laravel-live-blog-demo

Let’s go!

Prerequisites

Setting up

First, create a new Laravel project:

    composer create-project --prefer-dist laravel/laravel live-game-updates

Open up the generated project folder (live-game-updates). Remove all the lines in your .env file that start with DB_ and replace them with:

1DB_CONNECTION=sqlite
2    DB_DATABASE=database/database.sqlite

Then create a file called database.sqlite in the database folder of your app.

Authentication

Our app will require admin users to be logged in, so let’s set that up. Run the following command to set up Laravel’s included auth system:

    php artisan make:auth

Next, let’s add our admin user. Open up the file database/migrations/2014_10_12_000000_create_users_table.php, and modify the up method so it looks like this:

1// database/migrations/2014_10_12_000000_create_users_table.php
2    
3    public function up()
4    {
5        Schema::create('users', function (Blueprint $table) {
6            $table->increments('id');
7            $table->string('name');
8            $table->string('email')->unique();
9            $table->timestamp('email_verified_at')->nullable();
10            $table->string('password');
11            $table->rememberToken();
12            $table->timestamps();
13        });
14        \App\User::create([
15            'name' => "Admin",
16            'email' => "admin@live-game-updates.com",
17            'password' => \Illuminate\Support\Facades\Hash::make('secret'),
18        ]);
19    }

Now let’s run our migrations, so the database is set up and our admin user created:

    php artisan migrate

Building the views

First, we’ll build the home page. It shows a list of active games. If the user is logged in as the admin, it will also display a form to start recording a new game. Replace the contents of home.blade.php in the directory resources/views with the following:

1<!-- resources/views/home.blade.php -->
2    @extends('layouts.app')
3    
4    @section('content')
5        <div class="container">
6            <h2>Ongoing games</h2>
7            @auth
8                <form method="post" action="{{ url('/games') }}" class="form-inline">
9                  @csrf
10                  <input class="form-control" name="first_team" placeholder="First team" required>
11                  <input class="form-control" name="second_team" placeholder="Second team" required>
12                  <input type="hidden" name="first_team_score" value="0">
13                  <input type="hidden" name="second_team_score" value="0">
14                  <button type="submit" class="btn btn-primary">Start new game</button>
15                </form>
16            @endauth
17            @forelse($games as $game)
18                <a class="card bg-dark" href="/games/{{ $game->id }}">
19                    <div class="card-body">
20                        <div class="card-title">
21                            <h4>{{ $game->score }}</h4>
22                        </div>
23                    </div>
24                </a>
25            @empty
26                No games in progress.
27            @endforelse
28        </div>
29    @endsection

Next up is the view for a single game. Here we show the game’s score at the top and a list of events in reverse order below it. For the admin user, this view will also have a form where the user can post a report of a game event. The score displayed at the top will also be editable by an admin. Create the file game.blade.php in the directory resources/views with the following content:

1<!-- resources/views/game.blade.php -->
2    
3    @extends('layouts.app')
4    
5    @section('content')
6        <div id="main" class="container" xmlns:v-on="http://www.w3.org/1999/xhtml">
7            <h2>@{{ game.first_team }}
8                <span @auth contenteditable @endauth v-on:blur="updateFirstTeamScore">@{{ game.first_team_score }}</span>
9                -
10                <span @auth contenteditable @endauth v-on:blur="updateSecondTeamScore">@{{ game.second_team_score }}</span>
11                @{{ game.second_team }}</h2>
12            @auth
13                <div class="card">
14                    <div class="card-body">
15                        <form v-on:submit="updateGame">
16                            <h6>Post a new game update</h6>
17                            <input class="form-control" type="number" id="minute" v-model="pendingUpdate.minute"
18                                   placeholder="In what minute did this happen?">
19    
20                            <input class="form-control" id="type" placeholder="Event type (goal, foul, injury, booking...)"
21                                   v-model="pendingUpdate.type">
22    
23                            <input class="form-control" id="description" placeholder="Add a description or comment..."
24                                   v-model="pendingUpdate.description">
25    
26                            <button type="submit" class="btn btn-primary">Post update</button>
27                        </form>
28                    </div>
29                </div>
30            @endauth
31            <br>
32            <h4>Game updates</h4>
33            <div class="card-body" v-for="update in updates">
34                <div class="card-title">
35                    <h5>@{{ update.type }} (@{{ update.minute }}')</h5>
36                </div>
37                <div class="card-text">
38                    @{{ update.description }}
39                </div>
40            </div>
41        </div>
42        <script>
43            window.updates = @json($updates);
44            window.game = @json($game);
45        </script>
46    @endsection

We’re making the score elements editable by admins using the contenteditable attribute. This makes it possible for a user to click on the score and enter a new value. Once they click outside, we’ll update the value on the backend.

We’ll be using Vue to render and manage this view, but let’s come back to that later. For now, we’ll move on to adding the routes. Edit your routes/web.php so it looks like this:

1// routes/web.php
2    <?php
3    
4    Auth::routes();
5    
6    Route::get('/', 'HomeController@index')->name('home');
7    Route::get('/games/{id}', 'HomeController@viewGame');
8    Route::post('/games', 'HomeController@startGame')->middleware('auth');
9    Route::post('/games/{id}', 'HomeController@updateGame')->middleware('auth');
10    Route::post('/games/{id}/score', 'HomeController@updateScore')->middleware('auth');

We have five routes, not counting our authentication routes:

  • The home page, which shows a list of games (and allows the admin to start a new game).
  • The single game view, where a viewer can see updates on a particular game, and an admin can post new updates.
  • The route to start a new game.
  • The route to post a game update.
  • The route to update the game’s score.

The last two are only accessible by admins.

Implementing the core logic

Now, we’ll implement the logic for recording games. First, we’ll add Game and Update models. Run the following commands to create the models and their corresponding database migrations:

1php artisan make:model -m Game
2    php artisan make:model -m Update

Now let’s edit the generated migration files. Open up the CreateGamesTable migration (you’ll find it in the database/migrations folder) and replace its contents with the following:

1// database/migrations/201*_**_**_*****_create_games_table
2    <?php
3    
4    use Illuminate\Support\Facades\Schema;
5    use Illuminate\Database\Schema\Blueprint;
6    use Illuminate\Database\Migrations\Migration;
7    
8    class CreateGamesTable extends Migration
9    {
10        public function up()
11        {
12            Schema::create('games', function (Blueprint $table) {
13                $table->increments('id');
14                $table->string('first_team');
15                $table->string('second_team');
16                $table->string('first_team_score');
17                $table->string('second_team_score');
18                $table->timestamps();
19            });
20        }
21        
22        public function down()
23        {
24            Schema::dropIfExists('games');
25        }
26    }

Also replace the contents of the CreateUpdatesTable migration with this:

1// database/migrations/201*_**_**_******_create_updates_table
2    <?php
3    
4    use Illuminate\Support\Facades\Schema;
5    use Illuminate\Database\Schema\Blueprint;
6    use Illuminate\Database\Migrations\Migration;
7    
8    class CreateUpdatesTable extends Migration
9    {
10        public function up()
11        {
12            Schema::create('updates', function (Blueprint $table) {
13                $table->increments('id');
14                $table->unsignedInteger('game_id');
15                $table->unsignedInteger('minute');
16                $table->string('type');
17                $table->string('description');
18                $table->timestamps();
19            });
20        }
21    
22        public function down()
23        {
24            Schema::dropIfExists('updates');
25        }
26    }

Now run php artisan migrate so our database tables get created.

Let’s update the models. Replace the contents of the Game model with the following:

1// app/Game.php
2    <?php
3    namespace App;
4    
5    use Illuminate\Database\Eloquent\Model;
6    
7    class Game extends Model
8    {
9        protected $guarded = [];
10        
11        protected $appends = ['updates', 'score'];
12    
13        public function getUpdatesAttribute()
14        {
15            return Update::orderBy('id desc')->where('game_id', '=', $this->id)->get();
16        }
17        
18        // return the game score in the format "TeamA 1 - 0 TeamB"
19        public function getScoreAttribute()
20        {
21            return "$this->first_team $this->first_team_score - $this->second_team_score $this->second_team";
22        }    
23    }

Here, we’ve configured the updates property of a game to return all updates posted for it in reverse chronological order (most recent first). We’ve also added a score attribute that will display the score in a common format.

Replace the contents of the Update model with the following:

1// app/Update.php
2    <?php
3    namespace App;
4    
5    use Illuminate\Database\Eloquent\Model;
6    
7    class Update extends Model
8    {
9        protected $guarded = [];
10    }

Finally, back to the controller to complete our routing logic. We’ll write methods that handle each of the routes we defined above. Add the following methods in your HomeController class:

First, the index method, which renders the homepage with a list of games:

1// app/Http/Controllers/HomeController.php
2    
3        public function index()
4        {
5            $games = \App\Game::all();
6            return view('home', ['games' => $games]);
7        }

The viewGame method shows a single game and its updates:

1// app/Http/Controllers/HomeController.php
2    
3    public function viewGame(int $id)
4    {
5        $game = \App\Game::find($id);
6        $updates = $game->updates;
7        return view('game', ['game' => $game, 'updates' => $updates]);
8    }

The startGame method creates a new game with the provided data and redirects to that game’s page:

1// app/Http/Controllers/HomeController.php
2    
3    public function startGame()
4    {
5        $game = \App\Game::create(request()->all());
6        return redirect("/games/$game->id");
7    }

The updateGame method creates a new game update:

1// app/Http/Controllers/HomeController.php
2    
3    public function updateGame(int $id)
4    {
5        $data = request()->all();
6        $data['game_id'] = $id;
7        $update = \App\Update::create($data);
8        return response()->json($update);
9    }

And the updateScore method updates the game’s score:

1// app/Http/Controllers/HomeController.php
2    
3    public function updateScore(int $id)
4    {
5        $data = request()->all();
6        \App\Game::where('id', $id)->update($data);
7        return response()->json();
8    }

Lastly, delete the __construct method in the HomeController class. Its only function is to attach the auth middleware to all the routes, which we don’t want.

Connecting the frontend to the backend

Now we need to complete the view for the game updates using Vue.js. Open up the file resources/js/app.js and replace its contents with the following:

1// resources/js/app.js
2    
3    require('./bootstrap');
4    
5    window.Vue = require('vue');
6    
7    const app = new Vue({
8        el: '#main',
9    
10        data: {
11                updates,
12                game,
13                pendingUpdate: {
14                    minute: '',
15                    type: '',
16                    description: ''
17                }
18        },
19    
20        methods: {
21            updateGame(event) {
22                event.preventDefault();
23                axios.post(`/games/${this.game.id}`, this.pendingUpdate)
24                    .then(response => {
25                        console.log(response);
26                        this.updates.unshift(response.data);
27                        this.pendingUpdate = {};
28                    });
29            },
30    
31            updateScore() {
32                const data = {
33                    first_team_score: this.game.first_team_score,
34                    second_team_score: this.game.second_team_score,
35                };
36                axios.post(`/games/${this.game.id}/score`, data)
37                    .then(response => {
38                        console.log(response)
39                    });
40            },
41    
42            updateFirstTeamScore(event) {
43                this.game.first_team_score = event.target.innerText;
44                this.updateScore();
45            },
46    
47            updateSecondTeamScore(event) {
48                this.game.second_team_score = event.target.innerText;
49                this.updateScore();
50            }
51        }
52    });

Finally, install dependencies:

    npm install

You can take the app for a test drive right now. Run npm run dev to compile the JavaScript, then php artisan serve to start the app on http://localhost:8000. To log in, visit /login and log in as admin@live-game-updates.com (password: “secret”). You’ll then be able to start recording new games and post updates.

Updating game score and events in realtime

Now, we’ll add the realtime component using Pusher Channels. First, pull in the server and client libraries by running:

1composer require pusher/pusher-http-laravel
2    npm i pusher-js

Then go to the Pusher dashboard and create a new app. Copy your app credentials from the App Keys section and add them to your .env file:

1PUSHER_APP_ID=your-app-id
2    PUSHER_APP_KEY=your-app-key
3    PUSHER_APP_SECRET=your-app-secret
4    PUSHER_APP_CLUSTER=your-app-cluster

Next, we’ll update the controller so the updateGame and updateScore method publish the updated values via Pusher Channels.

1// app/Http/Controllers/HomeController.php
2    
3    public function updateGame(int $id, \Pusher\Laravel\PusherManager $pusher)
4    {
5        $data = request()->all();
6        $data['game_id'] = $id;
7        $update = \App\Update::create($data);
8        $pusher->trigger("game-updates-$id", 'event', $update, request()->header('x-socket-id'));
9        return response()->json($update);
10    }
11    
12    public function updateScore(int $id, \Pusher\Laravel\PusherManager $pusher)
13    {
14        $data = request()->all();
15        $game = \App\Game::find($id);
16        $game->update($data);
17        $pusher->trigger("game-updates-$id", 'score', $game, request()->header('x-socket-id'));
18        return response()->json();
19    }

We’re making use of the X-Socket-Id header so that Pusher Chanels does not rebroadcast the event to the browser window that sent it (see more here).

Finally, we’ll update our Vue app so it updates to match the changes. Add this to the end of your app.js:

1// resources/js/app.js
2    
3    window.Pusher = require('pusher-js');
4    Pusher.logToConsole = true;
5    
6    const pusher = new Pusher(process.env.MIX_PUSHER_APP_KEY, {
7        cluster: process.env.MIX_PUSHER_APP_CLUSTER
8    });
9    
10    pusher.subscribe(`game-updates-${app.game.id}`)
11        .bind('event', (data) => {
12            app.updates.unshift(data);
13        })
14        .bind('score', (data) => {
15            app.game.first_team_score = data.first_team_score;
16            app.game.second_team_score = data.second_team_score;
17        });

Here, we set up our Pusher Channels client and listen for the event and score events on the game updates channel, and update the corresponding values of the Vue app. Vue will automatically update the view with the new values.

All done! Time to try our app out. Compile the JavaScript by running:

    npm run dev

Then start the app by running:

    php artisan serve

Visit /login and log in as admin@live-game-updates.com (password: “secret”).

Use the form on the home page to start a new game. You’ll be redirected to that game’s page. Open that same URL in an incognito window (so you can view it as a logged-out user).

Make changes to the game’s score by clicking on the scores and entering a new value. The score will be updated once you click on something else.

You can also post updates by using the form on the page. In both cases, you should see the scores and game updates in the incognito window update in real-time.

Conclusion

We’ve built a useful and simple project that can be used to provide realtime updates on a local sports league, for instance. This type of tech powers many sites in the real world, and I hope you had fun working with it. The source code of the completed application is available on GitHub.