windwalker/event
Lightweight event dispatching for PHP and the Windwalker framework: register listeners, dispatch events, and decouple components with a simple, PSR-14-friendly API.
The package is a small, framework-agnostic event dispatcher — the PSR-14 kind, not Laravel's batteries-included one. The repository linked in the package metadata (ventoviro/windwalker-event) is a read-only split; the canonical source and documentation live in the Windwalker monorepo (windwalker-io/windwalker, docs under windwalker.readthedocs.io, Event chapter). Star count and dependent count are effectively zero, so treat it as "read the source, it's ~15 small files" rather than "search for tutorials."
Minimal wiring:
composer require windwalker/event
composer show windwalker/event # confirm major version before copying snippets
use Windwalker\Event\Dispatcher;
use Windwalker\Event\Event;
$dispatcher = new Dispatcher();
$dispatcher->addListener('order.paid', function (Event $event) {
// $event['order'] via ArrayAccess, or $event->getArgument('order')
}, priority: 10);
$dispatcher->dispatch(new Event(['order' => $order]), 'order.paid');
Look at three files first, in this order: Dispatcher (the only class you normally touch), Event (mutable payload + stop()/isStopped()), and ListenerPriorityQueue (how ordering is resolved). If those three make sense, you have read the whole package.
Best first use case as a Laravel dev: you are extracting a domain service into a package that must be usable by a non-Laravel consumer, and you need side effects (audit log, mail, cache bust) decoupled from the core flow without dragging illuminate/support, the container, or the queue into a library. Type-hint Psr\EventDispatcher\EventDispatcherInterface in your package, and let the host app supply either Windwalker\Event\Dispatcher or Symfony's.
Bind it once, inject the interface. In a Laravel service provider:
$this->app->singleton(\Psr\EventDispatcher\EventDispatcherInterface::class, fn () => new Dispatcher());
Then constructor-inject the PSR interface everywhere. Do not inject the concrete Dispatcher — you will lose the ability to swap implementations later, and you gain nothing.
Prefer named events over event-class-as-name. dispatch($event, 'order.paid') lets you subscribe by a string contract while keeping a typed event object. Listeners can then instanceof check when needed, but usually the event name is enough.
Use priorities deliberately, not decoratively. Higher priority runs first. Useful for: an "enrich the event payload" listener at priority 100, the actual side-effect listener at 0, and a telemetry listener at -100 that must observe the final state.
Stopping propagation. The Event object supports stop()/isStopped(). For immutable events, implement PSR-14's StoppableEventInterface on your own event class instead — the dispatcher honours it.
Subscribers for clustered listeners. Rather than a dozen addListener calls in a provider, group them behind a subscriber object that returns ['event.name' => ['method', priority]] and register it in one line. This is the pattern that scales past ~5 listeners.
Test with a spy, not a mock. Register a listener that appends to an array, dispatch, assert the array. It is faster to write and less brittle than mocking the dispatcher:
$seen = [];
$dispatcher->addListener('order.paid', function (Event $e) use (&$seen) { $seen[] = $e['order']; });
Decorator for observability. Wrap the dispatcher in a class implementing the same interface that logs event name, listener count, and elapsed time, then delegate. Because the interface is PSR-14, this is a five-line class and works anywhere.
dispatch() returns the event, not listener results. Laravel's dispatcher returns an array of responses. Here you get the event back so you can chain, but you cannot read return values. If you need a listener to communicate a result, mutate the event payload — that is the intended channel.
No wildcards, no discovery, no ShouldQueue, no model events. There is no 'order.*' matching, no attribute-based auto-registration by default, no queue integration, no broadcasting, no Eloquent hooks. If you are porting a listener that expects those, you are writing them yourself.
Mutable event payloads bite on re-dispatch. Event implements ArrayAccess and Countable; a listener can silently overwrite $event['order'] and downstream listeners see the mutation. This is a feature, but it means event objects are not safe to cache or reuse. Create a fresh one per dispatch.
Removing a closure requires keeping a reference. removeListener() needs the identical callable. Store closures in properties or use named invokable classes if you ever need to unsubscribe — a common need in long-running workers where the dispatcher is a singleton and listeners accumulate across jobs.
Priority ties are unspecified order. Insertion order usually holds, but do not build logic that depends on it. Give colliding listeners distinct priorities.
Version churn is real. v2/v3-era API uses trigger()/triggerEvent() with argument arrays; v4 aligns to PSR-14 dispatch()/addListener(). composer show before you copy anything from a blog post, and pin the major in composer.json. Namespace is Windwalker\Event\* — no collision with Illuminate, but autocomplete will happily suggest both Event classes in a Laravel app, so import explicitly.
Extension points are the ListenerProviderInterface and the priority queue. If ordering semantics or listener storage do not fit (e.g. you want listeners resolved lazily from the container), implement your own provider and hand it to the dispatcher — that is the sanctioned seam, not subclassing Dispatcher.
Vendor-risk mitigation. Zero stars and zero dependents means no community CVE review and no guarantee of maintenance. It is MIT and tiny, so the pragmatic posture is: pin the version, keep all use Windwalker\Event\... statements behind a thin internal adapter (or just type-hint PSR-14 in your own code), and you can swap to Symfony's dispatcher in an afternoon if the package goes quiet.
How can I help you explore Laravel packages today?