PSR-14

A Major Event in PHP

Larry Garfield

@Crell

Cover of Exploring PHP 8.0 Cover of Thinking Functionally in PHP

What's an Event?

Events in PHP

  1. An extension point mechanism
  2. Action stream (Event Sourcing)
  3. Asynchronous Loop
  4. User data for a calendar event
  5. This conference

We only mean the first one

Problem space

Component A wants to act when Component B acts

Direct Observer

  • B has list of "observer" objects
  • A registers with B
  • B iterates list and calls each
  • Profit!

Mediated Observer

  • B references "mediator" object
  • A registers with mediator
  • B triggers mediator, which iterates list
  • Profit!

Almost everyone does Mediated Observer

Symfony/EventDispatcher


          $listener = new PayforPizza();
          $dispatcher->addListener('pizza.arrived', [$listener, 'onPizzaArrived']);

          // ...

          $pizzaHere = new PizzaArrivedEvent();
          $dispatcher->dispatch('pizza.arrived', $pizzaHere);
          if ($pizzaHere->wasAnchovies()) {
            throw new UpException();
          }
        

Laravel Events


          $pizzaHere = new PizzaArrivedEvent();
          event($pizzaHere);
          // or
          Event::fire(new TestEvent());
        

League/Event


          $event = $emitter->emit('pizza.arrived');

          // or

          $pizzaHere = new class implements EventInterface { ... }
          $pizzaHere = $emitter->emit($pizzaHere);
          if ($pizzaHere->wasAnchovies()) {
            throw new UpException();
          }

          // or

          $events = $emitter->emitBatch([$pizza, $pizza]);
        

Zend Framework


          $eventManager->triggerEvent(new PizzaArrivedEvent());

          // or

          $eventManager->triggerEventUntil(function ($returnValue) {
              if ($returnValue instanceof Payment && $returnValue->paymentReceived()) {
                  return true;
              }
              return false;
          }, new PizzaArrivedEvent());
        

Drupal Hooks


          function modulename_pizza_arrived($type) { ... }

          $result = module_invoke_all('pizza_arrived', 'anchovies');
          // $result is an array, but can also pass by reference, sort of.
        

TYPO3 Signals


          $signalSlotDispatcher->connect(
            PizzaDelivery::class,
           'SIGNAL_afterarrival',
            MyListener::class,
            'eatThePizzaMethod'
          );

          $signalSlotDispatcher->dispatch(
            __CLASS__, 'pizzaArrived', [$extensionKey, $this]
          );
        

So which do I use?

¯\_(ツ)_/¯

          As a library author
          I want to target one extension mechanism
          So that people can use my library
          with any framework
        
Kitten will fix it

Enter stage left

Ninjagrl offering a FIG to a turtle

Bec Simensen, https://ninjagrl.com/artwork/fig

PHP-FIG

  • PHP Framework Interoperability Group
  • United Nations of PHP
  • PHP Standard Recommendations (PSR)
    • Autoloading
    • Coding standards
    • Caching
    • Logging
    • HTTP handling

PSR-14: The team

  • Larry Garfield
    Larry Garfield
    (Editor)
  • Cees-Jan Kiewiet
    Cees-Jan Kiewiet
    (Sponsor)
  • Elizabeth Smith
    Elizabeth Smith
  • Matthew Weier O'Phinney
    Matthew Weier O'Phinney
  • Ryan Weaver
    Ryan Weaver
  • Benni Mack
    Benni Mack

PSR-14

Goals

  • Let libraries expose "Events" generically
  • Make listening to Events easy
  • Easy migration path

Non-Goals

  • Event Sourcing
  • Async Event Loop
  • Strict BC

So what's it supposed to do?

Use cases

  1. Notification: I did a thing! If you care...
  2. Enhancement: Please change this thing before I use it.
  3. Collection: Give me all your things!
  4. Alternative chain: First to use it wins

Design principles

  • The Type system is your friend
  • Composability is your friend
  • Standardize as little as possible but no less than that

The spec


          interface EventDispatcherInterface {
              /**
               * @return object
               *   The Event that was passed, now modified by listeners.
               */
              public function dispatch(object $event);
          }

          interface ListenerProviderInterface {
              public function getListenersForEvent(object $event) : iterable;
          }

          interface StoppableEventInterface {
              public function isPropagationStopped() : bool;
          }
        

Usage (Library author)


          $provider = new Provider();
          // ...

          $dispatcher = new Dispatcher($provider);

          $pizzaArrived = $dispatcher->dispatch(new PizzaArrived());
        
  • Provider: Map Events to Listeners
  • Dispatcher: Call Listeners

Basic Dispatcher


          class Dispatcher implements DispatcherInterface {
            // Constructor goes here.

            public function dispatch(object $event) {
              foreach ($this->provider->getListenersForEvent($event) as $listener) {
                if ($event instanceof StoppableEventInterface
                  && $event->isPropagationStopped()) {
                    break;
                }
                $listener($event);
              }
              return $event;
            }
          }
        

("Unstoppable Events" is my new band name...)

Dispatchers are the API to calling libraries

Providers are the API to frameworks

Providers

Can map Events to Listeners based on...

  • its class/interface type
  • metadata
  • permissions
  • day of the week

Can order Listeners based on...

  • Explicit priority
  • Before/after ordering
  • Domain-dependent logic
  • Random

"Listener" is any PHP callable

So how do Listeners get into the Provider?

¯\_(ツ)_/¯

Crell/Tukio


          $provider = new OrderedListenerProvider();

          // Any order.
          $provider->addListener(function(PizzaArrived $event) {
            log($event->hasAnchovies());
          });

          // Priority ordering.
          $key = $provider->addListener([$listener, 'pizzaHere'], 5);

          // Relative ordering.
          $provider->addListenerBefore($key, 'my_listener_function');
       

Events map to Listeners by reflection!

Crell/Tukio: Services


          $provider = new OrderedListenerProvider($PSR_11_Container);

          $provider->addListenerService(
            'service_name', 'handlePizza', PizzaArrived::class, 5
          );

          // (Also before/after support)
        

Crell/Tukio: Subscribers


          class MySubscriber implements SubscriberInterface {

            // This method will become a Listener for PizzaArrived.
            public function onPizzaArrived(PizzaArrived $event) : void { /*...*/ }

            #[ListenerPriority(10)]
            public function ifPizzaWrong(WrongPizzaDelivered $event) : void { /*...*/ }

            // This method is registered explicitly below.
            public function ifPizzaLate(PizzaArrivedLate $event) : void { /*...*/ }

            public static function registerListeners(ListenerProxy $proxy): void
            {
                // Give this Listener a higher-than-default priority.
                $a = $proxy->addListener('ifPizzaLate', 10);
            }
          }

          $provider->addSubscriber('service_name', MySubscriber::class);
        

(Most of this gets setup in your Container config.)

Crell/Tukio: Compiled


          $builder = new ProviderBuilder();
          $builder->addSubscriberService(PizzaSubscriber::class, 'pizza_subscriber');
          $compiler = new ProviderCompiler();

          // Write the generated compiler out to a file.
          $filename = '/path/to/compiled/code/provider.php';
          $out = fopen($filename, 'w');
          $compiler->compile($builder, $out, 'CompiledProvider', 'My\\App');
          fclose($out);
        

          include($filename); // Or autoload it.

          $provider = new My\App\CompiledProvider($container);
          $dispatcher = new WhateverDispatcher($provider);
        

bmack/kart-composer-plugin

composer.json


          {
              "extra": {
                  "psr-14": {
                      "default": "src/Listeners"
                  }
              }
          }
        

setup.php


          $provider = new Bmack\KartComposerPlugin\ComposerReflectionListenerProvider();
        

Any method on a class in that directory is now a Listener.
You're welcome.

Or write your own

Or use your framework's

  • Symfony 4.3+
  • Laminas (Zend Framework)
  • Yii 3.0
  • TYPO3 10.0
  • Sulu

(If yours isn't here, feature request time!)

Packagist: 41,127,008 Installs, 349 Dependents

Bidirectional communication

  • Events MAY be mutable, or not, your choice
  • Modify to pass back data
  • Events are your domain model!

Example: Access control

Access permitted iff...

  • >=1 Listener says Allow
  • 0 Listeners say Deny
  • Listeners can say "don't care"

The Event


          class AccessCheck implements StoppableEventInterface {
            protected Document $doc;
            protected ?bool $allow = null;

            public function __construct(Document $doc) { $this->doc = $doc; }
            public function document(): Document { return $this->doc; }

            public function allow(): void { $this->allow = true; }
            public function deny(): void { $this->allow = false; }

            public function allowed(): bool {
              return $this->allow === true;
            }

            public function isPropagationStopped(): bool {
              return $this->allow === false;
            }
          }

          class CreateAccessCheck extends AccessCheck {}
          class ReadAccessCheck extends AccessCheck {}
          class UpdateAccessCheck extends AccessCheck {}
          class DeleteAccessCheck extends AccessCheck {}
        

The Listeners


          $provider = new OrderedListenerProvider();
          $dispatcher = new Dispatcher($provider);
          $user = get_current_user_somehow();

          $provider->addListener(function (AccessCheck $event) use ($user) {
             if ($user->isGuest()) {
                 $event->deny();
             }
          });

          // Note specific class.
          $provider->addListener(function (UpdateAccessCheck $event) use ($user) {
             $isOwnDocument = $event->document()->owner() == $user->id();
             if ($isOwnDocument && $user->hasPermission('edit own documents')) {
                 $event->allow();
             }
          });
        

The caller


          $allow = $dispatcher->dispatch(new UpdateAccessCheck($document))->allowed();
          if (!$allow) {
             // Show an error message or something.
          }
        

AccessCheck is the API!

Works with any Dispatcher or Provider!

Custom Providers


          class AccessCheckerProvider implements ListenerProviderInterface {

             public function addVoter(callable $voter, bool $canDeny = false) { ... }

             public function getListenersForEvent(object $event): iterable { ... }
          }
        

Does that mean I need multiple dispatchers?

Aggregate Providers


          class AggregateProvider implements ListenerProviderInterface {
            // ...

            public function addProvider(ListenerProviderInterface $p): void {
              $this->providers[] = $provider;
            }

            public function getListenersForEvent(object $event): iterable {
              foreach ($this->providers as $provider) {
                  yield from $provider->getListenersForEvent($event);
              }
            }
          }
        

Provider concatenation

fig/event-dispatcher-util

Delegating Providers


          class DelegatingProvider implements ListenerProviderInterface {
            protected $providers = [
              PizzaArrived::class => [$provider1, $provider2],
              TacosArrived::class => [$provider3, $provider4],
              AccessCheck::class => [$accessCheckProvider],
            ];
            // ...
            public function getListenersForEvent(object $event): iterable {
              foreach ($this->providers as $type => $providers) {
                if ($event instanceof $type) {
                  foreach ($providers as $provider) {
                    yield from $provider->getListenersForEvent($event);
                  }
                  return;
                }
              }
              yield from $this->defaultProvider->getListenersForEvent($event);
            }
          }
        

Provider branching

Mix and match


          $compiledProvider = new MyCompiledProvider();
          $orderedProvider = new OrderedListenerProvider();

          $defaultProvider = new AggregateProvider();

          $defaultProvider->addProvider($compiledProvider);
          $defaultProvider->addProvider($orderedProvider);

          $accessCheckProvider = new AccessCheckProvider();

          $delegatingProvider = new DelegatingProvider($defaultProvider);

          $delegatingProvider->addProvider($accessCheckProvider, [AccessCheck::class]);

          $dispatcher = Dispatcher($delegatingProvider);
        

Deferred events

This Event will be slow, and I don't care about the result.

You don't know that.

Only the Listener author knows what's safe to defer!

Example: Queued Events

I want to send an email on save


          $connection = new AMQPStreamConnection('localhost', 5672, 'guest', 'guest');
          $channel = $connection->channel();

          $channel->queue_declare('doc_notifications', false, false, false, false);
        

          $provider->addListener(function (DocumentSaved $event) use ($channel) {
            $details = [
              'document_id' => $event->getSubject()->id(),
            ];

            $message = new AMQPMessage(serialize($details));
            $channel->basic_publish($message, '', 'doc_notifications');
          });
        

Example: Generic queuing


          class QueueableProvider implements ListenerProviderInterface {
            protected $listeners = [];

            public function __construct(
                protected ContainerInterface $container,
                protected AMQPChannel $channel = null,
            ) {
              if ($this->channel) {
                $this->channel->queue_declare('events', false, false, false, false);
              }
            }
            // ...
          }
        

Example: Generic queuing


          public function addListenerService(
              string $service, string $method, string $type, bool $queue): void {

            if ($queue && $this->channel) {
              $listener = $this->makeListenerForQueue($service, $method);
            }
            else {
              $listener = $this->makeListenerForService($service, $method);
            }

            $this->listeners[] = [
              'listener' => $listener,
              'type' => $type,
            ];
          }
        

Example: Generic queuing


          class QueueableProvider implements ListenerProviderInterface {
            // ...

            protected function makeListenerForService(
              string $service,
              string $method,
            ) : callable {
              $container = $this->container;

              return fn(object $event) => $container->get($service)->$method($event);
            }
          }
        

Example: Generic queuing


          protected function makeListenerForQueue(
              string $service, string $method) : callable {
            $channel = $this->channel;

            return function (object $event) use ($service, $method, $channel) : void {
              $details = [
                'service' => $service,
                'method'  => $method,
                'event'   => $event,
              ];

              $message = AMQPMessage(serialize($details));
              $channel->basic_publish($message, '', 'events');
            };
          }
        

Example: Generic queuing


          class QueueableProvider implements ListenerProviderInterface {
            // ...

            public function getListenersForEvent(object $event): iterable {
              foreach ($this->listeners as $listener) {
                if ($event instanceof $listener['type']) {
                  yield $listener['listener'];
                }
              }
            }
          }
        

Example: Generic queuing


          function queue_runner(AMQPChannel $channel, ContainerInterface $container) {

            $worker = function (AMQPMessage $message) use ($container) {
              $details = unserialize($message->body);

              $listener = $container->get($details['service'])->$details['method'];
              $listener($details['event']));
            };

            $channel->queue_declare('events', false, false, false, false);
            $channel->basic_consume('events', '', false, true, false, false, $worker);
            while (count($channel->callbacks)) {
              $channel->wait();
            }
          }
        

PSR-14: tl;ce

(Too long; checked email)

  • Library authors have a universal extension target
  • Framework authors only need to write Provider
  • Off-the-shelf universal Dispatchers and Providers
  • Domain-aware Providers are easy

Interoperability++

Further reading

Get the free book!

https://bit.ly/psr14-book

(Or donate to support OSMI.)

composer require psr/event-dispatcher

Larry Garfield

@Crell

All about PHP 8!

https://bit.ly/php80

Cover of Exploring PHP 8.0

Buy my book!

https://bit.ly/fn-php

Cover of Thinking Functionally in PHP

https://www.garfieldtech.com/