sympress / event-dispatcher
MU plugin for class-based WordPress events, listeners, and subscribers.
Package info
github.com/SymPress/event-dispatcher
Type:wordpress-muplugin
pkg:composer/sympress/event-dispatcher
Requires
- php: ^8.5
- psr/event-dispatcher: ^1.0
- sympress/kernel: ^1.1.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.88
- symfony/var-dumper: ^8.1
- sympress/qa: ^0.1.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-06 18:20:36 UTC
README
Standalone WordPress MU-plugin for Symfony-style class-based events, listeners, and subscribers on top of native WordPress hooks and filters.
Requirements
- WordPress 6.9
- PHP 8.5
Design Goals
- Symfony-like listener and subscriber API
- PSR-14 compliant dispatcher and listener provider contracts
- Class-based WordPress action and filter events
- Immutable event value objects
- Native WordPress hook bridge with one callback registration per event class
- Container-friendly bootstrap for
SymPress\Kernel\App - Enterprise-focused QA with PHPUnit, PHPStan, PHPCS, and PHP CS Fixer
Installation
- Place the package in
wp-content/mu-plugins/event-dispatcher/. - Ensure Composer autoloading is available.
- Add a root MU loader file because WordPress does not autoload subdirectories.
<?php declare(strict_types=1); require WPMU_PLUGIN_DIR . '/event-dispatcher/event-dispatcher.php';
Registering Subscribers
use SymPress\EventDispatcher\Application\EventSystem; use SymPress\EventDispatcher\Contract\EventSubscriberInterface; use SymPress\EventDispatcher\Event\AbstractFilterEvent; final readonly class UploadMimesEvent extends AbstractFilterEvent { public function __construct( array $mimes, public int $userId, ) { parent::__construct($mimes, [$mimes, $userId]); } public static function hookName(): string { return 'upload_mimes'; } public static function acceptedArgs(): int { return 2; } public static function fromHookArguments(array $arguments): static { return new self( (array) ($arguments[0] ?? []), (int) ($arguments[1] ?? 0), ); } /** * @return array<string, string> */ public function mimes(): array { /** @var array<string, string> $mimes */ $mimes = $this->value(); return $mimes; } public function withAllowed(string $extension, string $mimeType): self { $mimes = $this->mimes(); $mimes[$extension] = $mimeType; return new self($mimes, $this->userId); } } final class UploadSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents(): array { return [ UploadMimesEvent::class => ['allowSvg', 100], ]; } public function allowSvg(UploadMimesEvent $event): UploadMimesEvent { return $event->withAllowed('svg', 'image/svg+xml'); } } add_action( EventSystem::REGISTER_HOOK, static function ($dispatcher): void { $dispatcher->addSubscriber(new UploadSubscriber()); }, );
Filter Strategy
Filter sind bereits implementiert.
- Das erste native Filter-Argument ist der Payload des Events.
- Filter-Events sind immutable Value Objects.
- Listener mutieren nie in-place, sondern geben bei Änderungen eine neue Event-Instanz zurück.
- Der letzte Event-Stand wird über
toHookResult()zurück in den nativen WordPress-Filter geschrieben.
Kurz gesagt: apply_filters() bleibt nach außen WordPress-kompatibel, intern arbeitest du aber mit sauberen immutable Events.
Registering Attribute-Based Listeners
use SymPress\EventDispatcher\Application\EventSystem; use SymPress\EventDispatcher\Attribute\AsEventListener; use SymPress\EventDispatcher\Attribute\AsEventSubscriber; #[AsEventSubscriber(event: UploadMimesEvent::class, method: 'allowSvg', priority: 100)] final class UploadListener { public function allowSvg(UploadMimesEvent $event): UploadMimesEvent { return $event->withAllowed('svg', 'image/svg+xml'); } #[AsEventListener(priority: 0)] public function allowWebp(UploadMimesEvent $event): UploadMimesEvent { return $event->withAllowed('webp', 'image/webp'); } } add_action( EventSystem::REGISTER_HOOK, static function ($dispatcher): void { $dispatcher->register(new UploadListener()); }, );
Use Cases
1. WordPress Filter: MIME Types erweitern
use SymPress\EventDispatcher\Application\EventSystem; use SymPress\EventDispatcher\Event\AbstractFilterEvent; final readonly class UploadMimesEvent extends AbstractFilterEvent { /** * @param array<string, string> $mimes */ public function __construct( array $mimes, public int $userId, ) { parent::__construct($mimes, [$mimes, $userId]); } public static function hookName(): string { return 'upload_mimes'; } public static function acceptedArgs(): int { return 2; } public static function fromHookArguments(array $arguments): static { return new self( (array) ($arguments[0] ?? []), (int) ($arguments[1] ?? 0), ); } /** * @return array<string, string> */ public function mimes(): array { /** @var array<string, string> $mimes */ $mimes = $this->value(); return $mimes; } public function withAllowed(string $extension, string $mimeType): self { $mimes = $this->mimes(); $mimes[$extension] = $mimeType; return new self($mimes, $this->userId); } } add_action( EventSystem::REGISTER_HOOK, static function ($dispatcher): void { $dispatcher->addListener( UploadMimesEvent::class, static fn (UploadMimesEvent $event): UploadMimesEvent => $event->withAllowed( 'svg', 'image/svg+xml', ), 100, ); }, );
2. WordPress Action: Typed save_post
use SymPress\EventDispatcher\Application\EventSystem; use SymPress\EventDispatcher\Event\AbstractActionEvent; final readonly class SavePostEvent extends AbstractActionEvent { public function __construct( public int $postId, public bool $update, ) { parent::__construct([$postId, $update]); } public static function hookName(): string { return 'save_post'; } public static function acceptedArgs(): int { return 2; } public static function fromHookArguments(array $arguments): static { return new self( (int) ($arguments[0] ?? 0), (bool) ($arguments[1] ?? false), ); } } add_action( EventSystem::REGISTER_HOOK, static function ($dispatcher): void { $dispatcher->addListener( SavePostEvent::class, static function (SavePostEvent $event): void { if (!$event->update) { return; } error_log('Updated post: ' . $event->postId); }, ); }, );
3. Attribute-Based Hook Service
use SymPress\EventDispatcher\Application\EventSystem; use SymPress\EventDispatcher\Attribute\AsEventListener; use SymPress\EventDispatcher\Attribute\AsEventSubscriber; #[AsEventSubscriber(event: SavePostEvent::class, method: 'onSavePost')] final class EditorialWorkflowListener { public function onSavePost(SavePostEvent $event): void { if (!$event->update) { return; } error_log('Editorial workflow for post ' . $event->postId); } #[AsEventListener(priority: 100)] public function allowSvg(UploadMimesEvent $event): UploadMimesEvent { return $event->withAllowed('svg', 'image/svg+xml'); } } add_action( EventSystem::REGISTER_HOOK, static function ($dispatcher): void { $dispatcher->register(new EditorialWorkflowListener()); }, );
4. Pure Domain Event Without WordPress Hook
use SymPress\EventDispatcher\Application\EventSystem; use SymPress\EventDispatcher\Event\AbstractEvent; final readonly class OrderPaidEvent extends AbstractEvent { public function __construct( public int $orderId, public string $paymentReference, ) { } } $dispatcher = EventSystem::getInstance()->getDispatcher(); $dispatcher->addListener( OrderPaidEvent::class, static function (OrderPaidEvent $event): void { error_log( sprintf( 'Order %d paid with reference %s', $event->orderId, $event->paymentReference, ), ); }, ); $dispatcher->dispatch(new OrderPaidEvent(42, 'PAY-123'));
5. Invokable Attribute Listener for Domain Events
use SymPress\EventDispatcher\Attribute\AsEventListener; #[AsEventListener(event: OrderPaidEvent::class, priority: 50)] final class SendInvoiceListener { public function __invoke(OrderPaidEvent $event): void { error_log('Send invoice for order ' . $event->orderId); } } add_action( EventSystem::READY_HOOK, static function ($dispatcher): void { $dispatcher->register(new SendInvoiceListener()); }, );
Runtime Model
EventSystemowns the singleton bootstrap lifecycle.HookEventDispatcherimplements PSR-14 dispatching and listener resolution plus the WordPress hook bridge.HookEventDispatcher::register()resolvesAsEventListenerandAsEventSubscriberon classes and methods.AbstractActionEventmaps native actions to typed event objects.AbstractFilterEventmaps native filters to immutable event value objects; listeners return a new event instance when they change data.- Filter listeners should return the same event unchanged or a new event instance of the same class.
- Subscribers follow the Symfony event subscriber format.
Development
composer qa
License
This package is licensed under GPL-2.0-or-later.
Compiled listener lifecycle
Autoconfigured AsEventListener/AsEventSubscriber services are resolved once during container compilation and registered through a lazy service locator before the EventSystem ready hook. Keep listeners private; their constructor runs on first matching dispatch. The manual register API remains supported and caches class attribute definitions rather than reflecting on every dispatch.
Removing the last effective listener removes exactly the dispatcher's native WP callback and priority, preserving unrelated callbacks and inherited/interface listeners. Re-registration installs one callback. Already-started/completed bootstrap hooks (muplugins_loaded, plugins_loaded, setup_theme, after_setup_theme, init, wp_loaded) reject late listeners in debug mode and skip them in production. Repeated actions/filters report missed invocations through _doing_it_wrong and observe future invocations only. Register bootstrap listeners before the relevant WordPress lifecycle boundary.
Upgrade note
Invalid hook definitions and already-started bootstrap hook registrations throw
InvalidHookEvent only in debug mode. Production skips the invalid definition,
reports _doing_it_wrong and continues valid definitions/services. Container
bootstrap applies kernel.debug to the singleton before compiled registrations
and ready hooks, even when the public dispatcher service is never fetched.
Standalone construction defaults to production behavior; call configureDebug(true)
explicitly when debug exceptions are wanted. WP_DEBUG alone does not enable them.
The dispatcher constructor and public service aliases remain unchanged.
Compiled entries are deduplicated by canonical service ID, event and method. Registering the same container service manually (including through an alias) keeps each matching manual event/method authoritative; its matching lazy compiled entry will not invoke it again. Distinct explicitly tagged methods/events on that service remain active. Repeated automatic tags/initialization also dispatch once. Private automatic services stay lazy until a matching event is dispatched.