Search by

sympress / event-dispatcher

brianvarskonst

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

Statistics

Installs: 1 178

Dependents: 2

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.5 2026-10-03 15:18 UTC

README

Checks Release PHP Downloads License: GPL-2.0-or-later

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

  1. Place the package in wp-content/mu-plugins/event-dispatcher/.
  2. Ensure Composer autoloading is available.
  3. 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

  • EventSystem owns the singleton bootstrap lifecycle.
  • HookEventDispatcher implements PSR-14 dispatching and listener resolution plus the WordPress hook bridge.
  • HookEventDispatcher::register() resolves AsEventListener and AsEventSubscriber on classes and methods.
  • AbstractActionEvent maps native actions to typed event objects.
  • AbstractFilterEvent maps 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.