SIGNALS Documentation
API Reference

Plugin hooks

HookType Event/Filter/Validator/Decorator, PluginContext first-arg contract, priorities, AuditableEvent bridge, failure isolation, and Xero pusher examples.

Hook types

App\Sdk\Hooks\HookType:

Case Value Dispatcher method Behaviour
Event event dispatchEvent Listen only; return ignored
Filter filter applyFilters Value-transforming chain; failed step keeps prior value
Validator validator runValidators Returns HookValidatorVerdict list; throws → isolated denial
Decorator decorator applyDecorators Object return replaces payload

Register from register():

public function hook(string $name, HookType $type, callable $handler, int $priority = 50): self

Manifest gate: hook name must appear in signals.yaml hooks[]. For HookType::Event and HookType::Filter, the name must also exist in EventRegistry (or you register it first via $registrar->event() / webhookEvent()). Undeclared or unknown names throw PluginRegistrarException.

PluginContext first argument

PluginHookDispatcher::invoke injects App\Sdk\PluginContext as the first argument on every handler invocation, then the hook payload:

Hook type Handler signature
Event function (PluginContext $context, mixed ...$payload)
Filter function (PluginContext $context, mixed $value, mixed ...$contextArgs)
Validator function (PluginContext $context, mixed ...$args)
Decorator function (PluginContext $context, object $payload, mixed ...$contextArgs)

PluginContext is final readonly with:

public function __construct(
    public string $package,
    public PluginManifest $manifest,
) {}

public function read(string $entity, int $id): array
public function update(string $entity, int $id, array $attributes): bool
public function setting(string $key, mixed $default = null): mixed
public function http(): PluginHttpClient
public function storage(): PluginStorage
public function recent(array $entry): void
public function notify(string $key, User|iterable $users, array $payload): void
public function emit(string $name, array $payload = []): void

These delegate to Signals::* with the package pre-bound — do not hand-thread $package through closures.

Priorities

Default priority is 50. PluginHookRegistry::for($hookName, $type) returns matching registrations sorted ascending (lower runs first). Equal priorities retain relative order after usort.

Declare optional priority on each manifest hook entry; pass the same value to $registrar->hook(..., $priority).

EventRegistry name requirement

Event and Filter hooks are catalogue-backed. Register custom names before wiring handlers:

$registrar->event('acme.example.happened', ['plugin' => true]);
$registrar->hook('acme.example.happened', HookType::Event, $handler, 50);

Core names such as account.updated and invoice.issued are already seeded in config/events.php. Details: Plugin events and Event Registry.

AuditableEvent bridge + re-entry guard

PluginServiceProvider bridges core activity into plugin Event hooks two ways:

  1. Laravel string events — for each registered Event hook name, Event::listen($hookName, …) forwards to dispatchEvent($hookName, ...$payload).

  2. AuditableEvent — when Event handlers exist for $event->action:

dispatchEvent($action, $model, $oldValues, $newValues)

Re-entry guard: if $event->metadata contains key 'plugin', the bridge skips dispatch. Plugin data-facade writes set metadata: ['plugin' => $package], so a plugin update does not re-fire the same Event hooks recursively.

Failure isolation + auto-disable

PluginHookDispatcher catches Throwable per handler: log + skip; core continues.

Config key Env Default
plugins.hook_failure_threshold PLUGINS_HOOK_FAILURE_THRESHOLD 10
plugins.hook_timeout_seconds PLUGINS_HOOK_TIMEOUT_SECONDS 5
  • Cache key: plugin-hooks:failures:{package}
  • Success → Cache::forget the counter
  • Consecutive failures ≥ threshold → PluginLifecycleManager::disable() when status is Enabled
  • Slow handlers above timeout log a warning; they are not interrupted

Validator denial messages used by the dispatcher:

  • Exception path: exception message (fallback 'Validator hook failed.') / code plugin_validator_exception
  • Bool false deny: 'Validator hook denied the operation.' / code plugin_validator_denied

Core call-sites map HookValidatorVerdict::$code into the 422 errors.code bag (same convention as dispatch shortage / repair stock guards) so clients can tell deny from throw without parsing message text.

Bridge status (shipped vs deferred)

Shipped: Event hooks via string events + AuditableEvent. Validator call-site: assignment.creating in CreateResourceAssignment (plugins veto via HookType::Validator before persist). Filter call-site: assignment.query on resource-assignment list reads (ResourceAssignmentController::index + Resource Timeline query) via SchedulingHookBridge::applyQuery().

Scheduling hook catalogue (P8A-13′ — mandatory):

Spec hook Type Core call-site Webhook / audit twin
assignment.creating Validator CreateResourceAssignment (pre-persist) —
assignment.created Event SchedulingHookBridge::created() after persist resource_assignment.created
assignment.status_changed Event confirm / start / complete / cancel transitions resource_assignment.{confirmed,started,completed,cancelled}
conflict.detected Event RecordsAssignmentConflicts / batch conflict emit scheduling_conflict.detected
demand.unmet Event cancel path when shortfall remains resource_assignment.demand_unmet
assignment.query Filter API index + timeline base query —

Spec names are the plugin contract. Webhook subscribers keep the resource_assignment.* / scheduling_conflict.* prefixes. Event hooks are dispatched explicitly (not via the AuditableEvent action-name bridge) so payloads can include structured old_status / new_status and shortfall arrays.

Filter hooks must only narrow the query. A handler on assignment.query (or any future Filter call-site) receives the live Eloquent builder after authorisation and warehouse scoping have been applied. Add where() constraints only. Never call orWhere() at the top level, remove existing constraints, or otherwise widen the result set — nothing enforces this mechanically today, and a widening filter can leak rows the caller is not authorised to see. A handler that returns anything other than a builder is ignored and the unfiltered (but still fully scoped) query is used.

Deferred (see Coming soon): Decorator core call-sites. You can register that type and call PluginHookDispatcher directly in tests.

Worked example — Xero contact pusher

From plugins/signals/xero-sync/src/XeroContactPusher.php:

public function __invoke(PluginContext $context, mixed ...$payload): void
{
    $accountId = $this->resolveEntityId($payload);

    if ($accountId === null) {
        return;
    }

    $direction = (string) $context->setting('sync_direction', 'push');

    if (! in_array($direction, ['push', 'both'], true)) {
        return;
    }

    $account = $context->read('accounts', $accountId);
    $contact = $client->upsertContact($account);

    $context->update('accounts', $accountId, [
        'custom_fields' => [
            'xero_contact_id' => $contact['ContactID'],
        ],
    ]);
}

Wired in XeroSyncPlugin::register():

$registrar->hook('account.updated', HookType::Event, $contactPusher, 50);
$registrar->hook('invoice.issued', HookType::Event, $invoicePusher, 50);

Entity id resolution accepts the first payload argument as an array with id, a model with id, or a positive int — matching the AuditableEvent $model argument.