PHP Pluggable and Modular System – Part 2 (Implementation) [Event Dispatcher]

In the first series of this guide, we discussed the theoretical aspect of building a pluggable system in PHP, I wrote a bit of code in that guide plus a couple of stuff you should avoid, you can learn more here:

In this guide, I’ll go over the practical aspect of building a pluggable system from scratch using PHP.

So, I concluded I’ll be using the Mediator Pattern for the implementation, design pattern is not something to follow blindly, if it doesn’t make sense for you, mangle it to soothe your needs, rules are there to be broken.

I don’t want my system to be too self-reliant, so, I would be implementing my event system using the PSR-14 (Event Dispatcher Spec), this way, if you are coming from another framework or system that already implements this spec, you can easily plug it into my framework.

Although each system implementation of PSR-14 might be slightly different, nevertheless, as a library or plugin developer, you would benefit from the spec since the interfaces are common, and the changes you might end up making would be little compared to you adding the support for your library in a system like WordPress or Drupal.

I won’t go over the nuances of the spec here, you can learn more here: PSR-14 Event Dispatcher if you have gone through the first part of this guide, then this is a Mediator Pattern, this standard is kind of an agreed way everyone should be doing it, it’s not like it is implemented for you, you’ll still need to implement it yourself, just a common mechanism.

Note: I am not forcing you to follow the PSR-14 spec, I love it, you might not, and that’s okay. Birds of the same feathers don’t necessarily need to flock together!. I have previously implemented a widget system without this spec, I was using the observable pattern (so, you get the point).

Why I Built My Own Event Dispatcher

Before going on, I should mention, there is an excellent implementation of this out there, which are:

  • Tukio By Larry Garfield (Crell) – Larry Garfield, the editor of the PSR-14 spec coupled with other specs (PSR-6, 8, and 13). We had a chat around Event Dispatcher in general on Twitter and he is a nice guy with a brilliant mind.
  • Symphony Event Dispatcher – It is also compatible with PSR-14
  • Laravel Event – Not compatible with PSR-14, and it is not framework agnostic. If you use this, you’ll have to rewrite your event system when migrating away from Laravel

If there are excellent Event Dispatcher Libraries, why am I creating my own?

I want to learn! I initially wanted to use Tukio but I felt like, I’ll gain tremendous knowledge if I tackle this on my own, it might not be perfect but the knowledge I gained doing this can’t be bought.

Besides, I only want a very limited functionality, so, my own implementation turns out to be simple for my use case. As time goes on, I can add more functionality as I see fit.

Building The Event Dispatcher

The psr-14 interface is a composer away, install using:

composer require psr/event-dispatcher

If you are not using composer, you can download the library: php-fig-event-dispatcher and require it in your project.

Having done that, you should start by…

Creating a ListenerProvider That Implements The ListenerProviderInterface:

<?php
namespace App\Library\PluginSystem;
use Psr\EventDispatcher\ListenerProviderInterface;

class ListenerProvider implements ListenerProviderInterface
{
    private $listeners = [];

    /**
     * Instead of having to call new Blah Blah, Simply calling ListenerProvider::init would automatically
     * instantiate this class.
     * @return ListenerProvider
     */
    public static function init() {
        return new self();
    }

    /**
     * This attach the an Event to the listener
     * @param string $eventType
     * @param callable $callable
     * @return $this
     */
    public function attachListener(string $eventType, callable $callable): self
    {
        // This hold the particular event,
        $this->listeners[$eventType][] = $callable;
        return $this;
    }

    /**
     * @param string $eventType
     * @return ListenerProvider
     */
    public function removeListeners(string $eventType): ListenerProvider
    {
        if (array_key_exists($eventType, $this->listeners)) {
            unset($this->listeners[$eventType]);
        }

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function getListenersForEvent(object $event): iterable
    {
        // TODO: Implement getListenersForEvent() method.
        if( $event instanceof Event ){
            $eventType = $event->getName();
        } else {
            $eventType = \get_class($event);
        }

        // array_key_exists checks if the given key or index exists in the array
        // $eventType is the key to check and the $this->listeners are the list of event listeners
        // if it exist we return the eventListeners, else we return an empty array
        if (array_key_exists($eventType, $this->listeners)) {
            return $this->listeners[$eventType];
        }

        return [];
    }

    /**
     * @param EventSubscriberInterface $subscriber
     * @return ListenerProvider
     */
    public function SubscribeToEvent(EventSubscriberInterface $subscriber): ListenerProvider
    {
        foreach ($subscriber->getSubscribedEvents() as $eventName => $params) {

            if (is_array($params)){
                $this->listeners = self::init();
                foreach ($params as $listener){
                    $this->listeners->attachListener($eventName, new $listener);
                }
            }
        }
        return $this->listeners;
    }
}

According to the spec, the ListenerProvider is:

responsible for determining what Listeners are relevant for a given Event, but MUST NOT call the Listeners itself. A Listener Provider may specify zero or more relevant Listeners.

So, this is the place, we add or remove listeners for a specific event, I have built it to be a bit intuitive (you can chain methods), the code is self-explanatory (I have added comments).

If you are coming from Part 1 of this guide, then the above shouldn’t be new to you, the only thing different is that I have a SubscribeToEvent, this is there if you want to subscribe to multiple events with their listeners, this is good if you want to keep things organized.

Create an EventSubscriberInterface, and add the following:

<?php

/**
 * Interface EventSubscriberInterface
 * @package App\Library\PluginSystem
 */
interface EventSubscriberInterface
{
    /**
     * Returns an array of event names this subscriber wants to listen to.
     * The array keys are event names and the value can be listeners or callable
     */
    public static function getSubscribedEvents();
}

The next step is…

Creating The Event Dispatcher That Implements The EventDispatcherInterface

<?php
namespace App\Library\PluginSystem;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\EventDispatcher\StoppableEventInterface;

class EventDispatcher implements EventDispatcherInterface
{

    /**
     * @var ListenerProviderInterface
     * @return EventDispatcher|null
     */
    private $provider;

    public static function init(ListenerProviderInterface $provider = null): EventDispatcher
    {
        return new self($provider);
    }

    /**
     * Rather then letting the class defines the listener itself, we use a dependable injection to pass the
     * $provider object to the ListenerProviderInterface
     *
     * The reason we have the ListenerProviderInterface in the constructor is to type hint
     * and ensures that the function gets only ListenerProviderInterface objects as arguments
     *
     * EventDispatcher constructor.
     * @param ListenerProviderInterface|null $provider
     */
    public function __construct(ListenerProviderInterface $provider = null)
    {
        $this->provider = $provider; // This stores a copy of the object in our class property "provider"
    }

    /***
     * @param object $event
     * @return object
     */

    public function dispatch(object $event): object
    {
        // If an event is already stopped, no point in going further, we return the event
        if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
            return $event;
        }
        
        $listeners = $this->provider->getListenersForEvent($event);
        foreach ($listeners as $listener) {
            if($event instanceof StoppableEventInterface && $event->isPropagationStopped()){
                break;
            }
            $listener($event);
        }
        return $event;
    }
}

I have provided comments in the code, so, it should be self-explanatory.

As you can see in the dispatch method, we are checking if an event has the instance of the StoppableEventInterface, with this, a listener can stop further execution of other event listeners.

There might be cases when you don’t want the Event passed to other listeners, which could be because the current listener has achieved the stuff other listeners might want to do, I see a lot of good things I can do with this.

Lastly, create the…

Event Class That Implements The EventsInterface

<?php
namespace App\Library\PluginSystem;
use Psr\EventDispatcher\StoppableEventInterface;

class Event implements StoppableEventInterface
{
    /**
     * @var bool Whether no further event listeners should be triggered
     */
    private $propagationStopped = false;

    /**
     * Get event name
     *
     * @return string
     */
    public function getName(): string
    {
        return get_class($this);
    }
    /**
     * Is propagation stopped?
     *
     * This will typically only be used by the Dispatcher to determine if the
     * previous listener halted propagation.
     *
     * @return bool
     *   True if the Event is complete and no further listeners should be called.
     *   False to continue calling listeners.
     */
    public function isPropagationStopped(): bool
    {
        // TODO: Implement isPropagationStopped() method.
        return $this->propagationStopped;
    }

    /**
     * Stops the propagation of the event to further event listeners.
     *
     * If multiple event listeners are connected to the same event, no
     * further event listener will be triggered once any trigger calls
     * stopPropagation().
     */
    public function stopPropagation(): void
    {
        $this->propagationStopped = true;
    }
}

and that’s it.

You can now create events that do different things, if any listener is listening to such events, they can be fired at an appropriate time.

For example, let’s create a mockup event that should be fired when a newPost is created:

NewPostCreatedEvent

<?php
namespace Modules\Post\Events;
use App\Library\PluginSystem\Event;

/**
 * Event For When a NewPost has Been Created. This Event Would Typically Hold The Value or The
 * Object of the Actual Post Been Created, so, Whatever is Listening To This Event Can Take it, and Do
 * Something With It.
 *
 * Class ArtistEvent
 * @package Modules\Artist\Events
 */
class NewPostCreatedEvent extends Event
{
    /**
     * @var object
     */
    private $object;

    /**
     * @param  $object
     */
    public function __construct($object)
    {
        $this->object = $object;
    }

    /**
     * Get event name
     *
     * @return string
     */
    public function getName(): string
    {
        return get_class($this);
    }

    /**
     * @return object
     */
    public function getObject()
    {
        return $this->object;
    }

}

We can simulate the event process like so:

$NewPost = (object) ['Title' => 'Event Dispatcher', 'Body' => 'We discussing about event dispatcher'];

$ListenerProviders =  ListenerProvider::init()
    ->attachListener(NewPostCreatedEvent::class, function(NewPostCreatedEvent $eventObject) {
         dump("This is a new Post and has a title: {$eventObject->getObject()->Title}");
    })
    ->attachListener(NewPostCreatedEvent::class, function(NewPostCreatedEvent $eventObject){
        dump("This is a new Post and has a title: {$eventObject->getObject()->Body}");
    });

// We Dispatch The AfterPostFormEvent, All Listener Listening To This Event Would Be Called In Turns
EventDispatcher::init($ListenerProviders)->dispatch(new NewPostCreatedEvent($NewPost));

// Output
"This is a new Post and has a title: Event Dispatcher"
"This is a new Post and has a title: We discussing about event dispatcher"

I am using Closure as the callback for my listener here, but you can use anything as long as it is of a type callable. There is nothing stopping you from using a method from a class as your callback function. Also, you can see our listeners got access to the event data, and as such, you can do further processing on the data.

Before we get into real-world usage, let me show you how the SubscribeEvent works:

I don’t know about you, but attaching multiple listeners can be a bit rough, in that case, I have provided a subscription feature, to use it, simply create a new class, and map all of your listeners to an event.

The way, I’ll do this is as follows:

<?php
namespace Modules\Post\Events;
use App\Library\PluginSystem\EventSubscriberInterface;

class SubscriberToMultipleEvents implements EventSubscriberInterface
{

    public static function getSubscribedEvents(){

        return [ NewPostCreatedEvent::class => [
            NotifyUsersofNewPost::class,
            ModifyNewPostTitle::class
        ]];
    }
}

class NotifyUsersofNewPost {

    public function __invoke(NewPostCreatedEvent $eventObject){
        // TODO: Do Something
        dump("Hey, New User. There is a new Post with title {$eventObject->getObject()->Title}");
    }
}

class ModifyNewPostTitle {

    public function __invoke(NewPostCreatedEvent $eventObject){
        // TODO: Do Something
        dump("Modify The Post Title To UpperCae: " . strtoupper($eventObject->getObject()->Title));
    }
}

Everything is organized in one place, offcourse, you should move the listener classes into their appropriate folder (the above is just a quick example), to then subscribe the event, I can simply do:

$NewPost = (object) ['Title' => 'Event Dispatcher', 'Body' => 'We discussing about event dispatcher'];
$ListenerProviders =  ListenerProvider::init()->SubscribeToEvent(new SubscriberToMultipleEvents());
// We Dispatch The AfterPostFormEvent, All Listener Listening To This Event Would Be Called In Turns
EventDispatcher::init($ListenerProviders)->dispatch(new NewPostCreatedEvent($NewPost));

// Output

"Hey, New User. There is a new Post with title Event Dispatcher"
"Modify The Post Title To UpperCae: EVENT DISPATCHER"

This is cool ‘cos you can have multiple events and listeners in a single subscriberBox.

Implementation in Laravel

Note: I would be using the Event Dispatcher we created above, I am not using Laravel events, so, you can easily use this even if you are not using Laravel, you just need to figure out a way to load different services when you application boots, maybe by creating a Service Container?

The way I implemented this in Laravel is, I first created a Module-Based architecture, Core Module is different from Post Module, and a Post Module is different from a Customer Module, if I feel like something is wrong with the Customer Module, I can switch it off in the config by simply adding a false to the enable key.

Any Modules that share a common functionality should be placed in the Core Module, then you can use that information in a site-wide format in your other Modules.

How would this help for our Event-Driven System?

Each module should have its own event and listeners. Any plugin that wants to plug into your system doesn’t even need to know anything about the Modules, you should build it in such a way that when the Plugin loads, the system should be able to inspect the events and listeners in that specific plugin and map it to the appropriate Module.

Don’t worry, you’ll still be using the same Dispatcher, but since everything is Module-based, it knows the context the module is working on.

Step 1: Create a Module Folder At The Root of Your Project

Create a folder named Modules at the root of your project, inside this folder, you can create Controllers, routes, Views, and Models that are specific to each Module. For example, if I wanna create a Core module, I can simply do:

Modules
    Core
        Controllers
        routes
        Views
        Models
        config.php

You get the point, the logic is separate.

Step 2: Create a ModuleServiceProvider

To finalize our Module architecture, create a file named ModuleServiceProvider.php at the root of the Modules folder, and add the following:

<?php
namespace Modules;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\ServiceProvider;

class ModulesServiceProvider extends ServiceProvider
{
    /**
     * @var Filesystem
     */
    private Filesystem $files;

    /**
     * This Bootstrap the application service.
     * In our case, it bootstrap the neccessary services for the modules.
     * It autoloads our modules without having to add it manually
     *
     * @param Filesystem $files
     * @return void routeModule
     */
    public function boot(Filesystem $files) {
        $this->files = $files;
        // We get the Modules path with base_path, we then use is_dir to check if it actually exists
        if (is_dir(base_path('Modules'))) {
            /***
             * $this->files->directories(app_path('Modules')) returns array of folder in the Modules, e.g:
             * array:3 [
                    0 => "/directory/app/Modules/Core"
                    1 => "/directory/app/Modules/Media"
                    2 => "/directory/app/Modules/Post"
             ]
             * Then we use array_map to send each of the elements of the array to a function which then return new array modified by that function
             * in our case, we use array_map to send each element to php basename function which get the last dirname e.g
             *
             * array:3 [
                    0 => "Core"
                    1 => "Media"
                    2 => "Post"
                    ]
             *
             */
            $modules = array_map('basename', $this->files->directories(base_path('Modules')));

            // This search for Core module and move it to the end, this way, we can easily use the catchallroute
            $searchCoreModule = array_search('Core', $modules, true);
            if ($searchCoreModule){
                unset($modules[$searchCoreModule]); // unset the core module
                $modules[] = 'Core'; // re-add it to the last index
            }

            foreach ($modules as $module) {
                $this->registerModule($module);
            }
        }
    }

    /**
     * Register a module by its name
     *
     * @param  string $module
     *
     * @return void
     */
    protected function registerModule(string $module)
    {

        // This is used to register config for each module if it has one or for getting instance configs
        $this->registerConfig($module);

        // you can disable each module in the config inside the instances array or in its own config
        $enabled = config("{$module}.enabled", true);
        if ($enabled) {
            $this->registerRoutes($module);
            $this->registerViews($module);
        }
    }

    /**
     * Register the config file for a module by its name
     *
     * @param  string $module
     *
     * @return void
     */
    protected function registerConfig(string $module): void
    {
        $key = "modules.instances.{$module}";
        $config = $this->app['config']->get($key, []);

        $file = base_path("Modules/{$module}/config.php");
        if ($this->files->exists($file)) {
            /** @noinspection PhpIncludeInspection */
            $config = array_merge(include($file), $config);
        }

        if ($config) {
            $this->app['config']->set($key, $config);
            if (! $this->app['config']->get($module)) {
                $this->app['config']->set($module, $config);
            }
        }
    }

    /**
     * Register the route for the module by its name
     *
     * @param  string $module
     * @return void
     */
    protected function registerRoutes(string $module){

        $routeFolder = $this->prepareComponent($module, 'routes');
        $AllRoutesFile = $this->files->files($routeFolder);
        // Loop through all the route file in the route folder and load them
        foreach ($AllRoutesFile as $file){
            $this->loadRoutesFrom($file->getRealPath());
        }
    }

    /**
     * Register the views for a module by its name
     *
     * @param  string $module
     * @return void
     */
    protected function registerViews(string $module)
    {
        $views = $this->prepareComponent($module, 'views');
        if ($views) {
            $this->loadViewsFrom($views, $module);
        }
    }

    /**
     * Prepare component registration
     *
     * @param  string $module
     * @param  string $component
     * @param  string $file
     *
     * @return string
     */
    protected function prepareComponent(string $module, string $component, string $file = ''): bool|string
    {
        // The first config parameter checks if the module have a config in its folder, else we use the default
        $path = config("{$module}.structure.{$component}", config("modules.default.structure.{$component}"));
        // Strips out unnecessary whitespace from the end of the module directory
        $resource = rtrim(str_replace('//', '/', base_path("Modules/{$module}/{$path}/{$file}")), '/');
        if (! ($file && $this->files->exists($resource)) && ! (!$file && $this->files->isDirectory($resource))) {
            $resource = false;
        }
        return $resource;
    }

}

I won’t go over the explanation of how the above works as I have intentionally left comments in the code.

Step 3: Add The ModulesServiceProvider To app config

The next thing is adding the ModuleServiceProvider in the app config, goto config/app.php add Modules\ModulesServiceProvider::class,

in the providers’ array, e.g:

/*
* Application Service Providers...
*/
Modules\ModulesServiceProvider::class, // App Module
.....

Step 4: Define Events in The Module Config

In each module, we have a config file, in that config file, we can define an event that pertains to that module, here is an example:

post module config

 

There is nothing stopping me from adding listeners to the events depicted above (this is just an example), a Plugin can leverage these known events and add listeners for them in their own config.

In a logical way, the module would say: “Hey plugins, any of you out there that have my events, if, there is any, we push it to the relevant event, if none, we discard it.”

Step 5: Use The Same Steps For Plugin

So, you can follow the above process, and create a new folder called Plugin at the root of your application, as we did before, you also need to create a PluginServiceProvider copy the above format, I won’t repeat that here.

You can take a step further by building a U.I for your plugins, and registering them in your database, so, you can build a logic that checks if a plugin is activated, and if so, you kick in the necessary components, well, that’s just an idea, so, getting back to the event stuff.

Step 5: Define Events in a Specific Plugin Config

You can add an event in your plugin config that leverage a specific module event, here is an example of what the plugin config would look like:

plugin events config

Now, in the PluginServiceProvider, you can have a function that checks if a plugin is activated, if so, we map the listener to the event that we already have in our main modules with a single exception:

If the module doesn’t have the event name that is listed in the plugin event, we won’t combine them, even if the devs add an arbitrary event name, we would discard it, this ensures that we are only dealing with the event the module defines, meaning an extension from the plugin would be authorized if and only if they have an event key in common to the particular module event.

This is a design decision I went with, your case might be different. I simply want to keep the communication between the Modules and the Plugins, nothing more.

Here is the implementation of that, it turns out to be simple:

$modules = array_map('basename', $this->files->directories(base_path('Modules')));
foreach ($modules as $module) { // loop through the module config, and append events
    if (Config::has($module) && config("{$module}.enabled") && config("{$module}.events") && config("{$plugin}.events")) {
    $mergePluginModule = array_merge_recursive(config($module)['events'], array_intersect_key(config($plugin)['events'], config($module)['events']));
    Config::set("{$module}.events", $mergePluginModule);
        }
    }

The array_intersect_key checks if the plugin event array has something in common with the module event, so, I just recursively merge only the intersection (using recursive merging because you might have several events in your modules). You can also do this manually using loops, with a couple of logic here and there.

Offcourse, in the future you might facilitate the communication of plugins (if you want one to extend the other), to do this, you can define an event entry in the plugin you want to extend, add a key in the plugin config that holds the commonality of events it wants other plugin to extend.

So, it is only a matter of getting the listeners of all the event wherever you want to use ’em. One good thing about all we have done so far is that our Mediator won’t become a God object, if I only need the post listeners, I simply call the Modules that holds that.

Step 7: Usage

For example, in my Post controller, here is how I am doing that:

$Listeners = $this->getListeners('Post', AfterPostFormEvent::class);

the getListeners would prepare the listeners for us, here is how it is done behind the scene:

    /**
     * @param $module
     * @param $eventName
     * @return ListenerProvider
     */
    public function getListeners($module, $eventName): ListenerProvider
    {
        $specificEvent = config((string)$module)['events'][$eventName];
        $ListenerProvider = ListenerProvider::init();
        foreach ($DraftCreatedEvent as $Listener){
            $ListenerProvider->attachListener($eventName, new $Listener);
        }
        return $ListenerProvider;
    }

We are only attaching a subset Listeners event of what we need at that point in time.

So, to use and dispatch it, I can do:

/* 
* The listeners would listen for a specific event to occur. In the below case, we are getting
* all the Listeners that are listening to the AfterPostFormEvent.
*/
$Listeners = $this->getListeners('Post', AfterPostFormEvent::class);
// We Dispatch The AfterPostFormEvent, All Listener Listening To This Event Would Be Called In Turns
// The getHtml method is specific to the AfterPostFormEvent since I am dealing with form here.
$AfterPostForm = EventDispatcher::init($Listeners)->dispatch(new AfterPostFormEvent())->getHtml();

If a new post is created, I can fire the event like so:

$Listeners = $this->getListeners('Post', NewPostCreatedEvent::class);
// We Dispatch The AfterPostFormEvent, All Listener Listening To This Event Would Be Called In Turns
// The $post variable would distribute the newly post created data to the listeners
$AfterPostForm = EventDispatcher::init($Listeners)->dispatch(new NewPostCreatedEvent($post));

Your plugin can hook into lots and lots of events defined by your modules, here are more examples:

  • Event that fires when a Post is Deleted
  • Event that fires when a Post is Created
  • Event that fires when a Post is Updated
  • Event that loads HTML content at specific section of your admin page
  • etc….

I think this would conclude this guide on building a pluggable architecture, here are some stuff you can add to enhance the design:

  • If you are saving the plugins to database, then you can try locking the plugin scanning for a period of time or you can even get all the plugins you already know (could be stored in database), loop through the plugin directory, if the directory is the same as the ones you get in the database, do nothing, if otherwise, add it.
  • Cach list of discovered event listeners
  • and so on.

Experience is the best teacher.

Posted in PHP

Comment policy: Respectful and beneficial comments are welcome with full open hands. However, all comments are manually moderated and those that doesn't relate with what the passage is saying or offensive comments would be deleted. Thanks for understanding!

Leave a Reply

Your email address will not be published. Required fields are marked *