Events

A plain PSR-14 event dispatcher — Psr\EventDispatcher\EventDispatcherInterface — with attribute-driven listener registration.

Writing an event and a listener

An event is a plain object, no base class or marker interface required:

final readonly class OrderPlaced
{
    public function __construct(
        public int $orderId,
        public string $customerEmail,
    ) {}
}

A listener is any public method carrying #[Listener] — the attribute takes no arguments, since the event class is inferred from the method’s own single parameter type:

use Kinetis\Events\Listener;

final readonly class SendOrderConfirmation
{
    public function __construct(private Mailer $mailer) {}

    #[Listener]
    public function onOrderPlaced(OrderPlaced $event): void
    {
        $this->mailer->send($event->customerEmail, 'Order confirmed');
    }
}

One class can carry multiple #[Listener] methods for different events, the same way a controller can carry multiple routed methods.

Dispatching

Nothing to register — any class anywhere under one of your own PSR-4 roots carrying a #[Listener] method is found automatically, with no directory or namespace convention required. A controller constructor-injects the concrete EventDispatcher class — not Psr\EventDispatcher\EventDispatcherInterface — and calls it directly:

use Kinetis\Events\EventDispatcher;

final readonly class OrderController
{
    public function __construct(private EventDispatcher $events) {}

    #[Post('/orders')]
    public function store(#[Body] CreateOrderRequest $data): array
    {
        // ...
        $this->events->dispatch(new OrderPlaced($order->id, $data->email));

        return ['status' => 'created'];
    }
}

Note

Inject the concrete EventDispatcher class, not the PSR interface. EventDispatcher is never explicitly registered anywhere — it’s autowired fresh per request, the same way TransactionGuard is — and PHP can only autowire a concrete class by reflecting its constructor, not an unbound interface.

Every listener registered for an event’s exact class runs — a listener for OrderPlaced does not also fire for a subclass.

Ordering multiple listeners for the same event

#[Listener(priority: int = 50)] — bounded 0-100, throwing InvalidArgumentException outside that range — decides which listener runs first when more than one is registered for the same event. Higher runs earlier:

#[Listener(priority: 90)]
public function onOrderPlaced(OrderPlaced $event): void { /* runs first */ }

#[Listener(priority: 10)]
public function onOrderPlacedLast(OrderPlaced $event): void { /* runs last */ }

See Stopping propagation below: once one listener can prevent later ones from running at all, which listener runs first is a real behavioral difference. Two listeners sharing a priority are ordered alphabetically by class name, then by method name, so the result never depends on filesystem/scan order. Restrict the discovery scan for a large application with LISTENER_DISCOVERY_PATHS — see CLI.

Stopping propagation

An event implementing PSR-14’s StoppableEventInterface can stop later listeners from running:

use Psr\EventDispatcher\StoppableEventInterface;

final class OrderPlaced implements StoppableEventInterface
{
    private bool $stopped = false;

    public function stop(): void
    {
        $this->stopped = true;
    }

    public function isPropagationStopped(): bool
    {
        return $this->stopped;
    }
}

EventDispatcher checks isPropagationStopped() after every listener and stops calling further ones the moment it returns true.

Deferring a listener to a queue

A listener implementing Kinetis\Events\ShouldQueue is invoked through Kinetis\Events\ListenerInvokerInterface instead of being called directly:

use Kinetis\Events\Listener;
use Kinetis\Events\ShouldQueue;

final readonly class SendOrderConfirmation implements ShouldQueue
{
    public function __construct(private Mailer $mailer) {}

    #[Listener]
    public function onOrderPlaced(OrderPlaced $event): void
    {
        $this->mailer->send($event->customerEmail, 'Order confirmed');
    }
}

By default, ListenerInvokerInterface resolves to SynchronousListenerInvoker, which just calls the listener inline — a ShouldQueue listener works identically to any other one until something else is registered. Queue’s QueuedListenerInvoker pushes it onto a real queue instead:

use Kinetis\Events\ListenerInvokerInterface;
use Kinetis\Queue\QueuedListenerInvoker;

$app->instance(ListenerInvokerInterface::class, new QueuedListenerInvoker($queue));

See also

  • QueueShouldQueue/QueuedListenerInvoker, for running a listener on a worker instead of inline.

  • ContainerRequestScope, and why EventDispatcher is constructor-injected as a concrete class rather than resolved through the PSR-14 interface.

  • Routing & Validation — attribute-driven request binding, following the same “no config files, just attributes on your own methods” style.

  • CLI — restricting namespace-based discovery for a large application, the mechanism LISTENER_DISCOVERY_PATHS follows.

  • Caching & AOT Compilation — how discovered listeners are stored in the production cache.