Guides
Add search to the site
Turn on content search, build the index, and choose between PostgreSQL and Meilisearch.
At the end of this page your published content is searchable: GET /v1/search answers queries
over every entry a caller is allowed to read, and a theme can put a search box in front of it.
Nothing new has to be installed — the index lives in the site's own PostgreSQL database — and
Meilisearch is there if you would rather run one.
You need published content, an admin account with the content.manage permission, and a shell on
the server for one command.
Switch content search on
Search ships off.
- Go to Settings › General.
- In Feature toggles, turn on Content search.
- Press Save.
The same switch is the Search row in Extensions › Capabilities; both write the capability
thallo.search. See capabilities and packs for the deploy-time
default in config/thallo.php.
Until it is on, /v1/search is not registered and answers the router's 404, search:reindex and
search:status do not exist, and every publish leaves the index alone.
Build the index
Switching search on indexes nothing. Backfill it once, from a shell in the project directory:
php glueful search:reindex It reports how many documents it indexed. Two options narrow the run: --type=<slug> for one
content type, --locale=<code> for one locale. From then on the index keeps itself in step —
publishing, updating, unpublishing and deleting an entry each reindex it. A search-backend
failure during a publish is logged and never breaks the publish, so run search:reindex again
if you suspect a gap.
Check what answers:
php glueful search:status
Engine: Postgres full-text search
Backend: reachable, index present. It exits non-zero when the backend is unreachable, and prints a line for every per-type configuration problem it finds.
What is indexed
One document per published entry per locale, for every content type. An entry is indexed when its content type has not been deleted, the entry is active, and it has a route for that locale.
- Fields. Every
string,textandblocksfield of the type. - Title. The field named
title; failing that the entry's slug; failing that the first indexed string field. - Body. The remaining indexed fields, as the words a reader sees. A
richtext field loses its tags; aplaintext field loses its Markdown syntax; a field whose whole value is one URL or one file path is left out, because it is where a page came from rather than what it says. Ablocksfield (a page built in the Design view) contributes the text of every block in page order, nested blocks included: each block's string and text fields, read by that block type's schema, so a setting, a link or a colour is never indexed as a word.
Left out: drafts, scheduled entries that have not gone live yet, and every field that is not
string, text or blocks — number, boolean, datetime, enum, reference, asset,
json and token. Pages already indexed pick up their block text on the next
php glueful search:reindex.
Visibility is not baked into a document. It is resolved from the live content types on every request, so turning a type's Public delivery off drops it out of anonymous results at once, with no reindex.
Pick which fields are indexed
By default the builder picks the fields itself. Override that per content type in
config/search.php — create the file in your project and set only the keys you are changing:
<?php
return [
'types' => [
'blog' => [
'title_field' => 'headline',
'body_fields' => ['summary', 'body'],
'exclude_fields' => ['seo_description'],
'weights' => ['headline' => 5, 'summary' => 2, 'body' => 1],
],
],
]; weights order the fields concatenated into the searchable body, highest first. A configured
field that does not exist, or is not a string or text field, is skipped at runtime and named by
php glueful search:status. Run php glueful search:reindex after changing any of this.
Query the search API
GET /v1/search?q=<terms>&locale=<code>[&type=<slug>][&limit=<n>][&offset=<n>] q and locale are required. type narrows results to one content type. limit defaults to 20
and is clamped to 1–50; offset is 0 or more. The route is rate limited to 120 requests a minute
per caller.
An API key is optional. Without one the caller sees only content types whose Public delivery
is on; with one, the read:content and read:content:{type} scopes widen that, exactly as they
do for the delivery API. Visibility is applied inside the query, so total and paging stay
correct. See the content API for keys and scopes.
curl "https://example.com/v1/search?q=climate+crisis&locale=en&limit=2" {
"success": true,
"message": "Success",
"data": {
"hits": [
{
"uuid": "e1f0a72c4b98",
"type": "blog",
"locale": "en",
"href": "/blog/climate",
"title": "The climate crisis",
"snippet": "…the <mark>climate</mark> <mark>crisis</mark> began…",
"score": 0.98
}
],
"total": 42,
"limit": 2,
"offset": 0
}
} The only markup in snippet is <mark>; everything else in it is escaped, so it is safe to put
straight into the page. title carries no highlighting.
The failures: empty q or missing locale is 422, an unknown type is 404, a type the caller
may not read is 403, and a backend that cannot answer is 503.
Choose the engine
SEARCH_ENGINE in .env picks it.
SEARCH_ENGINE |
Engine |
|---|---|
auto (the default) |
Meilisearch when MEILISEARCH_HOST is set, otherwise PostgreSQL. |
postgres |
PostgreSQL full-text search. The site's database must be PostgreSQL. |
meilisearch |
Meilisearch. Needs the glueful/meilisearch extension enabled and a reachable server. |
PostgreSQL is the engine that needs nothing: the index is the search_documents table, created
by php glueful thallo:provision, and Postgres maintains its search vector itself. A query is
cut into words and nothing else — each word must match, by its stem in the page's language or as
a prefix of a word as written, so more words narrow a search and no operator a visitor types
reaches the parser. A locale maps to a text-search configuration by its language, so fr-CA is
indexed as French; a language Postgres has no configuration for uses simple, which matches
whole words and prefixes without stemming. Only the first twelve distinct words of a query are
used.
Meilisearch adds typo tolerance, at the cost of a server to run. Enable the extension:
php glueful extensions:enable glueful/meilisearch Then add MEILISEARCH_HOST to .env, pointing at the server, and MEILISEARCH_KEY if the
server needs a key. Neither is in the shipped .env.example; write them in yourself.
A choice that cannot be honoured is never swapped for the other engine behind your back. Search
goes unavailable instead: the endpoint answers 503, publishing carries on untouched, and
php glueful search:status prints the reason. Run php glueful search:reindex after changing
engines.
Two more settings, in .env or config/search.php: SEARCH_INDEX names the Meilisearch index
(content), and SEARCH_SNIPPET_LENGTH sets the snippet crop length in words (40).
Put a search box in a theme
Templates get search_enabled(), which is true only while the capability is on. Offer a box
inside it, so a visitor never gets one that cannot answer.
The default theme ships the box as _docs_search.twig, used by entry/docs.twig and
listing/docs.twig. Nothing in it is tied to the docs type, so include it from any entry or
listing template — both already carry the type and site it reads:
{% include '_docs_search.twig' %} A theme of your own that omits the file falls back to the default theme's copy. To write your
own instead, reuse the behaviour that ships with it: give the form data-docs-search,
data-type (a content type's slug, or empty for every type) and data-locale, mark it hidden,
put an input, an element with role="listbox" and an element with data-docs-search-status
inside it, and call {{ block_script('docs-search') }}. The script unhides the form, queries
after two characters, and lists eight hits: arrows move, Enter opens, Escape closes, / focuses.
A visitor without JavaScript sees no box rather than a dead one. See
make your own theme.
For a documentation section the box is already wired up — see a documentation section on your site.
Check it worked
Run php glueful search:status and read the engine line. Then query the endpoint for a word you
know is in a published page:
curl "https://example.com/v1/search?q=climate&locale=en" A total above zero, with an href you can open, means the index is live. A 404 means the
capability is still off; a 503 means the engine cannot answer, and search:status says why.