Problem

Plugin A produces something interesting (a new order, a published post, a cache that just rebuilt). Plugin B wants to react (send an email, invalidate a downstream cache, notify a webhook). You do not want A to know about B; they should communicate through the same kind of event surface Scriptor's core uses.

Recipe

PSR-14 is what the container already speaks. Your event is a plain class in your plugin's namespace; the dispatcher routes listeners by the class name they subscribed to. Producers and consumers never reference each other directly.

Define the event class:

namespace Acme\Orders\Events;

final class OrderPlaced
{
    public function __construct(
        public readonly int $orderId,
        public readonly string $customerEmail,
        public readonly int $totalCents,
    ) {}
}

Plain readonly properties for the data, no marker interface needed. PSR-14 listeners match by class instance, not by an explicit "event type" string.

In the producing plugin, resolve the dispatcher and dispatch:

namespace Acme\Orders;

use Acme\Orders\Events\OrderPlaced;
use League\Container\Container;
use Psr\EventDispatcher\EventDispatcherInterface;

final class OrderService
{
    public function __construct(private readonly Container $container) {}

    public function place(int $orderId, string $email, int $cents): void
    {
        // ... persist the order ...

        $this->container
            ->get(EventDispatcherInterface::class)
            ->dispatch(new OrderPlaced($orderId, $email, $cents));
    }
}

In the consuming plugin (a separate composer package, or just a separate plugin in the same site), subscribe through the same PluginContext surface listeners for built-in events use:

namespace Acme\OrderNotifier;

use Acme\Orders\Events\OrderPlaced;
use Scriptor\Boot\Plugin\Plugin as ScriptorPlugin;
use Scriptor\Boot\Plugin\PluginContext;

final class Plugin implements ScriptorPlugin
{
    public function register(PluginContext $context): void
    {
        $context->subscribe(OrderPlaced::class, [$this, 'onPlaced']);
    }

    public function version(): string { return '0.1.0'; }

    public function onPlaced(OrderPlaced $event): void
    {
        mail(
            $event->customerEmail,
            'Order #' . $event->orderId,
            'Thanks for your order. Total: ' . ($event->totalCents / 100),
        );
    }
}

Three pieces worth flagging:

  • Event classes belong to the producing plugin's package. A consumer that subscribes to OrderPlaced::class must be able to use it; either the producer ships the event class in its Composer package (the typical shape) or both plugins depend on a third "events" package that owns the class definition. The consumer must list the producer (or the shared package) in its own composer.json require.
  • Listeners run in registration order. PluginContext subscriptions are dispatched in the order plugins booted (alphabetical by Composer name). If listener ordering matters (one listener mutates the event before another reads it), document the contract on the event class.
  • There is no broadcast guarantee. PSR-14 is fire-and-forget: dispatch returns once every synchronous listener has run, but nothing guarantees a listener was registered. For delivery-critical work (a payment that must always notify fraud detection) keep the call direct; the event is for decoupling, not for replacing required wiring.

Variants

Mutable slot for cooperative events

Scriptor's built-in PageResolving and RouteNotFound events use a mutable ?Page $resolution slot so multiple listeners can cooperate. The same pattern works for plugin-defined events:

final class CartTotalCalculating
{
    public int $totalCents = 0;

    public function __construct(
        public readonly array $lineItems,
    ) {}
}

A discount-plugin listener subtracts from totalCents; a shipping-plugin listener adds to it; the producer reads the final value after dispatch. This is the "let the bus tell me the answer" pattern; the PageResolving event is the reference.

Listener that prevents propagation

If you need a listener to stop subsequent listeners from seeing the event (an admin override that should claim authority), make the event implement Psr\EventDispatcher\StoppableEventInterface:

use Psr\EventDispatcher\StoppableEventInterface;

final class OrderPlaced implements StoppableEventInterface
{
    private bool $stopped = false;

    public function stopPropagation(): void { $this->stopped = true; }
    public function isPropagationStopped(): bool { return $this->stopped; }

    // ... data fields as before
}

This is uncommon; most plugin events run all listeners. Reach for it only when you have a reason to.

See also