Files
latch/docs/guide.md
2026-04-26 23:23:59 +03:00

14 KiB

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.

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:

$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.

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.

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.

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 "<div class=\"widget\"><h3>{$widget->title}</h3>{$widget->html}</div>";
}

The context argument is optional — pass null or omit it if handlers don't need context:

$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}.

$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:

$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:

$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:

$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.

$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, '<p><a><strong>')));

// 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.

$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.

$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.

// 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

$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:

$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:

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():

$cms = $registry->registerSource('cms', null, ['content-management', 'publishing']);

Handlers declare tags as the second argument to registerHandler():

$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:

$enhancers = $registry->handlersByTag('content-enhancement');
// → [HandlerInfo('seo', [...]), HandlerInfo('minifier', [...])]

Each result is a HandlerInfo with name, tags, and a hasTag() helper:

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:

$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:

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:

$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:

// 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:

// 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

$sources = $registry->sources();
// Returns: array<string, SourceStore>

foreach ($sources as $id => $source) {
    echo "{$id} (class: {$source->class})\n";
}

List hook points for a source

$points = $registry->sources()['cms']->points();
// Returns: array<string, HookPoint>

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):

$handlers = $registry->sources()['cms']->getHandlers('render-html');
// Returns: list<HandlerEntry>

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)
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."
}