Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
EventDispatcher
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
4 / 4
12
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dispatch
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 callListeners
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 handleThrowable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of php-fast-forward/event-dispatcher.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2025-2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/event-dispatcher
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\EventDispatcher;
20
21use FastForward\EventDispatcher\Event\NamedEvent;
22use Throwable;
23use FastForward\EventDispatcher\Event\ErrorEvent;
24use Psr\EventDispatcher\ListenerProviderInterface;
25use Psr\EventDispatcher\StoppableEventInterface;
26use Symfony\Contracts\EventDispatcher\EventDispatcherInterface as SymfonyContractsEventDispatcherInterface;
27
28/**
29 * Dispatch events through PSR-14 listener providers and optional named wrappers.
30 *
31 * This dispatcher resolves listeners for the original event object first. When the event is not already
32 * a {@see NamedEvent}, it dispatches a named wrapper afterward so listeners registered by string
33 * identifier can observe the same event instance.
34 *
35 * @see \Psr\EventDispatcher\EventDispatcherInterface
36 */
37  class EventDispatcher implements SymfonyContractsEventDispatcherInterface
38{
39    /**
40     * Create a dispatcher backed by the provided listener provider.
41     *
42     * @param ListenerProviderInterface $listenerProvider listener provider used to resolve event listeners
43     */
44    public function __construct(
45        private ListenerProviderInterface $listenerProvider
46    ) {}
47
48    /**
49     * Dispatch an event to all matching listeners.
50     *
51     * Stoppable events are returned immediately when propagation has already been halted. When a listener
52     * throws during a non-error dispatch, the dispatcher emits an {@see ErrorEvent} before rethrowing the
53     * original throwable.
54     *
55     * @param object $event event object to dispatch
56     * @param string|null $eventName explicit name for the generated {@see NamedEvent} wrapper
57     *
58     * @return object the dispatched event instance
59     *
60     * @throws Throwable thrown when a listener failure is not absorbed by an error-event listener
61     */
62    public function dispatch(object $event, ?string $eventName = null): object
63    {
64        $listeners = $this->listenerProvider->getListenersForEvent($event);
65
66        $this->callListeners($listeners, $event);
67
68        if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
69            return $event;
70        }
71
72        // Attempt to dispatch a NamedEvent wrapper if the original event is not already one.
73        // This allows listeners registered for the named form to be invoked as well.
74        if ($event instanceof NamedEvent) {
75            return $event->getEvent();
76        }
77
78        return $this->dispatch(new NamedEvent($event, $eventName ?? $event::class));
79    }
80
81    /**
82     * Invoke the provided listeners until propagation stops or a listener fails.
83     *
84     * @param iterable<callable> $listeners listeners resolved for the current event
85     * @param object $event event instance passed to each listener
86     *
87     * @throws Throwable thrown when listener failure handling rethrows an error
88     */
89    private function callListeners(iterable $listeners, object $event): void
90    {
91        $stoppable = $event instanceof StoppableEventInterface;
92
93        foreach ($listeners as $listener) {
94            if ($stoppable && $event->isPropagationStopped()) {
95                break;
96            }
97
98            try {
99                $listener($event);
100            } catch (Throwable $throwable) {
101                $this->handleThrowable($throwable, $event, $listener);
102            }
103        }
104    }
105
106    /**
107     * Dispatch an error event for a listener failure and rethrow the original throwable.
108     *
109     * Error events are not wrapped again, which prevents recursive error dispatch when an error listener
110     * itself fails.
111     *
112     * @param Throwable $throwable throwable raised by the listener
113     * @param object $event event being dispatched when the failure occurred
114     * @param callable $listener listener that raised the throwable
115     *
116     * @throws Throwable always rethrows after attempting error dispatch
117     */
118    private function handleThrowable(Throwable $throwable, object $event, callable $listener): void
119    {
120        if ($event instanceof ErrorEvent) {
121            // Rethrow the original exception to avoid recursive error dispatch.
122            throw $event->getThrowable();
123        }
124
125        $this->dispatch(new ErrorEvent($event, $listener, $throwable));
126
127        // Rethrow the original exception if not handled.
128        throw $throwable;
129    }
130}