Plugin events
EventRegistry for plugins — visibility flags, registrar event()/webhookEvent(), finding hookable names, and core event families.
What it is
App\Services\Events\EventRegistry is the canonical domain-event catalogue. Plugin Event and Filter hooks must use names that exist in this registry (enforced when $registrar->hook() runs for those types).
Contract page: Event Registry.
Seed source: config/events.php (hundreds of named events). Enumerate at runtime:
app(\App\Services\Events\EventRegistry::class)->names();
// or
array_keys(config('events.events'));
Visibility flags
Each EventDefinition carries consumer flags:
| Flag | Meaning |
|---|---|
api |
Visible to API / activity consumers that opt into API visibility |
webhook |
Eligible for outbound webhook subscriptions |
workflow |
Eligible for workflow triggers |
plugin |
Eligible for plugin discovery / plugin consumers |
system_protected |
Excluded from names() / describe() listings; still present for has() / get() |
Consumers query via visibleTo('api'|'webhook'|'workflow'|'plugin').
Declaring plugin-owned events (events[])
Plugin-provided catalogue names must appear in signals.yaml events[] (wiring ⊆ declaration). Names are vendor-prefixed like permissions (e.g. package acme/example → acme.sync.completed). Rules: events.malformed, events.namespace.
# signals.yaml
events:
- name: acme.sync.completed
label: Sync Completed
webhook: true # required for webhookEvent() / event(..., webhook: true)
hooks:
# Other plugins (or this one) that want to *handle* the event declare it here.
- name: acme.sync.completed
type: event
priority: 50
Registering plugin-owned events
From PluginRegistrar (signatures copied from source):
public function webhookEvent(string $name): self
public function event(string $name, array $visibility = []): self
// accepted visibility keys: api?, webhook?, workflow?, plugin?, payload_schema?
webhookEvent($name)registers withEventSource::Plugin,webhook: true, and adds the name toWebhookEventRegistryso it is subscribable in the webhooks UI/API.event($name, $visibility)registers a catalogue entry with the supplied flags;webhook: truealso registers intoWebhookEventRegistry.
Outbound delivery is automatic. A declared event with webhook: true is delivered to subscribers by either dispatch route:
Signals::emit($package, $name, $payload)— the SDK path, which also runs plugin Event hooks.Event::dispatch($name, [$payload])— a plain Laravel event, bridged byApp\Listeners\DispatchPluginWebhookEvent, which subscribes to every plugin-source catalogue entry with webhook visibility at boot.
Neither route needs plugin-specific wiring, and neither re-fires while the Verbs event store is replaying. Pass a single associative array as the payload; other shapes are wrapped under a payload key.
Manifest gate: both methods require name ∈ events[]. When registering webhook visibility, the manifest entry must also have webhook: true. (hooks[] still gates hook() / container listeners — it is the handler subscription list, not the provided-event list.)
Typical order:
$registrar->event('acme.sync.completed', [
'plugin' => true,
'webhook' => true,
]);
// A different plugin (or the same one) may then:
$registrar->hook('acme.sync.completed', HookType::Event, $handler, 50);
Firing plugin events — Signals::emit()
Signals::emit('acme/example', 'acme.sync.completed', ['job_id' => 42]);
// From a hook handler:
$context->emit('acme.sync.completed', ['job_id' => 42]);
Fire path: PluginHookDispatcher::dispatchEvent($name, $payload) — the same sink the AuditableEvent / Laravel string-event bridges use — then, when the catalogue entry has webhook: true, WebhookService::dispatch($name, $payload).
Guards: plugin enabled; name declared in that package's events[]; name registered in EventRegistry.
Subscribing to CORE events — listen()
$registrar->listen($eventName, $listener, $priority = 50) subscribes to any
catalogued domain event by its published name — never a class path — so
plugins speak the same stable vocabulary webhook subscribers do:
$registrar->listen('rental.created', function (PluginContext $context, PluginEventPayload $event): void {
$context->log('New rental '.$event->get('id').' by '.$event->actorType());
});
// A container-resolved invokable class is also accepted:
$registrar->listen('invoice.issued', AcmeInvoiceListener::class);
The listener receives the usual PluginContext first, then an immutable
App\Sdk\Hooks\PluginEventPayload carrying the published payload plus the
actor_type / ref stamps ($event->event, ->get($key), ->actorType(),
->ref()).
Rails, all enforced by App\Sdk\Hooks\PluginEventDispatcher rather than by
convention:
| Rail | Behaviour |
|---|---|
| Catalogue gate | Only names with plugin: true and NOT system_protected may be subscribed |
| Fail loud | An unknown or withheld name throws PluginRegistrarException at plugin boot, not silently at runtime |
| Replay guard | Listeners never fire while Verbs::isReplaying() — same contract as outbound webhooks |
| Failure isolation | A throwing listener is logged, counted against its package, and auto-disables the plugin at plugins.hook_failure_threshold; it can never break the business action |
Manifest gate: the event name must appear in hooks[] (it is a handler
subscription, not a provided event). Subscriptions live in the shared
PluginHookRegistry under the namespaced hook name event:<name>, so they can
never be fired by — or collide with — a hand-placed hook() of the same name.
Fan-out happens in WebhookService::dispatch(), the one boundary every
catalogue-named emitter already funnels through: plugin and webhook subscribers
see the same event, exactly once per domain action, with no per-event wiring.
Finding hookable names
- Prefer names already in
config/events.phpthat match the domain moment you care about (e.g.account.updated,invoice.issued). - Confirm with
EventRegistry::has($name). - For integrations, rely on the AuditableEvent bridge (see Plugin hooks) — CRM/invoice actions fire
AuditableEventwith$actionequal to the catalogue name. - Register new names only when you own the domain moment and will dispatch them (or expose them for webhooks).
OpenAPI / REST activity is separate from this catalogue; webhook delivery uses Webhook Event Registry.
Core families (from config/events.php)
Prefixes present today (non-exhaustive counts from the seed file):
| Prefix | Approx. count | Examples |
|---|---|---|
rental.* |
67 | rental lifecycle, items, quotes |
account.* |
22 | account.updated, account CRM events |
flightcase.* |
14 | pack / seal / dissolve / dispatch |
shortage.* |
13 | shortage detection / resolution |
stock_check.* |
11 | stocktake lifecycle |
document.* |
9 | document generate / finalise |
plugin.* |
8 | see below |
catalogue_item.* |
7 | catalogue changes |
invoice.* |
4 | includes invoice.issued |
Also present under other prefixes: import/export-related names (when seeded), stock, purchase orders, notifications, etc. Always check the live catalogue rather than hard-coding an incomplete list.
Built-in plugin.* catalogue names
plugin.enabledplugin.disabledplugin.installedplugin.uninstalledplugin.updatedplugin.settings_updatedplugin.data_access_deniedplugin.data_updated
Self-subscription
A plugin may hook its own provided event — declare the name in both events[] (provider side) and hooks[] (consumer side), call event() before the hook() that subscribes to it (Event hook names must already exist in EventRegistry), and never re-emit from the handler (that would loop). signals/slots-demo does exactly this: SlotsDemoListener emits signals.slotsdemo.logged after logging an account update and SlotsDemoEchoListener receives it — see Plugin examples.
Related
- Plugin hooks — handler contracts and bridges
- Registries and resolvers — full registrar surface
- Plugin examples —
slots-demoprovided-event + self-subscription walkthrough