Build a live game updates site with Laravel
You will need PHP 7/1+ and Composer installed on your machine.
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!
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:
DB_CONNECTION=sqlite
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:
// database/migrations/2014_10_12_000000_create_users_table.php
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
\App\User::create([
'name' => "Admin",
'email' => "admin@live-game-updates.com",
'password' => \Illuminate\Support\Facades\Hash::make('secret'),
]);
}
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:
<!-- resources/views/home.blade.php -->
@extends('layouts.app')
@section('content')
<div class="container">
<h2>Ongoing games</h2>
@auth
<form method="post" action="{{ url('/games') }}" class="form-inline">
@csrf
<input class="form-control" name="first_team" placeholder="First team" required>
<input class="form-control" name="second_team" placeholder="Second team" required>
<input type="hidden" name="first_team_score" value="0">
<input type="hidden" name="second_team_score" value="0">
<button type="submit" class="btn btn-primary">Start new game</button>
</form>
@endauth
@forelse($games as $game)
<a class="card bg-dark" href="/games/{{ $game->id }}">
<div class="card-body">
<div class="card-title">
<h4>{{ $game->score }}</h4>
</div>
</div>
</a>
@empty
No games in progress.
@endforelse
</div>
@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:
<!-- resources/views/game.blade.php -->
@extends('layouts.app')
@section('content')
<div id="main" class="container" xmlns:v-on="http://www.w3.org/1999/xhtml">
<h2>@{{ game.first_team }}
<span @auth contenteditable @endauth v-on:blur="updateFirstTeamScore">@{{ game.first_team_score }}</span>
-
<span @auth contenteditable @endauth v-on:blur="updateSecondTeamScore">@{{ game.second_team_score }}</span>
@{{ game.second_team }}</h2>
@auth
<div class="card">
<div class="card-body">
<form v-on:submit="updateGame">
<h6>Post a new game update</h6>
<input class="form-control" type="number" id="minute" v-model="pendingUpdate.minute"
placeholder="In what minute did this happen?">
<input class="form-control" id="type" placeholder="Event type (goal, foul, injury, booking...)"
v-model="pendingUpdate.type">
<input class="form-control" id="description" placeholder="Add a description or comment..."
v-model="pendingUpdate.description">
<button type="submit" class="btn btn-primary">Post update</button>
</form>
</div>
</div>
@endauth
<br>
<h4>Game updates</h4>
<div class="card-body" v-for="update in updates">
<div class="card-title">
<h5>@{{ update.type }} (@{{ update.minute }}')</h5>
</div>
<div class="card-text">
@{{ update.description }}
</div>
</div>
</div>
<script>
window.updates = @json($updates);
window.game = @json($game);
</script>
@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:
// routes/web.php
<?php
Auth::routes();
Route::get('/', 'HomeController@index')->name('home');
Route::get('/games/{id}', 'HomeController@viewGame');
Route::post('/games', 'HomeController@startGame')->middleware('auth');
Route::post('/games/{id}', 'HomeController@updateGame')->middleware('auth');
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:
php artisan make:model -m Game
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:
// database/migrations/201*_**_**_*****_create_games_table
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateGamesTable extends Migration
{
public function up()
{
Schema::create('games', function (Blueprint $table) {
$table->increments('id');
$table->string('first_team');
$table->string('second_team');
$table->string('first_team_score');
$table->string('second_team_score');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('games');
}
}
Also replace the contents of the CreateUpdatesTable
migration with this:
// database/migrations/201*_**_**_******_create_updates_table
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateUpdatesTable extends Migration
{
public function up()
{
Schema::create('updates', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('game_id');
$table->unsignedInteger('minute');
$table->string('type');
$table->string('description');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('updates');
}
}
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:
// app/Game.php
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Game extends Model
{
protected $guarded = [];
protected $appends = ['updates', 'score'];
public function getUpdatesAttribute()
{
return Update::orderBy('id desc')->where('game_id', '=', $this->id)->get();
}
// return the game score in the format "TeamA 1 - 0 TeamB"
public function getScoreAttribute()
{
return "$this->first_team $this->first_team_score - $this->second_team_score $this->second_team";
}
}
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:
// app/Update.php
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Update extends Model
{
protected $guarded = [];
}
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:
// app/Http/Controllers/HomeController.php
public function index()
{
$games = \App\Game::all();
return view('home', ['games' => $games]);
}
The viewGame
method shows a single game and its updates:
// app/Http/Controllers/HomeController.php
public function viewGame(int $id)
{
$game = \App\Game::find($id);
$updates = $game->updates;
return view('game', ['game' => $game, 'updates' => $updates]);
}
The startGame
method creates a new game with the provided data and redirects to that game’s page:
// app/Http/Controllers/HomeController.php
public function startGame()
{
$game = \App\Game::create(request()->all());
return redirect("/games/$game->id");
}
The updateGame
method creates a new game update:
// app/Http/Controllers/HomeController.php
public function updateGame(int $id)
{
$data = request()->all();
$data['game_id'] = $id;
$update = \App\Update::create($data);
return response()->json($update);
}
And the updateScore
method updates the game’s score:
// app/Http/Controllers/HomeController.php
public function updateScore(int $id)
{
$data = request()->all();
\App\Game::where('id', $id)->update($data);
return response()->json();
}
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:
// resources/js/app.js
require('./bootstrap');
window.Vue = require('vue');
const app = new Vue({
el: '#main',
data: {
updates,
game,
pendingUpdate: {
minute: '',
type: '',
description: ''
}
},
methods: {
updateGame(event) {
event.preventDefault();
axios.post(`/games/${this.game.id}`, this.pendingUpdate)
.then(response => {
console.log(response);
this.updates.unshift(response.data);
this.pendingUpdate = {};
});
},
updateScore() {
const data = {
first_team_score: this.game.first_team_score,
second_team_score: this.game.second_team_score,
};
axios.post(`/games/${this.game.id}/score`, data)
.then(response => {
console.log(response)
});
},
updateFirstTeamScore(event) {
this.game.first_team_score = event.target.innerText;
this.updateScore();
},
updateSecondTeamScore(event) {
this.game.second_team_score = event.target.innerText;
this.updateScore();
}
}
});
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:
composer require pusher/pusher-http-laravel
npm i pusher-js
Then sign in to your Pusher dashboard and create a new app. Copy your app credentials from the App Keys section and add them to your .env
file:
PUSHER_APP_ID=your-app-id
PUSHER_APP_KEY=your-app-key
PUSHER_APP_SECRET=your-app-secret
PUSHER_APP_CLUSTER=your-app-cluster
Next, we’ll update the controller so the updateGame
and updateScore
method publish the updated values via Pusher.
// app/Http/Controllers/HomeController.php
public function updateGame(int $id, \Pusher\Laravel\PusherManager $pusher)
{
$data = request()->all();
$data['game_id'] = $id;
$update = \App\Update::create($data);
$pusher->trigger("game-updates-$id", 'event', $update, request()->header('x-socket-id'));
return response()->json($update);
}
public function updateScore(int $id, \Pusher\Laravel\PusherManager $pusher)
{
$data = request()->all();
$game = \App\Game::find($id);
$game->update($data);
$pusher->trigger("game-updates-$id", 'score', $game, request()->header('x-socket-id'));
return response()->json();
}
We’re making use of the X-Socket-Id
header so that Pusher 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
:
// resources/js/app.js
window.Pusher = require('pusher-js');
Pusher.logToConsole = true;
const pusher = new Pusher(process.env.MIX_PUSHER_APP_KEY, {
cluster: process.env.MIX_PUSHER_APP_CLUSTER
});
pusher.subscribe(`game-updates-${app.game.id}`)
.bind('event', (data) => {
app.updates.unshift(data);
})
.bind('score', (data) => {
app.game.first_team_score = data.first_team_score;
app.game.second_team_score = data.second_team_score;
});
Here, we set up our Pusher 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.
8 November 2018
by Shalvah Adebayo