Broadcasting events with Laravel and Socket.io

Published by at 14th May 2016 8:00 pm

PHP frameworks like Laravel aren't really set up to handle real-time events properly, so if you want to build a real-time app, you're generally better off with another platform, such as Node.js. However, if that only forms a small part of your application, you may still prefer to work with PHP. Fortunately it's fairly straightforward to hand off the real-time aspects of your application to a dedicated microservice written using Node.js and still use Laravel to handle the rest of the functionality.

Here I'll show you how I built a Laravel app that uses a separate Node.js script to handle sending real-time updates to the user.

Events in Laravel

In this case, I was building a REST API to serve as the back end for a Phonegap app that allowed users to message each other. The API includes an endpoint that allows users to create and fetch messages. Now, in theory, we could just repeatedly poll the endpoint for new messages, but that would be inefficient. What we needed was a way to notify users of new messages in real time, which seemed like the perfect opportunity to use Socket.io.

Laravel comes with a simple, but robust system that allows you to broadcast events to a Redis server. Another service can then listen for these events and carry out jobs on them, and there is no reason why this service has to be written in PHP. This makes it easy to decouple your application into smaller parts. In essence the functionality we wanted was as follows:

  • Receive message
  • Push message to Redis
  • Have a separate service pick up message on Redis
  • Push message to clients

First off, we need to define an event in our Laravel app. You can create a boilerplate with the following Artisan command:

$ php artisan make:event NewMessage

This will create the file app/Events/NewMessage.php. You can then customise this as follows:

1<?php
2
3namespace App\Events;
4
5use App\Events\Event;
6use App\Message;
7use Illuminate\Queue\SerializesModels;
8use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
9
10class NewMessage extends Event implements ShouldBroadcast
11{
12 use SerializesModels;
13
14 public $message;
15
16 /**
17 * Create a new event instance.
18 *
19 * @return void
20 */
21 public function __construct(Message $message)
22 {
23 // Get message
24 $this->message = $message;
25 }
26
27 /**
28 * Get the channels the event should be broadcast on.
29 *
30 * @return array
31 */
32 public function broadcastOn()
33 {
34 return ['room_'.$this->message->room_id];
35 }
36}

This particular event is a class that accepts a single argument, which is an instance of the Message model. This model includes an attribute of room_id that is used to determine which room the message is posted to - note that this is returned in the broadcastOn() method.

When we want to trigger our new event, we can do so as follows:

1use App\Events\NewMessage;
2Event::fire(new NewMessage($message));

Here, $message is the saved Eloquent object containing the message. Note the use of SerializesModels - this means that the Eloquent model is serialised into JSON when broadcasting the event.

We also need to make sure Redis is set as our broadcast driver. Ensure the Composer package predis/predis is installed, and set BROADCAST_DRIVER=redis in your .env file. Also, please note that I found that setting QUEUE_DRIVER=redis in .env as well broke the broadcasting system, so it looks like you can't use Redis as both a queue and a broadcasting system unless you set up multiple connections.

Next, we need another server-side script to handle processing the received events and pushing the messages out. In my case, this was complicated by the fact that we were using HTTPS, courtesy of Let's Encrypt. I installed the required dependencies for the Node.js script as follows:

$ npm install socket.io socket.io-client ioredis --save-dev

Here's an example Node.js script for processing the events:

1var fs = require('fs');
2var pkey = fs.readFileSync('/etc/letsencrypt/live/example.com/privkey.pem');
3var pcert = fs.readFileSync('/etc/letsencrypt/live/example.com/fullchain.pem')
4
5var options = {
6 key: pkey,
7 cert: pcert
8};
9
10var app = require('https').createServer(options);
11var io = require('socket.io')(app);
12
13var Redis = require('ioredis');
14var redis = new Redis();
15
16app.listen(9000, function() {
17 console.log('Server is running!');
18});
19
20function handler(req, res) {
21 res.setHeader('Access-Control-Allow-Origin', '*');
22 res.writeHead(200);
23 res.end('');
24}
25
26io.on('connection', function(socket) {
27 //
28});
29
30redis.psubscribe('*', function(err, count) {
31 //
32});
33
34redis.on('pmessage', function(subscribed, channel, message) {
35 message = JSON.parse(message);
36 console.log('Channel is ' + channel + ' and message is ' + message);
37 io.emit(channel, message.data);
38});

Note we use the https module instead of the http one, and we pass the key and certificate as options to the server. This server runs on port 9000, but feel free to move it to any arbitrary port you wish. In production, you'd normally use something like Supervisor or systemd to run a script like this as a service.

Next, we need a client-side script to connect to the Socket.io instance and handle any incoming messages. Here's a very basic example that just dumps them to the browser console:

1var url = window.location.protocol + '//' + window.location.hostname;
2var socket = io(url, {
3 'secure': true,
4 'reconnect': true,
5 'reconnection delay': 500,
6 'max reconnection attempts': 10
7});
8var chosenEvent = 'room_' + room.id;
9socket.on(chosenEvent, function (data) {
10 console.log(data);
11});

Finally, we need to configure our web server. I'm using Nginx with PHP-FPM and PHP 7, and this is how I configured it:

1upstream websocket {
2 server 127.0.0.1:9000;
3}
4
5server {
6 listen 80;
7 server_name example.com;
8 return 301 https://$host$request_uri;
9}
10
11server {
12 listen 443 ssl;
13 server_name example.com;
14 ssl on;
15 ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
16 ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
17 proxy_set_header Host $host;
18 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
19 proxy_set_header X-Forwarded-Proto $scheme;
20 proxy_set_header X-Real-IP $remote_addr;
21 ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
22 ssl_prefer_server_ciphers on;
23 ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
24 client_max_body_size 50M;
25 server_tokens off;
26 add_header X-Frame-Options SAMEORIGIN;
27 add_header X-Content-Type-Options nosniff;
28 add_header X-XSS-Protection "1; mode=block";
29
30 root /var/www/public;
31 index index.php index.html index.htm;
32
33 location / {
34 try_files $uri $uri/ /index.php?$query_string;
35 gzip on;
36 gzip_proxied any;
37 gzip_types text/plain text/css application/javascript application/x-javascript text/xml application/xml application/xml-rss text/javascript text/js application/json;
38 expires 1y;
39 charset utf-8;
40 }
41
42 location ~ \.php$ {
43 try_files $uri /index.php =404;
44 fastcgi_split_path_info ^(.+\.php)(/.+)$;
45 fastcgi_pass unix:/var/run/php/php7.0-fpm.sock;
46 fastcgi_index index.php;
47 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
48 include fastcgi_params;
49 }
50
51 location ~ /.well-known {
52 root /var/www/public;
53 allow all;
54 }
55
56 location /socket.io {
57 proxy_set_header Upgrade $http_upgrade;
58 proxy_set_header Connection "upgrade";
59 proxy_http_version 1.1;
60 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
61 proxy_set_header Host $host;
62 proxy_pass https://websocket;
63 }
64}

Any requests to /socket.io are proxied to port 9000, where our chat handling script is listening. Note that we allow the HTTPS connection to be upgraded to a WebSocket one.

Once that's done, you just need to restart your PHP application and Nginx, and start running your chat script, and everything should be working fine. If it isn't, the command redis-cli monitor is invaluable in verifying that the event is being published correctly.

Summary

Getting this all working together did take quite a bit of trial and error, but that was mostly a matter of configuration. Actually implementing this is pretty straightforward, and it's an easy way to add some basic real-time functionality to an existing Laravel application.