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:
Let’s go!
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.
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
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 last two are only accessible by admins.
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.
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.
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.
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.