This is part 2 of a 4-part tutorial. You can find part 1 here, part 3 here and part 4 here.
In the first part of this series, we created a basic spreadsheets application that allows a user to create spreadsheets. A user could also share the link to a sheet with another user, thereby giving them the ability to edit the spreadsheet.
In this article, we’ll bring our application functionality closer to that of Google Sheets by adding realtime collaboration functionality. This means that multiple users can work on the same sheet at the same time, and both of their changes show up in all other windows immediately. Here’s a glimpse of the end result:

Note: if you followed through with the first part and still have your project code, you can skip this section.
Clone the project from GitHub:
git clone -b part-1-users-spreadsheets https://github.com/shalvah/shoots.git
Or download the source directly from this link.
Then cd into the project folder and install dependencies:
composer install
Lastly, copy the .env.example to a new file called .env. Run the following command to generate an application encryption key:
php artisan key:generate
Note: if your MongoDB server requires a username and password, add those in your
.envfile as theDB_USERNAMEandDB_PASSWORDrespectively.
Our realtime sync has two parts:
afterChange event), we send the update via AJAX to the server. The server will then update the sheet's content in the database and broadcasts the change on the channel above to all subscribed users so the UI can be updated accordinglyLet’s do it! First, we’ll add a virtual property to our sheets, channel_name. This property will return the name of the Pusher channel we should subscribe to in order to be notified about changes to that sheet. Add the following method to your Sheet model (app/Models/Sheet.php):
1public function getChannelNameAttribute() 2 { 3 return "presence-sheet-$this->_id"; 4 }
The name of the channel starts with presence-, to indicate that this is a presence channel. We’re using a presence channel because it provides us with additional information about who is subscribed to the channel, which we’ll need in the next part of this guide.
Now, on to our frontend. Replace the code in your resources/views/spreadsheet.blade.php with the following:
1<head> 2 <title>{{ $sheet->name }}</title> 3 <meta name="csrf-token" content="{{ csrf_token() }}"> 4 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/handsontable/2.0.0/handsontable.min.css" t 5 <!-- Fonts --> 6 <link rel="dns-prefetch" href="https://fonts.gstatic.com"> 7 <link href="https://fonts.googleapis.com/css?family=Raleway:300,400,600" rel="stylesheet" type="text/css"> 8 9 <!-- Styles --> 10 <link href="{{ asset('css/app.css') }}" rel="stylesheet"> 11 </head> 12 13 <br> 14 <h2>{{ $sheet->name }}</h2> 15 <div id="sheet"></div> 16 17 <script src="https://cdnjs.cloudflare.com/ajax/libs/handsontable/2.0.0/handsontable.min.js"></script> 18 <script> 19 let csrfToken = document.head.querySelector('meta[name="csrf-token"]').content; 20 let sheetContent = @json($sheet->content); 21 22 let container = document.getElementById('sheet'); 23 let table = new Handsontable(container, { 24 data: sheetContent, 25 rowHeaders: true, 26 colHeaders: true, 27 minCols: 20, 28 minRows: 20, 29 afterChange: function (change, source) { 30 if (source === 'loadData') return; 31 32 console.log(change, source); 33 34 fetch('/sheets/{{ $sheet->_id }}', { 35 method: 'PUT', 36 body: JSON.stringify({ change: change[0] }), 37 headers: { 38 'X-CSRF-TOKEN': csrfToken, 39 'Content-Type': 'application/json' 40 }, 41 credentials: 'same-origin' 42 }) 43 } 44 }); 45 </script>
Let’s take a closer look at the code. The afterChange event is fired by Handsontable whenever the data in the table changes, either due to loading data from the server or due to a user changing a cell. When this happens, our callback gets called with two parameters:
For instance, let’s suppose we have an empty sheet. When the page is loaded, the afterChange is fired with the change being an empty array and the source being “loadData”. Let’s suppose our table looks like this:
1| A | B | C 2--------------------------- 31 | 42 | 53 |
Supposing a user edits cell A2 to contain the value “hi”:
1| A | B | C 2--------------------------- 31 | 42 | hi 53 |
The afterChange event will be fired with the source as “edit”. The change parameter will look like this:
1[ 2 [1, 0, null, 'hi'] 3 ]
Then, supposing the user changes their mind and switches the “hi” to “hello”, change will be:
1[ 2 [1, 0, 'hi', "hello"] 3 ]
In both these cases:
null if the cell was empty)In the callback we passed to afterChange above, we use Fetch to send the new change to the server. Let’s update our controller to handle this.
First, we need to install and configure the Pusher package, since we’ll be broadcasting the change to all connected clients. Run the following command:
1composer require pusher/pusher-http-laravel 2 php artisan vendor:publish --provider="Pusher\Laravel\PusherServiceProvider"
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:
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
Note: Laravel sometimes caches old configuration, so for the project to see your new configuration values, you might need to run the command
php artisan config:clear
Modify the update method of your app/Http/Controllers/SheetsController.php to look like this:
1public function update($id) 2 { 3 $sheet = Sheet::findOrFail($id); 4 $change = \request('change'); 5 [$rowIndex, $columnIndex, $oldValue, $newValue] = $change; 6 $sheet->content = $this->updateCell($rowIndex, $columnIndex, $newValue, $sheet->content); 7 $sheet->save(); 8 \Pusher::trigger($sheet->channel_name, 'updated', ['change' => $change]); 9 return response()->json(['sheet' => $sheet]); 10 } 11 12 protected function updateCell($rowIndex, $columnIndex, $newValue, $sheetContent) 13 { 14 // we expand the sheet to reach the farthest cell 15 for ($row = 0; $row <= $rowIndex; $row++) { 16 // create the row if it doesnt exist 17 if (!isset($sheetContent[$row])) { 18 $sheetContent[$row] = []; 19 } 20 for ($column = 0; $column <= $columnIndex; $column++) { 21 if (!isset($sheetContent[$row][$column])) { 22 // create the column if it doesnt exist 23 $sheetContent[$row][$column] = null; 24 } 25 } 26 } 27 $sheetContent[$rowIndex][$columnIndex] = $newValue; 28 return $sheetContent; 29 }
Here, we update the specific cell that was changed in the sheet’s content and then trigger an updated event via Pusher with the change as the payload.
Note: in our
updateCellfunction, we expand the sheet to reach the farthest column, filling all empty cells withnull. This is so our sheet doesn’t end up with missing rows and columns
Let’s head back to our frontend and add the code that handles this event. Add this to the bottom of your resources/views/spreadsheet.blade.php:
1<script src="https://js.pusher.com/4.2/pusher.min.js"></script> 2 <script> 3 let pusher = new Pusher('your-app-key', { 4 cluster: 'your-app-cluster', 5 authEndpoint: '/sheets/{{ $sheet->_id }}/subscription_auth', 6 auth: { 7 headers: { 8 'X-CSRF-Token': csrfToken 9 } 10 } 11 }); 12 pusher.subscribe("{{ $sheet->channel_name }}") 13 .bind('updated', function (message) { 14 let [rowIndex, columnIndex, oldValue, newValue] = message.change; 15 addCellValue(rowIndex, columnIndex, newValue); 16 table.loadData(sheetContent) 17 }); 18 19 function addCellValue(rowIndex, columnIndex, newValue) { 20 // we expand the sheet to reach the farthest cell 21 for (let row = 0; row <= rowIndex; row++) { 22 if (!sheetContent[row]) sheetContent[row] = []; 23 for (let column = 0; column <= columnIndex; column++) { 24 if (!sheetContent[row][column]) 25 sheetContent[row][column] = null; 26 } 27 } 28 sheetContent[rowIndex][columnIndex] = newValue; 29 } 30 </script>
Replace your-app-key and your-app-cluster in the code above with your Pusher app key and cluster that you obtained from your dashboard earlier.
In this code:
authEndpoint which Pusher will call to determine if the current user is permitted to subscribe to this sheet.updated event and update the sheet’s content similar to how we did on the server (in the addCellValue function), then we refresh the table UI by calling table.loadData.The last piece of the puzzle we need to implement is our authentication endpoint. Let’s do that now. Add this to the end of your routes/web.php:
Route::post('sheets/{id}/subscription_auth', 'SheetsController@authenticateForSubscription');
Now, add the authenticateForSubscription to your app/Http/Controllers/SheetsController.php:
1public function authenticateForSubscription($id) 2 { 3 $authSignature = \Pusher::presence_auth( 4 \request('channel_name'), 5 \request('socket_id'), 6 \Auth::user()->_id, 7 \Auth::user()->toArray() 8 ); 9 return response()->json(json_decode($authSignature)); 10 }
Great. Now, let’s test our app out. Start your MongoDB server by running mongod. (On Linux/macOS, you might need to run it as sudo).
Then start your app by running:
php artisan serve
Sign in to your app at http://localhost:8000/login (or http://localhost:8000/register if you didn’t sign up in the previous part) and create a new spreadsheet. Copy the spreadsheet’s url from your browser and open it in a second tab (as a different user or the same user). You should be able to make changes to the sheet in both tabs and see them show up in realtime.
Well, that was fun, wasn’t it? But there’s more to come. In the next part of this series, we’ll replicate another nice feature of Google Sheets: showing which users are currently viewing the spreadsheet in realtime. Stay tuned. You can check out the source code of the app thus far on GitHub.