Skip to content
Shesh Ghimire edited this page Jan 3, 2025 · 36 revisions

Event provides a pub/sub interface to listen for events when a concrete passes through various build stages.

If you haven't already, refer to this page to learn how to bootstrap and use container.

NOTE: Although Event helps to perform additional tasks during build process, in most cases a simple registration might be sufficient. It is recommended to use this feature only when some not-so-simple tasks needs to performed when resolving a concrete.

Event Manager

By default, an Event Manager manages Event Dispatcher for all build stage events defined in EventType enum. To disable event dispatcher for any event type:

use TheWebSolver\Codegarage\Container\Container;
use TheWebSolver\Codegarage\Container\Event\Manager\EventManager;

$eventManager = new EventManager();

// Prevent listening for event listeners during build stage.
$eventManager->setDispatcher(false, EventType::Building);

$app = new Container(eventManager: $eventManager);

// Set the above instantiated Container as a singleton instance.
// However, if `Container::boot()` is already invoked before,
// "$app" won't be set coz singleton is already initialized.
Container::use($app);

Container::boot() === $app; // true. Container with "EventType::Building" disabled.
  • Event Dispatcher set as false will never listen for that EventType.
  • Event Dispatcher for each EventType can only be set once.

Event Listeners

Event Listeners can be a function (named or lambda), or a class method. Event Listeners accepts an event instance based on EventType being listened for.

An entry can have as many event listeners as needed by the project. But most of the time, there might only be a single event listener registered.

Simple Registration

Container provides fluent interface using Builder Pattern to register an Event Listener for an entry of a certain EventType.

This registration process is the RECOMMENDED way to register an Event Listener and will be used throughout Event documentation.

$app->when(EventType::BeforeBuild)      // Or other enum case as required.
    ->for($entry)                       // Entry varies based on "EventType" being used.
    ->listenTo(
        listener: anEventListener(...), // listener accepts an $event object based on EventType.
        priority: 10                    // Default is 10. Lower the number, earlier it will be listened.
    );

// Adding another listener for same "$entry" which'll be listened after "anEventListener".
$app->when(EventType::BeforeBuild)
    ->for($entry)
    ->listenTo(delayedEventListener(...), priority: 20);

Advanced Registration

Container also provides a getter method to get an Event Listener Registry of a certain EventType. In most cases, simple registration should suffice.

It may be used if required:

  • to check event listeners that have already been registered.
  • to be informed about the high and low priorities set before.
  • to get/reset event listeners that has already been registered.

To register an Event Listener for an entry of a certain EventType:

$app->getListenerRegistry(EventType::BeforeBuild)?->addListener(
    anEventListener(...),
    $entry,
    $priority
);

Event

Event dispatched by Event Dispatcher implements:

  • Taggable: provides an entry name for whom listeners are listened by the dispatcher.
  • Stoppable: represents event has been completed and prevents further Listeners from being called.

Before Build Event

This is the first stage in the build process of any concrete. Here, an Event Dispatcher will dispatch a BeforeBuildEvent. All listeners registered will accept this event to SET/GET/UPDATE the concrete's dependency based on Parameter Name.

use WeakMap;
use ArrayAccess;
use TheWebSolver\Codegarage\Container\Event\EventType;
use TheWebSolver\Codegarage\Container\Event\BeforeBuildEvent;

function weakMapProviderEventListener(BeforeBuildEvent $e): void {
    // It is actually possible but NOT RECOMMENDED to perform entry checks in event listeners.
    // But, be aware this event listener must not be registered for a concrete that injects
    // same Parameter Name but the expected value is a string instead of a WeakMap object.
    // If needed, create another event listener to follow Single Responsibility Principle.
    $shouldListenForEntry = match ($e->getEntry()) {
        Access::class, 'arrayValues', Accessible::class => true,
        default                                         => false,
    };

    // It is scoped for an Access class, its alias or an interface it is registered to.
    if ($shouldListenForEntry) {
        $e->setParam(name: 'stack', value: new WeakMap());
    }
}

interface Accessible {
	public function getStack(): ArrayAccess;
}

class Access implements Accessible {
    public function __construct(private ArrayAccess $stack) {}

    public function getStack(): ArrayAccess {
        return $this->stack;
    }
}

$app->when(EventType::BeforeBuild)
    ->for(Access::class)
    ->listenTo(weakMapProviderEventListener(...));

$app->get(Access::class)->getStack() instanceof WeakMap; // true.

// Or, when classname is aliased, alias can be used instead:
$app->setAlias(Access::class, 'arrayValues');

$app->when(EventType::BeforeBuild)
    ->for('arrayValues')
    ->listenTo(weakMapProviderEventListener(...));

$app->get('arrayValues')->getStack() instanceof WeakMap; // true.

// Or, when classname or its alias is registered to an interface,
// interface (highly recommended) can be used instead:
$app->set(Accessible::class, 'arrayValues');

$app->when(EventType::BeforeBuild)
    ->for(Accessible::class)
    ->listenTo(weakMapProviderEventListener(...));

$app->get(Accessible::class)->getStack() instanceof WeakMap; // true.

Building Event

==⚠️ If EventType::Building stage is disabled as documented in Event Manager section, then LogicalError is thrown when using Builder Pattern.==

This is the second stage in the build process of any concrete. Here, an Event Dispatcher will dispatch a BuildingEvent. All listeners registered will accept this event to resolve dependency based on dependency Parameter TypeHint and Parameter Name.

This sounds a lot like Before Build Event but the intention is very different. Here, instead of the concrete classname, its injected dependency parameter name and type-hint is used as an entry. Meaning, wherever this exact parameter name and type-hint is used, it gets resolved irrespective of the concrete classname it is injected on.

The main use case of Building Event is to resolve some pre-defined dependencies based on the project's configuration.

use TheWebSolver\Codegarage\Container\Data\Binding;
use TheWebSolver\Codegarage\Container\Event\BuildingEvent;

function fixedRateLimiterEventListener(BuildingEvent $e): void {
    $e->setBinding(new Binding(FixedRateLimiter::class));
}

function slidingRateLimiterEventListener(BuildingEvent $e): void {
    // Register Sliding Rate Limiter as shared (singleton).
    $e->setBinding(new Binding(SlidingRateLimiter::class, isShared: true));
}

interface LimiterInterface {
    public function consume(int $noOfTokens = 1): bool
}

class FixedRateLimiter implements LimiterInterface {
    public function consume(int $noOfTokens = 1): bool {
        $hasLimitReached = // ...Compute rate limit based on fixed window policy with "$noOfTokens".

        return $hasLimitReached;
    }
}

class SlidingRateLimiter implements LimiterInterface {
    public function consume(int $noOfTokens = 1): bool {
        $hasLimitReached = // ...Compute rate limit based on sliding window policy with "$noOfTokens".

        return $hasLimitReached;
    }
}

Using Builder Pattern

This is useful where user has the flexibility to create multiple configurable instances of the same class.

Lets say project's Rate Limiter is configured in such a way that:

  • It has a provider class that provides Rate Limiters based on $id and $policy.
    • See: RateLimiterProvider below
  • The injected Rate Limiter's Parameter Name must end with RateLimiter.
    • See: $web1MinuteRateLimiter and $apiRateLimiter accepted by WebMiddleware and ApiMiddleware below
use TheWebSolver\Codegarage\Container\Container;
use TheWebSolver\Codegarage\Container\Event\EventType;

final class RateLimiterProvider {
    public function __construct(private Container $app) {}

    public function provideFor(string $id, string $policy): void {
        $entry         = lcfirst(trim($id)); // ...Perform transformation as needed.
        $paramName     = "{$entry}RateLimiter";
        $eventListener = 'fixed' === $policy
            ? fixedRateLimiterEventListener(...)
            : slidingRateLimiterEventListener(...)

        $this->app->when(EventType::Building)
            ->for(LimiterInterface::class, $paramName)
            ->listenTo($eventListener);
    }
}

$provider = $app->get(RateLimiterProvider::class);

// User registers multiple rate limiters:
$provider->provideFor(id: 'web15Seconds', policy: 'fixed');
$provider->provideFor(id: 'Web1Minute', policy: 'fixed');
$provider->provideFor(id: 'api', policy: 'sliding');

// User creates middlewares and inject dependencies following set standard.
// Parameter name must be a transformed "$id" with "RateLimiter" suffix.
class WebMiddleware {
    public function __construct(public readonly LimiterInterface $web1MinuteRateLimiter) {}
}

class ApiMiddleware {
    public function __construct(public readonly LimiterInterface $apiRateLimiter) {}
}

class ServiceThatNeedsApiRateLimiter {
    public function __construct(public readonly LimiterInterface $apiRateLimiter) {}
}

$app->get(WebMiddleware::class)->web1MinuteRateLimiter instanceof FixedRateLimiter; // true.

// Concretes that inject parameter with same type-hint and name. Because SlidingRateLimiter is
// set as shared, same Rate Limiter instance is injected instead of two separate instances.
$middlewareRateLimiter = $app->get(ApiMiddleware::class)->apiRateLimiter;
$serviceRateLimiter    = $app->get(ServiceThatNeedsApiRateLimiter::class)->apiRateLimiter;

$middlewareRateLimiter instanceof SlidingRateLimiter; // true.
$serviceRateLimiter instanceof SlidingRateLimiter; // true.
$middlewareRateLimiter === $serviceRateLimiter; // true.

Using PHP Attribute

This is useful where a predefined/fixed configuration must be used.

use TheWebSolver\Codegarage\Container\Attribute\ListenTo;

class WebMiddleware {
    public function __construct(
        #[ListenTo('fixedRateLimiterEventListener')]
        public readonly LimiterInterface $webRateLimiter
    ) {}
}

class ApiMiddleware {
    public function __construct(
        #[ListenTo('slidingRateLimiterEventListener')]
        public readonly LimiterInterface $apiRateLimiter
    ) {}
}

// Event Listener will get registered automatically from PHP Attributes.
$app->get(WebMiddleware::class)->webRateLimiter instanceof FixedRateLimiter; // true.
$app->get(ApiMiddleware::class)->apiRateLimiter instanceof SlidingRateLimiter; // true.

After Build Event

This is the final stage in the build process of any concrete. Here, an Event Dispatcher will dispatch an AfterBuildEvent. All listeners registered will accept this event to UPDATE/DECORATE the concrete's instance.

This is useful when some initialization is needed immediately after concrete is instantiated but before returning it back to the user.

This stage provides two distinct features:

  • Decorators: When the concrete's resolved instance needs some behavioral changes inside its methods. Decorators must inject the resolved instance as first parameter (type-hint to an interface and not the concrete) in it's constructor.
  • Updaters: When the concrete's resolved instance properties needs to be updated (using setter/getter method). Updaters must accept the resolved instance as first parameter (type-hint to an interface and not the concrete) and optionally, Container as second parameter.

When both decorators and updaters are provided for the same concrete, decorators are resolved first, and only then updaters are invoked (for the last resolved decorator instance if decorators are provided).

See After Build Event Feature Test using AfterBuildEvent Wiki Codes shown below.

Let's say only digital goods are sold by a company. Project is setup like so:

use ArrayAccess;
use ArrayObject;
use TheWebSolver\Codegarage\Container\Event\AfterBuildEvent;

function customerDetailsEventListener(AfterBuildEvent $event): void {
    // When resolving decorator, provide "ArrayObject" instance with "zip_code" initialized.
    $event->app()->when(MerchCustomerDetails::class)
        ->needs(ArrayAccess::class)
        ->give(static fn(): ArrayAccess => new ArrayObject(['zip_code' => '44600']));

    $event
        ->decorateWith(MerchCustomerDetails::class)
        ->update(
            static function(Customer $customer): void {
                $personalInfo = $customer->getPersonalInfo();

                $personalInfo['first_name'] = 'John';
                $personalInfo['last_name']  = 'Doe';
                $personalInfo['age']        = '41';

                $customer->setPersonalInfo($personalInfo);
            }
    );
}

interface Customer {
    public function setPersonalInfo(ArrayAccess $details): void;
    public function getPersonalInfo(): ArrayAccess;
    public function getAddress(): ArrayAccess;

    /** @return array{firstName:string,lastName:string,age:int} */
    public function personalInfoToArray(): array;

    /** @return array{state:string,country:string,zipCode:int} */
    public function addressToArray(): array;
}

class CustomerDetails implements Customer {
    public function __construct(
        private ArrayAccess $personalInfo,
        private ArrayAccess $address,
    ) {
        $address['state']    = 'Bagmati';
        $address['country']  = 'Nepal';
        $address['zip_code'] = '44811';
    }

    public function setPersonalInfo(ArrayAccess $details): void {
        $this->personalInfo = $details;
    }

    public function getPersonalInfo(): ArrayAccess {
        return $this->personalInfo;
    }

    public function getAddress(): ArrayAccess {
        return $this->address;
    }

    public function personalInfoToArray(): array {
        return [
            'firstName' => $this->personalInfo['first_name'],
            'lastName'  => $this->personalInfo['last_name'],
            'age'       => (int) $this->personalInfo['age'],
        ];
    }

    public function addressToArray(): array {
        return [
            'state'   => $this->address['state'],
            'country' => $this->address['country'],
            'zipCode' => (int) $this->address['zip_code'],
        ];
    }
}

Later, company started providing some free Merch to their loyal customers and wanted to give customers flexibility to set their shipping address. Instead of updating the CustomerDetails class, a new MerchCustomerDetails Decorator class is introduced:

class MerchCustomerDetails implements Customer {
    public function __construct(
        private Customer $customer,
        private ?ArrayAccess $shippingAddress = null,
    ) {}

    public function setPersonalInfo(ArrayAccess $details): void {
        $this->customer->setPersonalInfo($details);
    }

    public function getPersonalInfo(): ArrayAccess {
        return $this->customer->getPersonalInfo();
    }

    public function getAddress(): ArrayAccess {
        return $this->customer->getAddress();
    }

    public function personalInfoToArray(): array {
        return $this->customer->personalInfoToArray();
    }

    public function addressToArray(): array {
        $address = $this->customer->addressToArray();

        if (null === $this->shippingAddress) {
            return $address;
        }

        // Changes behavior on how shipping address is generated.
        return [
            'state'   => $this->shippingAddress['state'] ?? $address['state'],
            'country' => $this->shippingAddress['country'] ?? $address['country'],
            'zipCode' => (int) ($this->shippingAddress['zip_code'] ?? $address['zip_code']),
        ];
    }
}

Using Builder Pattern

use ArrayAccess;
use ArrayObject;
use TheWebSolver\Codegarage\Container\Event\EventType;

// Register to container.
$app->set(Customer::class, CustomerDetails::class);

// Provide the "ArrayObject" class for "ArrayAccess" interface.
$app->when(CustomerDetails::class)
    ->needs(ArrayAccess::class)
    ->give(ArrayObject::class);

// Before event listeners are registered.
$customer = $app->get(Customer::class);
$customer instanceof CustomerDetails; // true.
empty($customer->getPersonalInfo()); // true.

// Decorate "CustomerDetails" class with "MerchCustomerDetails", and update
// "CustomerDetails::$personalInfo" property with default values.
$app->when(EventType::AfterBuild)
    ->for(Customer::class)
    ->listenTo(customerDetailsEventListener(...));

// After event listeners are registered.
$customer = $app->get(Customer::class);
$customer instanceof MerchCustomerDetails; // true.
$customer->personalInfoToArray()['firstName'] === 'John'; // true.
[
    'state'   => 'Bagmati', // from CustomerDetails::$address.
    'country' => 'Nepal',   // from CustomerDetails::$address.
    'zipCode' => 44600,     // from MerchCustomerDetails::$shippingAddress.
] === $customer->addressToArray(); // true.

Using PHP Attribute

This is useful when:

  • decoration is internal, or
  • overcome multiple configuration changes required to decorate an existing class.

Continuing from example above.

use TheWebSolver\Codegarage\Container\Attribute\DecorateWith;

#[DecorateWith('customerDetailsEventListener')]
class CustomerDetails implements Customer {
    // ...implements as shown in example above.
}

Clone this wiki locally