PSR-14

A Major Event in PHP

Larry Garfield

@Crell

Larry implements Huggable

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: 22,444,705 Installs, 306 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 $doc;
            protected $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://leanpub.com/major-event-in-php

(Or donate to support OSMI.)

composer require psr/event-dispatcher

Larry Garfield

@Crell

Director of Developer Experience Platform.sh

The end-to-end web platform for agile teams

Stalk us at @PlatformSH

Buy my book!

https://bit.ly/fn-php

Cover of Thinking Functionally in PHP