Skip to content

Making a domain searchable

The command palette searches whatever you register with it. Out of the box that is the artifact catalogue — apps, reports, dashboards, dialogs. Your own domains are not in it until you add them, and adding one means writing two small classes and registering them as a pair.

This page walks through one: making partners searchable, so that typing a customer's name in the palette offers to open them.

The division of labour

Everything here follows from one split:

The CmdK layer decides what can be found. The Security layer decides what may be seen.

DoesNever does
CmdK layerbuilds the candidate query, materializes results into Action DTOstouches sdk_abilities, resolves roles or grants, filters for security reasons
Security layernarrows a query to the rows the actor may seeknows about the palette, builds DTOs, decides presentation

Keeping them apart is what lets a domain be searchable without its author learning the grant model, and lets the grant model change without touching a single collector.

The two-phase lifecycle

A collector is deliberately split in two, and the order is the whole point:

query()      →  the candidates            (what could be shown)
constrain()  →  visibility, at SQL level  (what may be shown)
collect()    →  materialization           (what will be shown)

Constraining happens between them, on the query — never on the results. That is what keeps counts honest, keeps authorization out of PHP loops, and means a row the actor may not see is never loaded, let alone counted.

1. The collector

It builds the candidate set and, once constrained, turns it into palette entries. It knows partners. It knows nothing about who may see them.

php
namespace App\Discovery;

use Illuminate\Database\Eloquent\Builder;
use LaravelUi5\Sdk\Intent\NavigationIntent;
use LaravelUi5\Sdk\Platform\SdkContext;
use LaravelUi5\Sdk\Shell\CmdK\Contracts\CollectorInterface;
use LaravelUi5\Sdk\Shell\CmdK\Dto\Action;
use LaravelUi5\Sdk\Partners\Models\Partner;

class PartnerCollector implements CollectorInterface
{
    protected function baseQuery(string $search): Builder
    {
        return Partner::query()
            ->where('archived', false)
            ->when($search !== '', fn (Builder $q) => $q->where(
                fn (Builder $inner) => $inner
                    ->where('name', 'like', "%{$search}%")
                    ->orWhere('search_term', 'like', "%{$search}%")
                    ->orWhere('email', 'like', "%{$search}%")
            ));
    }

    public function count(string $search, SdkContext $context): int
    {
        return $this->baseQuery($search)->count();
    }

    public function query(string $search, SdkContext $context): Builder
    {
        return $this->baseQuery($search);
    }

    public function collect(Builder $query, SdkContext $context): array
    {
        return $query->get()
            ->map(fn (Partner $partner) => new Action(
                id      : (string) $partner->id,
                title   : $partner->name,
                intent  : new NavigationIntent("route:com.acme.partners:detail/{$partner->id}"),
                keywords: $partner->email,
                section : 'Partners',
            ))
            ->toArray();
    }
}

Three things are worth pointing at:

count() is derived from the same base query. It must be, or the palette's counter and its list disagree — and the counter is the one that is computed before constraining is even possible to observe.

collect() receives the constrained query, not the base one. It may assume every row it loads is one the actor may see, which is exactly why it contains no checks.

The intent names where to go. A target is app:<namespace> for the app itself or route:<namespace>:<pattern> for a place inside it. Both resolve to the same app namespace, because a route inherits its app's access decision — the palette entry cannot outrank the app's #[Access].

2. The visibility resolver

This one lives in the Security layer and answers a single question: which partner rows may this actor see? Here, the actor's own record plus the organisations they belong to.

php
namespace App\Security;

use Illuminate\Database\Eloquent\Builder;
use LaravelUi5\Sdk\Platform\SdkContext;
use LaravelUi5\Sdk\Security\Contracts\VisibilityResolverInterface;

readonly class PartnerVisibilityResolver implements VisibilityResolverInterface
{
    public function constrain(Builder $query, SdkContext $context): Builder
    {
        $ids = array_map(
            static fn ($partner) => $partner->id,
            $context->orgPartners($context->at()),
        );

        $ids[] = $context->actor()->id;

        return $query->whereIn('id', $ids);
    }
}

It returns a query, never a result set. Resolving an id list first is fine — that is the actor's reachable set, computed once — but the narrowing itself stays relational.

Put the rule anywhere else and it becomes two rules: this resolver is the single place that answers "which partners" for the palette, the context discovery, and anything else that asks later.

3. The constrainer

The adapter between the two layers, and it does nothing else:

php
namespace App\Discovery;

use Illuminate\Database\Eloquent\Builder;
use LaravelUi5\Sdk\Platform\SdkContext;
use LaravelUi5\Sdk\Shell\CmdK\Contracts\VisibilityConstrainerInterface;
use App\Security\PartnerVisibilityResolver;

readonly class PartnerConstrainer implements VisibilityConstrainerInterface
{
    public function __construct(
        private PartnerVisibilityResolver $resolver,
    ) {
    }

    public function constrain(Builder $query, SdkContext $context): Builder
    {
        return $this->resolver->constrain($query, $context);
    }
}

It looks like a formality, and it is the seam that keeps the split real: the CmdK layer depends on a CmdK contract, and only this one class knows that a security resolver exists behind it.

4. Registration — always as a pair

In config/ui5.php. A discovery service declares its result key, the route it answers on, and its collectors; each collector names its authorizer:

php
'discovery' => [
    'search' => [
        'key'        => 'discoveryService',
        'route'      => 'ui5.shell.search',
        'collectors' => [
            Ui5ArtifactCollector::class => [
                'authorizer' => Ui5ArtifactConstrainer::class,
                'config'     => [],
            ],
            App\Discovery\PartnerCollector::class => [
                'authorizer' => App\Discovery\PartnerConstrainer::class,
                'config'     => [],
            ],
        ],
    ],
],

Each entry becomes a DiscoverySource at runtime — a collector bound to its authorizer. There is no way to register a collector without one, and that is deliberate: an unpaired collector would be a domain that searches everything for everyone.

ui5.discovery.context.collectors takes the same shape for the context discovery, which is the list the shell shows before anything is typed.

Two rules that outlive the example

Never filter after collecting. This is the shape to recognise and refuse:

php
$items = $collector->collect($query, $context);
$items = array_filter($items, fn ($item) => $context->can(/* … */)); // no

It costs O(n) per search, it makes counts disagree with lists, and it loads rows the actor may not see in order to discard them — which is a leak waiting for a logging statement or an exception message to expose it.

Search visibility is an optimization. Dispatch authorization is the enforcement. Even an entry that appears in the palette goes through intent authorization when it is actually opened. The two are not alternatives, and neither one alone is enough: without the constrainer the palette leaks the existence of records, and without dispatch authorization it hands them over.

What you get

A user types three letters, and partners they may reach appear beside apps and reports in the same list, with the same keyboard handling, opening through the same intent dispatch. Nothing in the palette knows what a partner is.

See also