# 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
```php
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 <<
{$title->value}
{$this->renderTags($headTags)}
{$payload->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
```php
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) => [
"page->excerpt}\">",
"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 '';
}
}
```
### Wiring
```php
$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.
```php
// 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) => [
"page->excerpt}\">",
]);
// Analytics plugin registers with its own named handler
$analytics = $registry->registerHandler('analytics');
$analytics->hook('cms', 'head-tags')
->handle(fn (PageContext $ctx) => [
'',
]);
// 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.
```php
// 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:
```php
// 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
```php
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
```php
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 . ''
));
}
}
```
You can also resolve via the `latch` alias:
```php
$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
```php
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.
```php
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