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

10 KiB

Examples

End-to-End: CMS + SEO Plugin

A CMS app that declares extension points, and an SEO plugin that hooks into them.

Source side — CMS app

use Latch\HookRegistry;

class CmsApp
{
    private HookSource $cms;

    public function __construct(private HookRegistry $registry)
    {
        $this->cms = $registry->registerSource('cms')
            ->filter('render-html', RenderPayload::class)
            ->filter('page-title', TitlePayload::class)
            ->action('page-published', PageEvent::class)
            ->collect('head-tags', PageContext::class);
    }

    public function renderPage(Page $page): string
    {
        $payload = new RenderPayload($page->html);
        $payload = $this->cms->apply('render-html', $payload);

        $title = new TitlePayload($page->title);
        $title = $this->cms->apply('page-title', $title);

        $headTags = $this->cms->collectFromHandlers(
            'head-tags', new PageContext($page)
        );

        return <<<HTML
        <!DOCTYPE html>
        <html>
        <head>
            <title>{$title->value}</title>
            {$this->renderTags($headTags)}
        </head>
        <body>{$payload->html}</body>
        </html>
        HTML;
    }

    public function publishPage(Page $page, User $actor): void
    {
        $page->publish();

        $this->cms->dispatch(
            'page-published', new PageEvent($page, $actor)
        );
    }

    private function renderTags(array $tags): string
    {
        return implode("\n    ", $tags);
    }
}

Handler side — SEO plugin

use Latch\HookRegistry;

class SeoPlugin
{
    public function __construct(HookRegistry $registry)
    {
        // Register a named handler - all hooks auto-tagged with 'handler:seo'
        $seo = $registry->registerHandler('seo');

        $seo->hook('cms', 'head-tags')
            ->priority(1)
            ->handle(fn (PageContext $ctx) => [
                "<meta name=\"description\" content=\"{$ctx->page->excerpt}\">",
                "<meta property=\"og:title\" content=\"{$ctx->page->title}\">",
            ]);

        $seo->hook('cms', 'page-title')
            ->priority(50)
            ->handle(fn (TitlePayload $t) => $t->withValue(
                $t->value . ' | My Site'
            ));

        $seo->hook('cms', 'render-html')
            ->priority(90)
            ->handle(fn (RenderPayload $p) => $p->withHtml(
                $p->html . $this->jsonLdScript($p)
            ));

        $seo->hook('cms', 'page-published')
            ->handle(fn (PageEvent $e) => SitemapQueue::push($e->page->url));
    }

    private function jsonLdScript(RenderPayload $payload): string
    {
        return '<script type="application/ld+json">{"@type":"WebPage"}</script>';
    }
}

Wiring

$registry = new HookRegistry();

$cms = new CmsApp($registry);
$seo = new SeoPlugin($registry);

echo $cms->renderPage($page);

Tag Filtering: Targeted Invocation

When multiple handlers are registered for the same point, the source can use tag filtering to only invoke specific ones. Pass a list of tags to apply(), dispatch(), or collectFromHandlers() and only handlers with at least one matching tag will run.

Using registerHandler(), each plugin gets auto-tagged with handler:{name}, making targeted invocation straightforward.

// Source registers with head-tags collect point
$cms = $registry->registerSource('cms')
    ->collect('head-tags', PageContext::class);

// SEO plugin registers with a named handler
$seo = $registry->registerHandler('seo');
$seo->hook('cms', 'head-tags')
    ->handle(fn (PageContext $ctx) => [
        "<meta name=\"description\" content=\"{$ctx->page->excerpt}\">",
    ]);

// Analytics plugin registers with its own named handler
$analytics = $registry->registerHandler('analytics');
$analytics->hook('cms', 'head-tags')
    ->handle(fn (PageContext $ctx) => [
        '<script src="/analytics.js"></script>',
    ]);

// Collect from all handlers (default)
$allTags = $cms->collectFromHandlers('head-tags', $context);

// Collect only from the SEO handler
$seoTags = $cms->collectFromHandlers('head-tags', $context, ['handler:seo']);

Capability discovery

Instead of hard-coding handler names, the source can discover handlers by capability tag and then target the chosen one.

// Handlers declare capabilities at registration
$email = $registry->registerHandler('email', ['notification-delivery']);
$push = $registry->registerHandler('push', ['notification-delivery']);

// Both hook into the same points
$cms = $registry->registerSource('cms')
    ->action('notify-subscribers', NotifyPayload::class);

$email->hook('cms', 'notify-subscribers')
    ->handle(fn (NotifyPayload $p) => EmailService::send($p));

$push->hook('cms', 'notify-subscribers')
    ->handle(fn (NotifyPayload $p) => PushService::send($p));

// Step 1: Discover who can deliver notifications
$providers = $registry->handlersByTag('notification-delivery');
// → [HandlerInfo('email', [...]), HandlerInfo('push', [...])]

// Step 2: Present them to the user
foreach ($providers as $p) {
    echo "{$p->name}\n"; // 'email', 'push'
}

// Step 3: Dispatch to the user's choice
$chosen = $userChoice; // e.g. 'email'
$cms->dispatch(
    'notify-subscribers', $payload, ["handler:{$chosen}"]
);

This pattern lets the source app find compatible handlers at runtime without importing anything from the handler packages — only the capability tag string needs to be agreed upon.

Handler discovering sources

The reverse works too — a handler can discover sources by capability and hook into all of them:

// Sources declare capabilities
$registry->registerSource('cms', null, ['content-management'])
    ->filter('render-html', RenderPayload::class);

$registry->registerSource('blog', null, ['content-management'])
    ->filter('render-html', RenderPayload::class);

// Handler discovers all content-management sources and hooks into each
$minifier = $registry->registerHandler('minifier');

$sources = $registry->sourcesByTag('content-management');
foreach ($sources as $source) {
    $minifier->hook($source->id, 'render-html')
        ->handle(fn (RenderPayload $p) => $p->withHtml(minify($p->html)));
}

Laravel Integration

The service provider is auto-discovered. Inject HookRegistryInterface anywhere Laravel resolves dependencies.

Source — CMS service provider

use Latch\Contracts\HookRegistryInterface;

class CmsServiceProvider extends ServiceProvider
{
    public function boot(HookRegistryInterface $registry): void
    {
        $cms = $registry->registerSource('cms')
            ->filter('render-html', RenderPayload::class)
            ->action('page-published', PageEvent::class)
            ->collect('nav-items', NavContext::class);
    }
}

Handler — SEO service provider

class SeoServiceProvider extends ServiceProvider
{
    public function boot(HookRegistryInterface $registry): void
    {
        $seo = $registry->registerHandler('seo');

        $seo->hook('cms', 'render-html')
            ->priority(90)
            ->handle(fn (RenderPayload $p) => $p->withHtml(
                $p->html . '<meta name="generator" content="seo-plugin">'
            ));
    }
}

You can also resolve via the latch alias:

$registry = app('latch');

Nextcloud Integration

Each Nextcloud app bundles its own copy of Latch via composer require. Cross-app communication is handled transparently by LatchBootstrap::registry(), which returns a bridged registry that routes hooks through NC's shared IEventDispatcher. No shared autoloader or extra app needed.

The API is identical to plain Latch — the only difference is using LatchBootstrap::registry() instead of new HookRegistry().

Source — app that owns the hooks

namespace OCA\CmsApp\AppInfo;

use Latch\Integration\Nextcloud\LatchBootstrap;
use Latch\Integration\Nextcloud\BridgedSource;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IRegistrationContext;

class Application extends App implements IBootstrap
{
    private BridgedSource $cms;

    public function __construct()
    {
        parent::__construct('cms');
    }

    public function register(IRegistrationContext $context): void {}

    public function boot(IBootContext $context): void
    {
        $registry = LatchBootstrap::registry();

        // Same API as plain Latch — declaration and invocation work the same way
        $this->cms = $registry->registerSource('cms')
            ->action('page-published', PageEvent::class)
            ->collect('head-tags', PageContext::class);
    }

    public function publishPage(Page $page, User $actor): void
    {
        // Dispatches to both local handlers and handlers in other apps
        $this->cms->dispatch(
            'page-published',
            new PageEvent($page, $actor)
        );
    }
}

Handler — another Nextcloud app

The handler app hooks into a remote source using the same hook() API. If the source doesn't exist in the local registry, it's treated as remote and the handler listens via NC events.

namespace OCA\SeoPlugin\AppInfo;

use Latch\Integration\Nextcloud\LatchBootstrap;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IRegistrationContext;

class Application extends App implements IBootstrap
{
    public function __construct()
    {
        parent::__construct('seo');
    }

    public function register(IRegistrationContext $context): void {}

    public function boot(IBootContext $context): void
    {
        $registry = LatchBootstrap::registry();
        $handler = $registry->registerHandler('seo');

        // Same API — hook into a source by name, even if it's in another app
        $handler->hook('cms', 'page-published')
            ->handle(fn ($payload) => SitemapQueue::push($payload->page->url));
    }
}

How it works

Each app bundles its own copy of Latch. The bridged registry uses NC's IEventDispatcher (shared across all apps) with conventional event names like latch:cms:page-published. Payloads work across autoloader boundaries because PHP resolves methods on the actual object at runtime, not the type hint.

All three hook types are supported through the bridge:

  • Actions — fire-and-forget through NC events
  • Filters — the event carries the payload; each listener mutates it and the source reads it back
  • Collectors — each listener adds items to the event; the source collects them after dispatch