Skip to content

03 Magic Properties

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

Prev | Next

Another enhancement the Magic trait gives you is the ability to ensure setter and getter methods are called every time you set or get a value directly from a property outside of it's defining class, whether you use the setter/getter methods or not.

Consider this academic example of a user account on a platform like StackOverflow. You have an account with reputation points. There is an event to be called once the user gets to the next "level" and gains access to new features, and so other events can be fired, like emailing them or moderators etc.

The mistake here is that the $reputation property has been set to public, allowing the events to be bypassed by mistake.

let's take a look at an example of this system being used correctly:

require_once __DIR__ . '/vendor/autoload.php';

use Affinity4\Magic\Magic;

class User
{
    // User model
}

class UserAccount
{
    use Magic;

    private $User;

    public $reputation = 0;

    public $level = 0;

    public $onReputationChange = [];

    public $onLevelUp = [];

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

    public function setReputation(int $reputation)
    {
        $current_reputation = $this->reputation;
        // We want acces to the user model also in our event listeners
        $this->onReputationChange($current_reputation, $reputation, $this->User);

        $this->reputation = $reputation;
    }

    public function getReputation(): int
    {
        return $this->reputation;
    }

    public function setLevel(int $level)
    {
        $current_level = $this->level;

        if ($current_level < $level) {
            $this->onLevelUp($level, $this->User);
        }

        $this->level = $level;
    }

    public function getLevel(): int
    {
        return $this->level;
    }
}

$User = new User;
$UserAccount = new UserAccount($User);

$UserAccount->onReputationChange[] = function(int $current_reputation, int $new_reputation, \User $User) use ($UserAccount)
{
    // Chweck this was a reputation increase and by 10 points or more
    if ($current_reputation < $new_reputation && $new_reputation >= 10) {
        echo "Reputation increased to $new_reputation\n";

        // Make sure to use the same instance of $UserAccount
        $UserAccount->setLevel(1); // Level up to Level 1
    }
};

$UserAccount->onLevelUp[] = function(int $new_level) {
    echo "You have leveled up! You're now on Level $new_level!\n";
};

$UserAccount->setReputation(10);
// echos...
// Reputation increased to 10
// You have leveled up! You're now on Level 1!

NOTE: You can set it to 9 to verify the level up event doesn't happen if you want.

This is all well and good while things are used as expected, however, because the reputation property and the level property are left as public, the following can be done:

// ....

// $UserAccount->setReputation(10);

$UserAccount->reputation = 10;

Nothing happens. You could even directly set the level property and nothing would happen. The system is unaware these properties changed.

Magic can fix this just by changing the properties to protected or private and adding 2 comments!

/**
 * @property int $reputation
 * @property int $level
 */
class UserAccount
{
    use Magic;

    private $User;

    private $reputation = 0; // Change to private

    private $level = 0; // Change to private

   // ...the rest is uncahnged!

now this...

$UserAccount->reputation = 10;

...will fire our setter events correctly.

Reputation increased to 10
You have leveled up! You're now on Level 1!

You can still use you're setters and getters as normal of course! But if you forget to, Magic will happen and keep your system working as expected.

Highlander game example

To show how all this can save you tons of conditional if/else/elseif code that becomes a nightmare to maintain, check out this game (or the start of one at least), based on 1986 movie The Highlander. You know, "There can be only one" and all that.

Requirements:

  1. There must be a Highlander class that all players are an instance of
  2. Each player starts the game with a "lifeforce" (not health related) of 10
  3. When a player kills another player they absorb/gain that opponents lifeforce, whatever it may be at the time
  4. We will be aware of how many highlanders are left only when we've killed another player
  5. If there are still other players to defeat the player will shout "There can be only one!"

That's basically the plot of the movie :)

So, first we create a class called Highlander that uses Affinity4\Magic\Magic with 2 private properties $number_of_highlanders and $lifeforce. These will have setter/getter methods set/get_number_of_highlanders and set/getLifeforce. We'll add @property docblock attributes for $number_of_highlanders and $lifeforce to enable the magic. We'll also have a shout method that just echoes a phrase

require_once __DIR__ . '/vendor/autoload.php';

use Affinity4\Magic\Magic;

/**
 * @property int $number_of_highlanders
 * @property int $lifeforce
 */
class Highlander
{
    use Magic;

    /**
     * @var int
     */
    private $number_of_highlanders= 3;

    /**
     * @var int
     */
    private $lifeforce = 10;

    public function setNumberOfHighlanders(int $number_of_highlanders)
    {
        $this->number_of_highlanders= $number_of_highlanders;
    }

    public function getNumberOfHighlanders(): int
    {
        return $this->number_of_highlanders;
    }

    public function setLifeforce(int $lifeforce)
    {
        $this->lifeforce = $lifeforce;
    }

    public function getLifeforce()
    {
        return $this->lifeforce;
    }

    public function shout(string $phrase)
    {
        echo $phrase;
    }
}

Next we create the kills method, which takes in the instance of the player you killed (so you can take their lifeforce etc). It fires the onKill event with the defeated player passed in:

require_once __DIR__ . '/vendor/autoload.php';

use Affinity4\Magic\Magic;

/**
 * @property int $lifeforce
 * @property int $number_of_highlanders
 */
class Highlander
{
    use Magic;

    private $lifeforce = 10;

    private $number_of_highlanders= 4;

    public $onKill = [];

    public function setLifeforce(int $lifeforce)
    {
        $this->lifeforce = $lifeforce;
    }

    public function getLifeforce(): int
    {
        return $this->lifeforce;
    }

    public function setNumberOfHighlanders(int $number_of_highlanders)
    {
        $this->number_of_highlanders= $number_of_highlanders;
    }

    public function getNumberOfHighlanders(): int
    {
        return $this->number_of_highlanders;
    }

    public function shout(string $phrase)
    {
        echo $phrase;
    }

    public function kill(\Highlander $Opponent)
    {
        $this->onKill($Opponent);
    }
}

$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);

// echoes...
// There are only 2 Highlanders left
// You now have 20 lifeforce!
// There can be only one!!

Not only is this less than 75 lines, but no method in the Highlander class has more than 1 line of code! And it will never need to. From now on if we decide we need more to happen when someone gets killed or makes a kill we just add more event handlers!

If that's not magic I don't what is!

Prev | Next

Clone this wiki locally