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:
-
Laravel string events — for each registered Event hook name,
Event::listen($hookName, …)forwards todispatchEvent($hookName, ...$payload). -
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::forgetthe 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.') / codeplugin_validator_exception - Bool
falsedeny:'Validator hook denied the operation.'/ codeplugin_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. Addwhere()constraints only. Never callorWhere()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.
Related contracts
- Plugin Hook Registry
- Plugin Validator Registry (rental transition validators via
$registrar->opportunityValidator(), separate fromHookType::Validator)