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::classmust be able touseit; 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 owncomposer.jsonrequire. - 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
PluginContext:subscribe()is the registration entry point both built-in and custom events flow throughPageResolvingandRouteNotFound: Scriptor's built-in cooperative events, the reference shape for plugins that want a mutable slot on their own events- Replace 404 with a fallback handler: worked example of the cooperation convention against Scriptor's own event
- Concept: Frontend events: the four-event narrative the dispatcher contract this recipe reuses is built around
- PSR-14 specification upstream