feat(core/events): add WebhookRegistering lifecycle event (HIGH)

WebhookRegistering event exposes:
- register(string $type, array $spec): add a webhook type to the
  registry
- types(): array — queryable post-dispatch registry

CoreServiceProvider dispatches the event at app boot and exposes the
collected registry via webhookTypes() — matches the existing
ApiRoutesRegistering / ConsoleBooting / ClientRoutesRegistering
event-driven module pattern.

Pairs with #1034 ofm.bot WebhookRegistrar (just landed) — that
service can now also be wired through this event, allowing OTHER
modules and external apps using Core to register webhook types via
the standard Core lifecycle.

Note: real Core lifecycle dispatcher lives in a sibling read-only
framework checkout. CoreServiceProvider here is a local shim that
mirrors the dispatch behaviour. Upstream patch needed when that
sibling lands.

Pest covers: instantiation + register, boot-time dispatch, post-boot
registry lookup.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=1013
This commit is contained in:
Snider 2026-04-25 19:37:23 +01:00
parent b7bc526d50
commit b0118ef8ef
3 changed files with 209 additions and 0 deletions

View file

@ -0,0 +1,58 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core;
use Core\Events\WebhookRegistering;
use Illuminate\Support\ServiceProvider;
/**
* Core service provider shim for boot-time webhook type registration.
*
* Stores the collected webhook type registry in the container after the
* application has fully booted so downstream services can resolve it.
*/
class CoreServiceProvider extends ServiceProvider
{
public const WEBHOOK_TYPES = 'core.webhook.types';
public function register(): void
{
$this->app->singleton(self::WEBHOOK_TYPES, static fn (): array => []);
}
public function boot(): void
{
$this->app->booted(function (): void {
$this->app->instance(self::WEBHOOK_TYPES, static::fireWebhookRegistering());
});
}
/**
* Fire WebhookRegistering and return the collected type registry.
*
* @return array<string, array<string, mixed>>
*/
public static function fireWebhookRegistering(): array
{
$event = new WebhookRegistering;
event($event);
return $event->types();
}
/**
* Get the webhook type registry captured during boot.
*
* @return array<string, array<string, mixed>>
*/
public static function webhookTypes(): array
{
return app()->bound(self::WEBHOOK_TYPES)
? app(self::WEBHOOK_TYPES)
: [];
}
}

View file

@ -0,0 +1,67 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Events;
/**
* Fired when webhook ingress types are being registered.
*
* Modules and external applications listen to this event to publish the
* webhook types they want Core to expose during application boot.
*
* ## When This Event Fires
*
* Fired once the application has booted and webhook ingress definitions are
* being collected for the active runtime.
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* WebhookRegistering::class => 'onWebhookRegistering',
* ];
*
* public function onWebhookRegistering(WebhookRegistering $event): void
* {
* $event->register('myapp.event.x', [
* 'payload' => [
* 'id' => 'string',
* ],
* ]);
* }
* ```
*/
class WebhookRegistering extends LifecycleEvent
{
/** @var array<string, array<string, mixed>> Collected webhook type definitions keyed by type */
protected array $types = [];
/**
* Register a webhook type definition.
*
* Later registrations with the same type replace earlier definitions so an
* application can override defaults during boot.
*
* @param string $type Fully qualified webhook type name
* @param array<string, mixed> $definition Payload shape or metadata for the type
*/
public function register(string $type, array $definition = []): void
{
$this->types[$type] = $definition;
}
/**
* Get all registered webhook type definitions.
*
* @return array<string, array<string, mixed>>
*
* @internal Used by the webhook ingress bootstrap
*/
public function types(): array
{
return $this->types;
}
}

View file

@ -0,0 +1,84 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\CoreServiceProvider;
use Core\Events\WebhookRegistering;
use Illuminate\Support\Facades\Event;
if (! class_exists(WebhookRegistering::class)) {
require_once dirname(__DIR__, 3).'/Events/WebhookRegistering.php';
}
if (! class_exists(CoreServiceProvider::class)) {
require_once dirname(__DIR__, 3).'/CoreServiceProvider.php';
}
test('WebhookRegistering_register_Good_collects_webhook_type_definitions', function (): void {
$event = new WebhookRegistering;
$event->register('myapp.event.x', [
'payload' => [
'id' => 'string',
],
]);
expect($event->types())->toBe([
'myapp.event.x' => [
'payload' => [
'id' => 'string',
],
],
]);
});
test('CoreServiceProvider_boot_Good_dispatches_webhook_registering', function (): void {
$received = false;
Event::listen(WebhookRegistering::class, function (WebhookRegistering $event) use (&$received): void {
$received = true;
$event->register('ofm.bot.message.received', [
'payload' => [
'message_id' => 'string',
],
]);
});
$this->app->register(CoreServiceProvider::class);
expect($received)->toBeTrue();
});
test('CoreServiceProvider_webhookTypes_Good_returns_registered_types_after_boot', function (): void {
Event::listen(WebhookRegistering::class, function (WebhookRegistering $event): void {
$event->register('ofm.bot.message.received', [
'payload' => [
'message_id' => 'string',
],
]);
$event->register('ofm.bot.message.deleted', [
'payload' => [
'message_id' => 'string',
],
]);
});
$this->app->register(CoreServiceProvider::class);
expect(CoreServiceProvider::webhookTypes())->toBe([
'ofm.bot.message.received' => [
'payload' => [
'message_id' => 'string',
],
],
'ofm.bot.message.deleted' => [
'payload' => [
'message_id' => 'string',
],
],
]);
});