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