Global Search (CmdK)
Cmd+K (or Ctrl+K) opens the command palette: one field, a list of things this actor may open, and Enter to go there. It is the shortest path through a suite that has more apps than a rail can show.
The palette is not a text search over your data. It searches artifacts — the apps, dashboards, reports and dialogs the installation has registered — and every row it offers is something the actor is allowed to open.
Needs SDK 1.2.0
On 1.1.1 the palette opens and filters in the browser, but two wire mismatches keep it from working: the client sent the search term under a different parameter name than the server read — so the server-side query never ran — and the selection event carried the chosen entry in a field the shell did not read, so Enter did not navigate. Both are fixed in 1.2.0, which also ships the rebuilt shell bundle: upgrade, and the browser gets the new main.esm.js from the package route. The page below describes the fixed behaviour.

Where the entries come from
ui5:sync writes every registered artifact into the catalog. The palette's shipped collector queries that table:
- four types only — applications, reports, dashboards and dialogs;
- excluding infrastructure artifacts, which are marked as not exposed at sync time; there is no per-app opt-out;
- matching the term against the artifact's title and description with a plain
LIKE.
So an artifact appears in the palette because it was synced and is not infrastructure. If something is missing, ui5:sync is the first place to look.
Visibility is part of the query
The palette never filters in PHP. The actor's ability ids go into the SQL, as a correlated exists over the ability table restricted to Access:
… where exists (
select 1 from sdk_abilities
where sdk_abilities.artifact_id = sdk_artifacts.id
and sdk_abilities.type = <Access>
and sdk_abilities.id in (<the actor's ability ids>)
)An artifact with no Access ability therefore does not appear in the palette — the query requires a match, so an ungated app is invisible here even though anyone may open it. That is worth knowing before you wonder where your app went: give it an #[Access] (Permission Levels).
And the filtering is an optimization, not the gate. Selecting a row dispatches an intent, and the intent is authorized again on the server (Intent Dispatch). The rule behind that split is on Visibility & CmdK.
What happens as you type
- On open, the palette is already populated: the shell's context carries a pre-fetched window of entries, so the first keystroke costs nothing.
- Below the minimum length (two characters by configuration) it refines that window in the browser, matching title and keywords.
- Above it, it first narrows the rows it already holds. It asks the server, which runs the collectors and returns the authorized rows, when the query no longer extends the previous one, when the held window was marked as too large, or when nothing matches locally.
- Too many matches — more than the configured limit, 1000 by default — and the server answers with no rows and a refine your query message instead of a page of noise. A client may ask for a smaller limit; it cannot raise the configured one.
The count the server reports is the candidate count, taken before visibility is applied. It is a "how broad is this term" number, not a promise about how many rows you will see.
The palette renders an icon and a title per row, as one flat list. The section a collector sets and the keywords it provides are carried but not displayed — keywords are matched, sections are not grouped yet.
Adding your own entries
Two contracts, one config entry. A collector finds candidates; a constrainer narrows the query to what the actor may see.
use LaravelUi5\Sdk\Shell\CmdK\Contracts\CollectorInterface;
use LaravelUi5\Sdk\Shell\CmdK\Dto\Action;
final class CustomerCollector implements CollectorInterface
{
public function count(string $search, SdkContext $context): int
{
return $this->query($search, $context)->count();
}
public function query(string $search, SdkContext $context): Builder
{
return Customer::query()
->when($search !== '', fn ($q) => $q->where('name', 'like', "%{$search}%"));
}
public function collect(Builder $query, SdkContext $context): array
{
return $query->get()->map(fn (Customer $c) => new Action(
id: "customer-{$c->id}",
title: $c->name,
intent: new WeaveOpenIntent('acme.customer', (string) $c->id),
keywords: $c->city,
section: 'Customers',
))->all();
}
}Three rules the contract insists on, and they are what keep the palette honest:
count()must derive fromquery()and must not add constraints of its own.query()returns a builder, not results. The constrainer needs something it can still narrow.collect()must not filter. Anything decided in PHP after the query is a leak waiting to happen.
The constrainer is an adapter onto a Security visibility resolver — it does not invent a rule, it applies one:
final class CustomerConstrainer implements VisibilityConstrainerInterface
{
public function __construct(private CustomerVisibilityResolver $resolver) {}
public function constrain(Builder $query, SdkContext $context): Builder
{
return $this->resolver->constrain($query, $context);
}
}Register the pair. Note the slot is called authorizer but takes the constrainer:
// config/ui5.php
'discovery' => [
'context' => [
'key' => 'contextService',
'route' => 'ui5.shell.context',
'collectors' => [
\LaravelUi5\Sdk\Shell\CmdK\Ui5ArtifactCollector::class => [
'authorizer' => \LaravelUi5\Sdk\Shell\CmdK\Ui5ArtifactConstrainer::class,
'config' => [],
],
],
],
'search' => [
'key' => 'discoveryService',
'route' => 'ui5.shell.search',
'collectors' => [
\LaravelUi5\Sdk\Shell\CmdK\Ui5ArtifactCollector::class => [
'authorizer' => \LaravelUi5\Sdk\Shell\CmdK\Ui5ArtifactConstrainer::class,
'config' => [],
],
CustomerCollector::class => [
'authorizer' => CustomerConstrainer::class,
'config' => [],
],
],
],
],Both branches exist for a reason: context fills the pre-fetched window in context.json, search serves the live query. A collector you want in both goes in both.
Restate the whole key
Configuration is merged one level deep, so declaring discovery replaces the SDK's subtree entirely — including key, route and the artifact collector pair. Leaving any of them out is an undefined-array-key error at request time, not a warning. Copy the block above and edit it.
What it doesn't do
- No fuzzy matching, no ranking.
LIKEon title and description, ordered by the query. - No grouping. Sections are carried on the DTO and ignored by the renderer.
- No per-user history or favourites.
- No search over your business data unless you write the collector for it — and then the rows are yours to authorize.
See also
- Visibility & CmdK: the layering rules, and why filtering belongs in the query
- Intent Dispatch: what a selected row does
- Context API: the
actionskey that pre-fills the palette - LeanShell Overview: the shell services this is one of