Search by

glueful / thallo-search

msowah

Search for Thallo: a public, delivery-parity content search API backed by Meilisearch, as a removable capability pack.

Package info

github.com/glueful/thallo-search

pkg:composer/glueful/thallo-search

Statistics

Installs: 1

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0-beta.22 2026-09-12 06:14 UTC

This package is auto-updated.

Last update: 2026-09-12 23:39:33 UTC


README

Public, delivery-parity content search for Thallo, backed by Meilisearch — shipped as a removable capability pack.

thallo-search owns Thallo semantics (published-only visibility, href/title, lifecycle sync, the ContentReindexer seam); the glueful/meilisearch extension owns the search mechanics. A single class (LiveMeilisearchIndex) touches Meilisearch, behind a pack-owned SearchBackend port — so a Postgres FTS backend could plug in later without touching anything else.

Install & enable

Unlike Thallo's infra-free packs (seo, analytics, collections, importers), thallo-search is opt-in, not bundled-on by default — it needs a running Meilisearch, so a lean install ships it off (no search reindexer is bound, /v1/search is not registered). It is still fully discoverable: php glueful extensions:list shows it under Available (off) (), and php glueful extensions:info thallo-search shows its details.

To add it to an existing app (it lives as a path package in this monorepo):

  1. composer require glueful/thallo-search
  2. Ensure Meilisearch is reachable (configure the glueful/meilisearch extension).
  3. php glueful extensions:enable thallo-search — writes the provider into the config/extensions.php allow-list and recompiles the extension cache (or add the FQCN Thallo\Search\SearchServiceProvider to that list by hand).
  4. php glueful search:reindex — backfill the index from published content.

The pack registers no migrations (Meilisearch owns storage). Once enabled, the thallo.search capability is on; disable it without removing the extension by setting 'thallo.search' => false in config/thallo.php's capabilities switchboard (routes then 404 and the reindexer resolves to a no-op). When Meilisearch is missing or unhealthy, the endpoint fails closed (503) and live reindexing no-ops without ever breaking a publish.

Endpoint

GET /v1/search?q=<terms>&locale=<code>[&type=<slug>][&limit=<n>][&offset=<n>]

Behind optional_api_key: an authenticated key narrows visibility to its scopes; an anonymous request sees only content types with public_delivery = true. Visibility is enforced inside the Meilisearch filter, so total and pagination stay correct.

Response — the payload is wrapped in the framework's standard data envelope:

{
  "success": true,
  "data": {
    "hits": [
      {
        "uuid": "e-1",
        "type": "blog",
        "locale": "en",
        "href": "/en/blog/climate",
        "title": "The climate crisis",
        "snippet": "…the <mark>climate</mark> crisis…",
        "score": 0.98
      }
    ],
    "total": 42,
    "limit": 20,
    "offset": 0
  }
}
  • Highlighting: the only markup in snippet is <mark>…</mark>; all other source markup is HTML-escaped, so the snippet is safe to render without client-side sanitising. title is plain text (no highlighting).
  • Visibility & type (delivery parity, matches DeliveryAccessMiddleware):
    • read:content scope ⇒ all types; read:content:{slug} scopes ⇒ those types; anonymous ⇒ public_delivery types only.
    • type omitted → results span every accessible type; inaccessible types are silently excluded.
    • type provided but inaccessible403. Unknown type404. Accessible type → results filtered to it.
  • Status codes: empty q → 422; missing locale → 422; unknown type → 404; inaccessible type → 403; backend unhealthy → 503. limit is clamped to [1, max_limit]; offset ≥ 0.

Configuration (config/search.php)

Key Default Meaning
index content Meilisearch index name (one shared content index).
snippet_length 40 Highlighted-body crop length, in words.
default_limit 20 Page size when limit is omitted.
max_limit 50 Upper bound for limit.
types.<slug> Optional per-type field selection (see below).

By default every string/text schema field is indexed; the title is the title field, else the entry label, else the first indexed string field. Override per content type:

'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 (higher weight first). Unknown or non-string configured fields are skipped at runtime and reported by search:status.

Commands

php glueful search:reindex [--type=<slug>] [--locale=<code>]   # backfill the index from published content
php glueful search:status                                       # doctor: backend health + config warnings

Real-server smoke test: the unit suite fakes the Meilisearch seam, so Meilisearch's actual contract (document-id charset, filterable attributes, delete-by-filter) is only exercised by tests/Integration/Search/MeilisearchSmokeTest.php — run it against a live server with MEILISEARCH_SMOKE=1 vendor/bin/phpunit --filter MeilisearchSmokeTest before shipping index-shape changes.

No visibility drift: visibility is resolved from the live content-type store on every request (nothing visibility-related is denormalized into documents), so flipping a content type's public_delivery flag takes effect in search immediately — no reindex needed.

Lifecycle

Publish/unpublish/update/delete events flow through Thallo's existing ContentReindexer seam (identity-only). A per-locale event re-reads and upserts (or deletes that locale's doc); a whole-entry delete (locale = null) purges every locale doc. Reindexing runs in the pipeline's after-commit and is wrapped so a search-backend failure is logged, never breaking the publish — search:reindex recovers.

v1 scope

Content search only. Not in v1: collections-row search, an admin search UI, a Postgres FTS backend, and any search-permission migration.