Standalone WordPress MU-plugin for Symfony-style class-based events, listeners, and subscribers on top of native WordPress hooks and filters.
- WordPress 6.9
- PHP 8.5
- 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
- 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';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 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.
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 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,
);
},
);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);
},
);
},
);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());
},
);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'));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());
},
);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.
composer test
composer cs:analyze
composer csThis package is licensed under GPL-2.0-or-later.