Simple Event Streaming in Laravel

Posted on Updated on Laravel by Gergő D. Nagy

EventSource is a solution to listen to server-send events from our client-side. It’s way simpler than WebSockets to implement, but also it has strong limitations. Let’s take a look at a simple implementation in Laravel, both JavaScript and server-side.

Limitations

First of all, let’s talk about the limitations of EventSource. There are three main issues that block using it for more complex applications:

  • first of all, it’s not possible to send headers with the EventSource’s request,
  • the second on is a connection limitation. It’s fairly limited regarding the number of concurrent connections,
  • and last, it can listen to events only and not able to fire events – like WebSockets.
Keeping in mind these, you may use socket.io or pusher if your application needs a more reliable solution.

Implementing Stream Responses in Laravel

Unlike WebSockets, we are using polling here. It means, on the back-end we need to implement a solution that allows us to control what data and when we want to show. We don’t have the possibility of using events and listeners here – unlike WebSockets – so, we need to implement something very simple here.

Let’s stick with a very basic idea, and let’s say we are looking for messages sent from the server-side in real-time. Every 5 seconds, we will fetch the latest messages if is there any. If there is no message, then we won’t trigger the event that the front-end listens for.

We have a simple Message model, with one field called body. We also set up the migration and the factory for this model, but now we don’t go to details. We will use the message factory to populate the DB asynchronously using tinker.

First of all, let’s create the controller and register the endpoint. Let’s create a StreamController where we will have the logic of the stream response. Then we register the route for the stream.

namespace App\Http\Controllers;

use App\Message;
use Carbon\Carbon;

class StreamController extends Controller
{
    /**
     * The stream source.
     *
     * @return \Illuminate\Http\Response
     */
    public function __invoke()
    {
        return response()->stream(function () {
            while (true) {
                if (connection_aborted()) {
                    break;
                }
                if ($messages = Message::where('created_at', '>=', Carbon::now()->subSeconds(5))->get()) {
                    echo "event: ping\n", "data: {$messages}", "\n\n";
                }
                ob_end_flush();
                flush();
                sleep(5);
            }
        }, 200, [
            'Cache-Control' => 'no-cache',
            'Content-Type' => 'text/event-stream',
        ]);
    }
}

So, what’s happening here? It’s quite simple. We return with a StreamResponse, which accepts a closure, where we need to define the logic of the stream.

Inside the closure, we create an infinite loop and we stop only if the connection is aborted. Then we are retrieving the messages that are created in the last five seconds and appending them to the printed data. Then we flush the output buffering and then wait for five seconds to repeat the process. Our printed data will look like this:

event: ping
data: [...]

For the response, we need to set the status code which is 200 and also the headers.

Even if we return with Symfony’s StreamResponse, we need to set the proper headers explicitly.

Lastly, we need to register the route for the stream endpoint. We can simply do this in our web routes:

Route::get('/stream', 'StreamController');

Since, we are using __invoke in our controller, no need to specify the method name when registering the route.

Implementing EventSource on the Front-end

On the front-end, we have a very easy job. First of all, you can find the documentation here, so if you wish to have more control, you can easily implement your own approach. For now, we keep it very simple, using plain JS, but of course, you can easily integrate this solution with any framework, like Vue.

let stream = new EventSource('/stream', { withCredentials: true }),
    list = document.querySelector('#messages');

stream.addEventListener('ping', event =>
    JSON.parse(event.data).forEach(message =>
        let li = document.createElement('li');
        li.innerHTML = `<em>${message.created_at}</em>: ${message.body}`;
        list.appendChild(li);
    });
});

So, what is going here? First of all, we create the EventSource instance pointing to the /stream endpoint we set up already. Also, we have a <ul> in our HTML markup with a #messsages ID.  We will append the messages we receive to this container.

Then we set up our event listener for our specific ping event. Remember, this event will be fired every five seconds if there is any new message to transfer.

If yes, we have new messages from the back-end, we parse the data and convert it to an array of objects. After this, we are able to loop through the objects and append all the new messages to the message container.

Now we can test our system. Let’s open the page where we have the script implemented with the message container and let’s generate some message using php artisan tinker. After we launched tinker, let’s create some fake models:

factory(App\Message::class, 3)->create();

If we did everything right, on the next poll our script will append the new messages to the container.

Summary

As we saw, this approach is very simple. No need for any dependency to be installed or extra configuration. With a little preparation, it works out of the box. We can also modify it as well a bit, for example, if we want to emit the event every 2 seconds, we just update it and ready t go. Also, we can emit other events as well at the same time, so we can set up other listeners as well and perform different actions.

However, the limitations we mentioned, restrict the usage a lot. If you need a more reliable solution, use WebSockets. This approach could be used only for some very specific scenarios, but in those cases, this can be a very good and simple solution.

Need a web developer? Maybe we can help, get in touch!