This Small Corner

Eric Koyanagi's tech blog. At least it's free!

Laravel Deep Dives: Event System

By Eric Koyanagi
Posted on

Event System Background

An event system implements an "observer" pattern...events are conceptually very simple for developers just from the name. Some part of the application "fires" an event, and one or more "listeners" do something. This is a natural sounding approach to decoupling code.

Doing a deeper dive of how this system works is useful, because naive event systems can have challenges with scale, debugging, and performance. There's also the magic of broadcasting, very useful for building real-time functionality in a Laravel application. Since broadcasting uses the Event class, it should be very easy to leverage this great feature just by diving into the way events work.

Events vs. Queues: Why use Events?

The previous article talks about queues, which already seem like they offer a convenient way to decouple code. Sure, but you don't always want or need to push everything into a queue -- queues have overhead and cost. Also, events allow you to subscribe multiple listeners for one event, allowing for more complex and extensible logic.

Since we did a deep dive of queues, we know that workers "run in the background". They can run concurrently via separate PHP processes on machines with more than one core, and they won't block the main application since they are separate processes in memory.

Therefore, we know that there's a difference between driving events in our main application compared to using a worker. We can also imagine that the two techniques can be leveraged together! It's easy to imagine a queue-driven job that has a variety of complex steps that could be improved by an observer implementation. For applications that heavily rely on workers and queues, events might offer a powerful way to decouple architecture and improve the application's versatility.

Finally, you can use events within queues to write to a real-time stream by using Broadcasting (linked above). This can power many rich applications, from chat to multiplayer. Laravel has built-in drivers for some commercial broadcasting platforms like Ably, but there's community packages for non-commercial options too. Or you can push data to Redis!

As you can imagine, this is a very powerful, robust way to build a scalable stack. To review, a user-driven front-end might populate a queue to drive a complex process. A worker handles the job, using events to decouple logic. A broadcaster then writes data to a real-time data stream. The front-end efficiently consumes this, perhaps updating a UI.

How Laravel Events Work

Now that we see why events are useful, let's look into how they work. We know there's basically two sides to an event system: "listening" for an event, and "firing" the event. I think it makes more sense to understand how the listener works, first.

We see that event listeners are registered in the EventServiceProvider. Since we've already looked at the Service Container, we therefore know that listeners are registered as the application bootstraps. This tells us that there's some level of overhead added to bootstrapping with every event added. For a long-running daemon (e.g. an application for workers), that isn't a big deal. For a user-facing application...well, it probably isn't a big deal either (caching, remember?), but it's something to keep in mind. Time spent in bootstrapping is really worth noting because it can be easily overlooked if not familiar with the details of Laravel's bootstrapping.

With that out of the way, let's look into how this works. Per Laravel's docs, the listeners are registered by populating the $listen property in the EventServiceProvider. It might look like this:

OrderShipped::class => [
   SendShipmentNotification::class,
],

As a reminder, the key is the event, the value is the listener or listeners. In this case, we can see that the EventServiceProvider isn't like other Service Containers, it's actually extended from Illuminate\Foundation\Support\Providers\EventServiceProvider. As we explored in the ServiceContainer, we know that register will be called on the provider as part of bootstrapping, so we don't need to look into where the EventServiceProvider's register method is called and can instead look at its code:

$this->booting(function () {
    $events = $this->getEvents();

    foreach ($events as $event => $listeners) {
        foreach (array_unique($listeners, SORT_REGULAR) as $listener) {
            Event::listen($event, $listener);
        }
    }

    foreach ($this->subscribe as $subscriber) {
        Event::subscribe($subscriber);
    }

    foreach ($this->observers as $model => $observers) {
        $model::observe($observers);
    }
});

Here is where our listeners are actually registered. We can see a few methods we'll want to look into. Event::listen, Event::subscribe, and Model::observe. We can quickly peak into getEvents() to see it returns the $listens array (or auto-discovered events, which I won't touch on here).

Without even looking into the lower level details, we know that event subscribers are classes used to help group listeners. If you have a ton of listeners to register, these are a convenience. We know that using subscribers requires us to call the "listen" method on events, so we'll focus on that. If you're curious about model observers, this is where they are initialized. I may circle-back to model observers or write a separate article about them, but the general idea is that these are "eloquent-driven events".

To understand what Event::listen does, we'll have to look into the Event facade. The point of facades is that they provide a static interface for classes that are registered in the service container.

The Event Facade

In the EventServiceContainer, the "Event" facade is bound to an instance of a Dispatcher. Don't see it? Check the base class in the Illuminate framework. Actually, the only thing the parent does it this binding. This is a longwinded way to say that we know that "Event::listen"'s logic lives in the event Dispatcher.

Finally, we can look at the body of the listen method:

if ($events instanceof Closure) {
    return collect($this->firstClosureParameterTypes($events))
        ->each(function ($event) use ($events) {
            $this->listen($event, $events);
        });
} elseif ($events instanceof QueuedClosure) {
    return collect($this->firstClosureParameterTypes($events->closure))
        ->each(function ($event) use ($events) {
            $this->listen($event, $events->resolve());
        });
} elseif ($listener instanceof QueuedClosure) {
    $listener = $listener->resolve();
}

foreach ((array) $events as $event) {
    if (str_contains($event, '*')) {
        $this->setupWildcardListen($event, $listener);
    } else {
        $this->listeners[$event][] = $listener;
    }
}

We see some recursive references, but ultimately? It stashes the listeners in an associative array, indexed by the event name. That isn't a surprise.

Firing Events

Alright, we see how, under the hood, "listening" just means building an associative array. Great! Now let's shoot an event:

// When the given "event" is actually an object we will assume it is an event
// object and use the class as the event name and this event itself as the
// payload to the handler, which makes object based events quite simple.
[$event, $payload] = $this->parseEventAndPayload(
    $event, $payload
);

if ($this->shouldBroadcast($payload)) {
    $this->broadcastEvent($payload[0]);
}

$responses = [];

foreach ($this->getListeners($event) as $listener) {
    $response = $listener($event, $payload);

    // If a response is returned from the listener and event halting is enabled
    // we will just return this response, and not call the rest of the event
    // listeners. Otherwise we will add the response on the response list.
    if ($halt && ! is_null($response)) {
        return $response;
    }

    // If a boolean false is returned from a listener, we will stop propagating
    // the event to any further listeners down in the chain, else we keep on
    // looping through the listeners and firing every one in our sequence.
    if ($response === false) {
        break;
    }

    $responses[] = $response;
}

return $halt ? null : $responses;

First we see the simple de-structuring to obtain the event and event payload. When it gets the listeners, it obtains the entries of the matching array we saw above and any wildcard matches.

There's some details that go into the low-level construction between here and where the listener's handle method is actually called. Still within the Dispatcher, you can see the createClassListener method is responsible for obtaining the $listener used in this loop. Why?

It needs to convert closures provided via the "Event::listen" method to an object we can use. Using closures like this has advantages, like being able to use dependency injection. Ultimately, you can see how the dispatcher resolves the callable, defaulting to the familiar "handle" method we use when creating listeners.

Conclusion

  1. We're now working our way up the Laravel stack, and can see how previous experience with queues, routing, and the service container help us understand higher-level concepts.
  2. Events can be used in conjunction with queues to decouple application logic while still taking advantage of concurrency
  3. You can broadcast to real-time memory systems to create rich, near real-time applications at scale
  4. The EventServiceContainer is used to bind listeners and binds the "event" singleton to the event dispatcher
  5. The event dispatcher is where the meat of the event system lives and handles registering listeners and dispatching events

Do you need events? That's up to you. The observer pattern is, like anything, just a tool. Not every application needs events, but they do have a ton of utility, especially if you explore broadcasting to pusher channels like Ably or Redis.

That's all for now!

« Laravel Deep Dives: Queues and Async Design | Laravel Deep Dives: Collections »
« Back to Article List
Written By
Eric Koyanagi

I've been a software engineer for over 15 years, working in both startups and established companies in a range of industries from manufacturing to adtech to e-commerce. Although I love making software, I also enjoy playing video games (especially with my husband) and writing articles.

Article Home | My Portfolio | My LinkedIn
© All Rights Reserved