# Guide ## Concepts Latch has two sides: - **Source side** — the app that owns the extension points. It declares what hooks exist, what payload types they expect, and invokes them at the right moment. - **Handler side** — any package that wants to extend the source. It attaches callables to the declared points. Sources must be registered before handlers can attach. Hooking into an undeclared source or point throws immediately — there are no silent no-ops. ## Source Side ### Registering a source A source declares its identity and extension points upfront. Each point has a name, a type, and the class of payload/context it works with. ```php use Latch\HookRegistry; $registry = new HookRegistry(); $cms = $registry->registerSource('cms') ->filter('render-html', RenderPayload::class) ->filter('page-title', TitlePayload::class) ->action('page-published', PageEvent::class) ->action('page-deleted', PageEvent::class) ->collect('sidebar-widgets', SidebarContext::class) ->collect('admin-menu', MenuContext::class); ``` You can optionally pass a class name and capability tags: ```php $registry->registerSource('cms', CmsApplication::class, ['content-management', 'publishing']); ``` ### Invoking filters Filters run a chain of handlers, each receiving the payload and returning a modified version. Use immutable value objects with `with*` methods for clean chaining. ```php class RenderPayload { public function __construct( public readonly string $html, public readonly array $meta = [], ) {} public function withHtml(string $html): self { return new self($html, $this->meta); } public function withMeta(string $key, mixed $value): self { return new self($this->html, [...$this->meta, $key => $value]); } } $payload = new RenderPayload($rawHtml); $payload = $cms->apply('render-html', $payload); echo $payload->html; ``` If no handlers are registered, the payload is returned unchanged. ### Invoking actions Actions are fire-and-forget. The source broadcasts an event; handler return values are ignored. ```php class PageEvent { public function __construct( public readonly Page $page, public readonly User $actor, ) {} } $cms->dispatch('page-published', new PageEvent($page, $currentUser)); ``` ### Invoking collectors Collectors gather contributions from all handlers. Each handler returns an array; results are merged into a single flat list. ```php class SidebarContext { public function __construct( public readonly Page $page, public readonly User $viewer, ) {} } class Widget { public function __construct( public readonly string $title, public readonly string $html, ) {} } $widgets = $cms->collectFromHandlers('sidebar-widgets', new SidebarContext($page, $viewer)); foreach ($widgets as $widget) { echo "

{$widget->title}

{$widget->html}
"; } ``` The context argument is optional — pass `null` or omit it if handlers don't need context: ```php $items = $cms->collectFromHandlers('admin-menu'); ``` If no handlers are registered, an empty array is returned. ## Handler Side ### Registering a handler Every handler must be registered with a name using `registerHandler()`. This creates a `HookHandler` that you use to attach hooks. All hooks are automatically tagged with `handler:{name}`. ```php $seo = $registry->registerHandler('seo'); $seo->hook('cms', 'render-html') ->handle(fn (RenderPayload $p) => $p->withHtml( str_replace('{{year}}', date('Y'), $p->html) )); ``` Each name can only be registered once. Attempting to register the same name again throws `DuplicateHandlerException`. Pass the `HookHandler` instance around via DI or store it as a property to reuse across your package. ### Additional tags You can pass additional tags at registration time: ```php $seo = $registry->registerHandler('seo', ['premium']); // All hooks get both 'handler:seo' and 'premium' ``` Or add more tags later with `globalTags()` - these apply to all hooks created after the call: ```php $seo = $registry->registerHandler('seo'); $seo->hook('cms', 'head-tags')->handle(...); // Tagged: ['handler:seo'] $seo->globalTags('v2'); $seo->hook('cms', 'render-html')->handle(...); // Tagged: ['handler:seo', 'v2'] ``` Per-hook tags still work and are merged with the auto-tags: ```php $seo->hook('cms', 'page-published') ->tag('sitemap') ->handle(...); // Tagged: ['handler:seo', 'sitemap'] ``` The `handler:` prefix is reserved and cannot be used manually via `tag()` or `globalTags()`. ### Priority Handlers run in priority order — lower numbers run first. Default is `10`. ```php $seo = $registry->registerHandler('seo'); // Runs first — sanitize early $seo->hook('cms', 'render-html') ->priority(1) ->handle(fn (RenderPayload $p) => $p->withHtml(strip_tags($p->html, '

'))); // Runs last — final formatting pass $seo->hook('cms', 'render-html') ->priority(100) ->handle(fn (RenderPayload $p) => $p->withHtml(nl2br($p->html))); ``` ### Exclusive handlers An exclusive handler short-circuits the chain. No lower-priority handlers run after it. This works on all three hook types. ```php $cache = $registry->registerHandler('cache'); $cache->hook('cms', 'render-html') ->priority(0) ->exclusive() ->when(fn (RenderPayload $p) => Cache::has("page:{$p->html}")) ->handle(fn (RenderPayload $p) => $p->withHtml(Cache::get("page:{$p->html}"))); ``` ### Conditional handlers Use `when()` to attach a condition. The handler is skipped (not removed) when the condition returns false. Skipped handlers do not break the chain — subsequent handlers continue normally. ```php $dashboard = $registry->registerHandler('dashboard'); // Only run for logged-in users $dashboard->hook('cms', 'sidebar-widgets') ->when(fn (SidebarContext $ctx) => $ctx->viewer->isAuthenticated()) ->handle(fn (SidebarContext $ctx) => [ new Widget('Your Drafts', renderDraftsWidget($ctx->viewer)), ]); // Only run in production $dashboard->hook('cms', 'render-html') ->when(fn () => getenv('APP_ENV') === 'production') ->handle(fn (RenderPayload $p) => $p->withHtml(minifyHtml($p->html))); ``` ### Tag filtering When invoking a hook, the source can pass a list of tags to only run matching handlers. A handler matches if it has at least one of the requested tags. ```php // Only run the SEO handler $payload = $cms->apply('render-html', $payload, ['handler:seo']); // Only run handlers with a custom tag $items = $cms->collectFromHandlers('admin-menu', $context, ['premium']); ``` If no tags are passed (the default), all handlers run as usual. This is useful when the source wants to target a specific handler - for example, letting the user choose which app handles a particular action. ### Full handler example ```php $debug = $registry->registerHandler('debug'); $debug->hook('cms', 'sidebar-widgets') ->priority(5) ->exclusive() ->when(fn (SidebarContext $ctx) => $ctx->viewer->isAdmin()) ->tag('admin') ->handle(fn (SidebarContext $ctx) => [ new Widget('Debug Panel', renderDebugPanel()), new Widget('Cache Stats', renderCacheStats()), ]); ``` ## Existence Checks Both sides can check whether the other exists before committing to work. ### Handler side — check if a source exists Useful for optional integrations where the source package may not be installed: ```php $minifier = $registry->registerHandler('minifier'); if ($registry->hasSource('cms')) { $minifier->hook('cms', 'render-html') ->handle(fn (RenderPayload $p) => $p->withHtml(minify($p->html))); } ``` ### Source side — check if anyone is listening Useful for skipping expensive payload construction when no handlers are registered: ```php if ($cms->hasHandlers('render-html')) { $payload = new RenderPayload($this->buildExpensiveHtml()); $payload = $cms->apply('render-html', $payload); } ``` Both methods validate their arguments — `hasHandlers()` throws `SourceNotFoundException` or `HookPointNotFoundException` if the source or point doesn't exist. ## Capability Discovery Both sources and handlers can declare capability tags at registration time, describing what they provide rather than who they are. The registry can then be queried by tag to find matching counterparts from either side. ### Declaring capabilities Sources declare tags as the third argument to `registerSource()`: ```php $cms = $registry->registerSource('cms', null, ['content-management', 'publishing']); ``` Handlers declare tags as the second argument to `registerHandler()`: ```php $seo = $registry->registerHandler('seo', ['content-enhancement', 'html-processing']); $analytics = $registry->registerHandler('analytics', ['tracking']); $minifier = $registry->registerHandler('minifier', ['content-enhancement']); ``` Handler capability tags are separate from per-hook tags set via `tag()` — they describe the handler itself, not individual hooks. ### Querying handlers by capability Use `handlersByTag()` to find all handlers that declared a given capability: ```php $enhancers = $registry->handlersByTag('content-enhancement'); // → [HandlerInfo('seo', [...]), HandlerInfo('minifier', [...])] ``` Each result is a `HandlerInfo` with `name`, `tags`, and a `hasTag()` helper: ```php foreach ($enhancers as $info) { echo $info->name; // 'seo' echo $info->hasTag('tracking'); // false } ``` ### Querying sources by capability Use `sourcesByTag()` to find all sources that declared a given capability: ```php $contentSources = $registry->sourcesByTag('content-management'); // → [SourceStore('cms')] ``` Each result is a `SourceStore` with `id`, `class`, `tags`, and a `hasTag()` helper. This lets handlers discover which sources are available without hard-coding source IDs: ```php foreach ($contentSources as $source) { echo $source->id; // 'cms' echo $source->hasTag('publishing'); // true } ``` ### Listing all handlers Use `handlers()` to get all registered handlers keyed by name: ```php $all = $registry->handlers(); // → ['seo' => HandlerInfo, 'analytics' => HandlerInfo, ...] ``` ### Discovery + targeted invocation Capability discovery pairs naturally with tag filtering. Discover available providers, let the user (or your app logic) pick one, then target it: ```php // 1. Find all handlers that provide notification delivery $providers = $registry->handlersByTag('notification-delivery'); // 2. Pick one (user choice, config, first match, etc.) $chosen = $providers[0]->name; // e.g. 'email' // 3. Target only that handler when dispatching $cms->dispatch('notify-subscribers', $payload, ["handler:{$chosen}"]); ``` Handlers can discover sources the same way: ```php // A handler finds sources it can extend $sources = $registry->sourcesByTag('content-management'); foreach ($sources as $source) { $handler->hook($source->id, 'render-html') ->handle(fn (RenderPayload $p) => $p->withHtml(minify($p->html))); } ``` ## Introspection ### List all sources ```php $sources = $registry->sources(); // Returns: array foreach ($sources as $id => $source) { echo "{$id} (class: {$source->class})\n"; } ``` ### List hook points for a source ```php $points = $registry->sources()['cms']->points(); // Returns: array foreach ($points as $name => $point) { echo "{$name}: {$point->type->value} ({$point->payloadClass})\n"; } // render-html: filter (RenderPayload) // page-published: action (PageEvent) // sidebar-widgets: collect (SidebarContext) ``` ### List handlers for a point Handlers are returned sorted by priority (lower first): ```php $handlers = $registry->sources()['cms']->getHandlers('render-html'); // Returns: list foreach ($handlers as $handler) { echo "priority={$handler->priority}" . " exclusive=" . ($handler->exclusive ? 'yes' : 'no') . " tags=" . implode(',', $handler->tags) . "\n"; } ``` ## Error Handling Latch throws specific exceptions for invalid usage: | Exception | When | | ---------------------------- | --------------------------------------------------------------------------------- | | `SourceNotFoundException` | Hooking into, invoking, or checking an unregistered source ID | | `HookPointNotFoundException` | Referencing a point name that wasn't declared on the source | | `HookTypeMismatchException` | Calling `apply()` on an action point, `dispatch()` on a filter, etc. | | `DuplicateHandlerException` | Calling `registerHandler()` with a name that's already registered | | `DuplicateSourceException` | Calling `registerSource()` with a name that's already registered | | `ReservedTagException` | Using the `handler:` prefix in `tag()` or `globalTags()` (reserved for auto-tags) | ```php use Latch\Exceptions\SourceNotFoundException; use Latch\Exceptions\HookPointNotFoundException; use Latch\Exceptions\HookTypeMismatchException; use Latch\Exceptions\DuplicateHandlerException; use Latch\Exceptions\DuplicateSourceException; use Latch\Exceptions\ReservedTagException; try { $cms->apply('some-point', $payload); } catch (HookPointNotFoundException $e) { // "Hook point 'some-point' not found in source 'cms'." } try { $handler = $registry->registerHandler('seo'); $handler->hook('cms', 'nonexistent-point'); } catch (HookPointNotFoundException $e) { // "Hook point 'nonexistent-point' not found in source 'cms'." } try { // 'page-published' is an action, not a filter $cms->apply('page-published', $payload); } catch (HookTypeMismatchException $e) { // "Hook point 'page-published' in source 'cms' is of type 'action', but was invoked as 'filter'." } try { $registry->registerHandler('seo'); $registry->registerHandler('seo'); // already registered } catch (DuplicateHandlerException $e) { // "Handler 'seo' is already registered." } try { $registry->registerSource('cms'); $registry->registerSource('cms'); // already registered } catch (DuplicateSourceException $e) { // "Source 'cms' is already registered." } ```