Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/sympress/event-dispatcher/archive/refs/heads/main.zipStandalone 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. Repeated actions/filters report missed invocations through _doing_it_wrong and observe future invocations only. Register bootstrap listeners before the relevant WordPress lifecycle boundary.
Releases
6 releases.
These releases are tags only. The author does not attach a packaged zip, so there are no download counts to report.