Cross-Entity Search Guide¶
One term, several entities, grouped results — the search box in an admin header that finds a user, an order and an invoice without you choosing a screen first.
What was already there, and what this adds¶
Searching one entity has never needed this page. Every model implements
Application\ApiList\ApiListSource, and ApiListQuery turns a term into a query with
paging, per-field search, dialect-correct ILIKE/LIKE and honest totals. That is what
a Datatable's search box uses, what a generated CRUD list uses, and what
GET /admin/users?search=… uses.
What no entity can implement for itself is the aggregate. Search\Registry is that,
and nothing else: a list of sources, and a loop over the engine that already exists.
| Per-entity search | Cross-entity search | |
|---|---|---|
| What answers it | ApiListSource on the model |
Search\Registry |
| Where it appears | a list screen's own search field | one box in the header |
| Configuration needed | none | app/search.php |
Registering a source¶
Sources are declared in app/search.php, a plain PHP file loaded by
Registry::loadDefinitions() — the same convention as app/schedule.php.
use Pramnos\Search\Registry;
Registry::register('Users', \Pramnos\User\User::class, [
'display' => ['username', 'email'],
'url' => '/users/edit/:id',
]);
A scaffolded project gets this file with User already registered. create:crud offers
to add a line for each new entity — it asks rather than doing it, because an omnibox is
a decision about what may surface and some tables should never appear in one.
Options¶
| Option | |
|---|---|
display |
Columns to show. The first is the result title (unless title is set), the rest become the subtitle. Defaults to the source's own default fields, which is a guess — set it. |
url |
Link pattern; :id is replaced with the primary key, URL-encoded. Omit for a result that is not a link. |
limit |
Cap for this source. Defaults to the query's per-source cap (5). |
fields |
Columns to select. Defaults to the primary key, display, and the columns title / subtitle patterns name. |
order |
Order spec, passed through to ApiListQuery. |
permission |
Who may see this entity at all — see below. |
filter |
A WHERE body applied before the term. As a callable, which rows this viewer may see. |
search |
fn (string $term, ApiListSource $source) — how this source matches the term, in place of its own rule. See below. |
count |
false skips the COUNT behind a group's total — see Without a count. |
title / subtitle |
A pattern with :column placeholders, or fn (array $row): string. With title set, every display column goes to the subtitle. |
The source itself may be an ApiListSource instance, the class name of one, or a
callable returning one. Prefer the class name: it is not constructed until something
actually searches, so a registry of six models costs nothing on the requests that never
use the box.
Matching the term your own way¶
By default a source matches the term with its own rule: the model's text columns, LIKE. An
application that knows better says so in app/search.php, without a model subclass:
Registry::register('Users', \Pramnos\User\User::class, [
'title' => '#:userid: :lastname :firstname (:username)',
'subtitle' => ':mobile',
'url' => '/admin/users/:id',
'search' => static function (string $term): array|false|null {
if (str_contains($term, '@')) {
return false; // no answer: no query, no group
}
if (preg_match('/^\d{7,}$/', $term)) {
return [['field' => 'mobile', 'op' => 'LIKE', 'value' => '%' . $term . '%']];
}
return [['or' => [
['field' => 'firstname', 'op' => 'LIKE', 'value' => '%' . $term . '%'],
['field' => 'lastname', 'op' => 'LIKE', 'value' => '%' . $term . '%'],
]]];
},
]);
What the callable returns decides what happens:
| Returns | Meaning |
|---|---|
| an array of conditions | The [field, op, value] / ['or' => …] form a list filter takes. Columns are checked against the model and values escaped; LIKE becomes ILIKE on PostgreSQL. |
| a string | A WHERE body used as it is. The term is user input: escape it yourself, with $db->prepareQuery(). |
null |
The source's own rule. |
false |
This source has no answer for this term: it is not queried and no group appears. |
The condition is added to filter, never in place of it, so a tenant scope holds whatever the
rule says. A rule that produces no condition — conditions naming only columns the model does
not have, an empty string — drops the source, because no condition would answer every term with
the table's first rows.
Titles from a pattern¶
title and subtitle take a pattern, where each :column is replaced by that column of the row
and an empty column leaves no double space, or a callable receiving the row. Without fields,
the columns a pattern names are selected for you. A callable cannot say which columns it reads,
so it reads display and whatever fields lists.
Permissions¶
The endpoint lets any signed-in user ask: ApiAdmin::search() requires authentication
and nothing else, with or without an organisation in scope. What an answer contains is
decided inside the registry, at two levels.
Per source — who may see this entity¶
Registry::register('Invoices', \App\Models\Invoice::class, [
'display' => ['reference', 'customer_name'],
'permission' => 'invoices.list', // checked with Auth\Gate
]);
Registry::register('Payroll', \App\Models\Payslip::class, [
'display' => ['employee', 'period'],
'permission' => static fn($user) => $user?->usertype === 'hr',
]);
A string goes through Auth\Gate::allows(); a callable
receives the current user (or null) and returns a bool.
A hidden source leaves no trace in the response
Not an empty group. An empty group headed "Invoices" tells somebody who may not see invoices that invoices exist, how they are labelled, and that a search found none. The group is absent entirely, and the source is never queried.
Gate is fail-closed: an ability with no rule, policy or stored permission behind it
decides false. That is the right default and the one surprising one — a permission
naming an ability nothing defines hides the source from everybody, administrators
included. If a registered entity never appears, check that its ability is actually
defined before checking anything else.
No permission means administrators only. A source without one shows to whoever may
open the administration area: the dashboard's ability (admin.dashboard) at the
administration floor, as AdminAccess decides. The
scaffolded Users source has no permission, so a member searching gets no user list. A
source meant for every signed-in user says so:
Registry::register('Pages', \App\Models\Page::class, [
'display' => ['title'],
'permission' => static fn ($user): bool => true,
]);
The MCP search tool reads the same registry, so the same rule applies to a token.
Per row — which records this viewer may see¶
filter as a callable is how a tenant scope is written:
Registry::register('Orders', \App\Models\Order::class, [
'display' => ['reference', 'customer_name'],
'filter' => static fn($user) => 'deleted = 0 AND tenant_id = ' . (int) $user->tenantid,
]);
A filter callable must return a string
Anything else — a null from a missing tenant, an early return somebody added —
drops the source instead of degrading to "no filter". Degrading would return every
row of the table to a viewer entitled to a subset. This is the one place where the
safe direction is not the convenient one, so it is enforced rather than documented.
What comes back¶
{
"query": "ann",
"total": 13,
"groups": [
{
"label": "Users",
"total": 12,
"more": true,
"results": [
{ "id": 1, "title": "annak", "subtitle": "anna@example.com", "url": "/users/edit/1" }
]
}
]
}
Each group also says more: whether rows exist beyond the ones returned.
Without a count¶
A group's total costs a COUNT with the same WHERE as the results, and a LIKE '%term%'
count reads the whole table — on every keystroke, in a search box. Where the box needs only
"there are more", say so:
Registry::register('Users', \Pramnos\User\User::class, [
'display' => ['username', 'email'],
'count' => false,
]);
The source is asked for one row past its limit, with its count turned off. The group comes back
with total: null and more: true when that extra row existed, and the box shows 5+ instead
of 5/340. The response's overall total then adds what such a group returned, so it reads as
"at least". A Model or User source skips the COUNT; any other ApiListSource still counts,
and answers in the same shape.
Grouped, not ranked. There is no score across sources, deliberately: a relevance
number comparing a username to an invoice line is invented. 1/12 next to a group is
honest in a way that a merged, re-sorted list is not.
A source that fails — a table a migration has not created, a query that errors — is
logged to the search log and dropped. One undeployed model must not take the search box
down for every other entity.
The user interface¶
Both scaffold styles ship a box; both call the same endpoint and render the same shape.
Server-rendered themes get Html\SearchBox
in the header, with behaviour from data-pf-omnibox in assets/js/pf-utils.js and styles
from assets/css/style.css. All three UI systems (Bootstrap, Tailwind, plain) get it.
SPA projects get components/Omnibox.svelte, wired into the app shell.
Both render only for a signed-in user who can reach the admin section, and only when something is registered — a box that always answers 403, or always finds nothing, is worse than no box.
Both debounce (250 ms) and both abort the previous request. The abort is not an
optimisation: without it a slow answer to an can land after a fast answer to anna and
replace newer results with older ones, which on screen looks like the search returning
wrong matches rather than like a race.
Rendering it somewhere else¶
$box = new \Pramnos\Html\SearchBox(); // endpoint from the app's api_prefix
$box->placeholder = 'Find anything…';
$box->id = 'sidebar-search'; // needed only for a second box on one page
echo $box->render();
The component contributes no script and no stylesheet — both are already on the page.
That is the same rule Html\Input follows: an element that pushes assets into the
document while rendering itself makes echoing a search box change the page's asset list.
Existing projects¶
A project scaffolded before this existed has no app/search.php, and
loadDefinitions() returning false means "no search box" rather than an error. To adopt
it, create the file, register at least one source, and:
- server-rendered — add
echo (new \Pramnos\Html\SearchBox())->render();to your header and re-takepf-utils.jsand the omnibox styles; - SPA —
pramnos project:resync --spa-componentswritescomponents/Omnibox.svelte.
The route is GET <api_prefix>/admin/search, handled by
\Pramnos\Auth\Controllers\ApiAdmin::search().
How the box authenticates¶
It doesn't send an API key — a page has none to send. It sends the session cookie plus
X-CSRF-Token, read from the <meta name="csrf"> tag the document prints for a
signed-in visitor, and ApiAuthMiddleware accepts that pair in place of a key. See
Calling your own API from your own page.
Two things follow for a project adopting this:
pf-utils.jshas to be current. The handler andpfApiHeaders()both live there; an older copy renders the box and does nothing with it.pramnos project:resyncbrings the file forward.- The token comes from the framework's document, not from your theme — nothing to
add to
head.php.
See also¶
- HTML Components —
SearchBox,Input,Select,Pagination - Query Builder — the query half
- Authorization —
Gate, abilities and policies