Skip to content

04 Invokable Classes as Event Handlers

Luke Watts edited this page Feb 18, 2024 · 2 revisions

Prev

While callbacks as event handlers are convenient and quick to write, they have limitations and can often encourage bad design choices.

For example, our Highlander game example in the Magic Properties page, which used callbacks as event handler only had about 11 lines of code. However, it already has serious problems that will only get worse as more lines are added or more callbacks are added.

This is the event handler:

$Highlander = new Highlander;
$Opponent = new Highlander;

// He killed someone along the way here. But so far only 
// he's aware there are only 3 Highlanders left, our player still thinks there are 4
--$Opponent->number_of_highlanders; 

$Highlander->onKill[] = function($Opponent) use ($Highlander) {
    $Highlander->lifeforce += $Opponent->getLifeforce();

    if ($Opponent->number_of_highlanders< $Highlander->number_of_highlanders) {
        $Highlander->number_of_highlanders= ($Opponent->number_of_highlanders- 1);
    }

    echo "You lifeforce is {$Highlander->lifeforce}!\n";
    echo "There are {$Highlander->number_of_highlanders} highlanders left\n";

    if ($Highlander->number_of_highlanders> 1) {
        $Highlander->shout("There can be only one!!!\n");
    }
};

$Highlander->kill($Opponent);

Problem 1: Enforcing Types

Let's start with the first line:

$Highlander->onKill[] = function($Opponent) use ($Highlander) {

The issue here is that we cannot enforce types. We can type hint $Opponent in our Highlander::kill() method that fires the onKill method, but that assumes we're passing the same value through to the kill method. We may in fact be passing it a generated value, that could be anything.

We're also unable to ensure $Highlander is actually an instance of \Highlander. If something else gets passed in we'll either get errors or worse, we could pass in another class with the same properties and methods that does completely unexpected things. This would mean no errors, but quite possibly hard to debug side-effects.

Problem 2: Single Responsibility

With only a callback to add our code to, we lose the organisational benefits of OOP. It's quite easy to end up breaking SRP without even realizing, especially on projects with numerous developers.

While out code looks initially like it all belongs together, with some closer examination, we can see it's actually modifying 2 parts of our Highlander class, updating $lifeforce and updating $number_of_highlanders

$Highlander->lifeforce += $Opponent->getLifeforce();
echo "You lifeforce is {$Highlander->lifeforce}!\n";

if ($Opponent->number_of_highlanders< $Highlander->number_of_highlanders) {
    $Highlander->number_of_highlanders= ($Opponent->number_of_highlanders - 1);
}

echo "There are {$Highlander->number_of_highlanders} highlanders left\n";

if ($Highlander->number_of_highlanders> 1) {
    $Highlander->shout("There can be only one!!!\n");
}

The first 2 lines are only dealing with the $lifeforce property, and should be moved out of this function. However, splitting everything out into their own callback would quickly become messy and hard to maintain. Callbacks and closures would require reading the code to determine what they are doing. If these lines were instead refactored to a class we would know what each class is for and what each method should be doing from the names (which should be clear and descriptive). We would also have everything else classes provide which callbacks do not.

Problem 3: Organization

How should we organize all of this? Should we simply create a separate file for each event in the application and dump everything in each file? We could, but I can imagine that becoming pretty horrible after a while.

Instead, if we had autoloading and a sensible folder structure we could simply loop over autoloaded classes and add event listeners to events. This would mean creating a new class in the right folder would be all it would take to bind a handler to an event.

Solution

Invokable classes can solve all of these problems, and give a few more perks that only OOP can provide. So let's refactor our existing code in to 2 separate event handler classes LifeforceEventHandler and NumberOfHighlandersEventHandler.

TakeOpponentsLifeforceEventHandler

The only requirement of an invokable event handler class is the it has an __invoke() method with the same arguments as out callback. However, we can now do more "setup" using the constructor as well.

Our event handler would now look something like:

class TakeOpponentsLifeforceEventHandler
{
    private $Highlander;

    public function __construct(\Highlander $Highlander)
    {
        $this->Highlander = $Highlander;
    }

    public function __invoke(\Highlander $Opponent)
    {
        $this->Highlander->lifeforce += $Opponent->getLifeforce();

        echo "You're lifeforce is now {$this->Highlander->lifeforce}!\n";
    }
}

We can now now enforce our $Highlander and $Opponent arguments are \Highlander instances. Really we should be using an interface here but that's up to you.

It's also quite clear that this classes purpose is to deal with anything to do with taking your opponents lifeforce.

We could even use the Magic trait here and fire an event for other classes to subscribe to. Let's say we need to add a SpecialAbility feature that gives a player a random special ability after they hit 50 lifeforce points. We could simply add an event onFiftyLifeforce in out __invoke() method. Now our special ability class could subscribe to this event to do what it needs to do.

UpdateNumberOfHighlandersEventHandler

It should be pretty obvious how to implement the UpdateNumberOfHighlandersEventHandler but for completeness sake let's see it

class UpdateNumberOfHighlandersEventHandler
{
    private $Highlander;

    public function __construct(\Highlander $Highlander)
    {
        $this->Highlander = $Highlander;
    }

    private function decrementNumberOfHighlanders(\Highlander $Opponent)
    {
        if ($Opponent->number_Of_highlanders < $this->Highlander->number_Of_highlanders) {
            $this->Highlander->number_Of_highlanders = (--$Opponent->number_Of_highlanders);
        }
    }

    public function __invoke(\Highlander $Opponent)
    {
        $this->decrementNumberOfHighlanders($Opponent);

        echo "There are {$this->Highlander->number_Of_highlanders} highlanders left\n";
    
        if ($this->Highlander->number_Of_highlanders > 1) {
            $this->Highlander->shout("There can be only one!!!\n");
        }
    }
}

Calling the Event Handlers

To attached our event handlers we simply replace the callbacks with the initialized EventHandler class, like so:

$Highlander = new Highlander;
$Opponent = new Highlander;

// He killed someone along the way here. But so far only 
// he's aware there are only 3 Highlanders left, our player still thinks there are 4
--$Opponent->number_of_highlanders;

$Highlander->onKill[] = new TakeOpponentsLifeforceEventHandler($Highlander);

$Highlander->onKill[] = new UpdateNumberOfHighlandersEventHandler($Highlander);

$Highlander->kill($Opponent);

Internally, the invoke methods will be used and passed in our the $Opponent instance from the kill() method

Prev