Skip to content
MCG Filament CMS Demo

Search

This document explains the site search built into the Filament CMS package: how the index is built, how to add a search page to a site, how queries are matched and ranked, reindexing after an upgrade, the limitations, how to replace the search backend, and how search pages are cached. It is written for the developer who adds search to a site and for the developer who replaces the backend.

Every block declares its own indexable text through Block::searchableText(). The method has been part of the block contract since v1, and every core block implements it. Before the search feature was added, no code read those values. The search feature is the code that reads them.

How the index is built

When a version is saved, VersionManager::searchTextFor() builds the version's search text from two sources and stores it in the version's search_text column:

  1. the values of the attributes named by the content type's searchableAttributes(), where the value is a string
  2. the searchableText() of every block in the version

"Every block" includes blocks nested inside containers: searchTextFor() walks the whole block tree with BlockTree::walk(), and a block inside a container is indexed in its own right. Hidden blocks contribute nothing, and hiding a container removes everything inside it from the index. searchTextFor() is one of the four places in the package that read the Hide field; see Block reference.

The search text is assembled before the version row exists and written in the same insert, so a version is never searchable without its indexed text. It is computed once and not updated afterwards. It is stored on the version and not on the page because a version is immutable: the indexed text cannot fall out of step with the blocks it was built from.

A search queries the published version of each page of a routable content type.

Adding a search page to a site

The Search block renders a search form and, when the request carries a ?q= query parameter, the results. The page that holds the block becomes the results page. The conventional path is /search.

Steps

  1. In the admin panel, open Pages and, on the table screen, click New Page.
  2. In the create form, enter a title and set the slug to search.
  3. In the block builder, click Add block and choose Search.
  4. In the page header, click Save & publish.

The block has three fields:

Field Default Validation
placeholder "Search this site" Optional, up to 120 characters
buttonlabel "Search" Optional, up to 60 characters
limit 20 Required, an integer from 1 to 100

All other presentation belongs to the site's theme. To change the markup, override the view by creating resources/views/vendor/cms/blocks/search.blade.php.

The form submits with a plain GET request. A results page can therefore be linked, bookmarked and shared, and the form works with JavaScript disabled.

The Search block's own searchableText() returns an empty string, so its button label is not indexed and every page carrying the block does not match a search for "search".

Matching

DatabaseSearch (src/Support/DatabaseSearch.php), the default backend, matches a query by these rules:

  • Every word must appear. The query is split on whitespace, and a page matches only when its search text contains every word. A two-word search returns pages containing both words, not either word. Matching on any word would return many weakly related pages, and readers look only at the first screen of results.
  • Matching is case-insensitive on all three supported databases (MySQL, PostgreSQL and SQLite). PostgreSQL's LIKE is case-sensitive, and its ILIKE operator does not exist on the other two databases. Mcg\FilamentCms\Support\LikeSearch::contains() handles the difference in one place by comparing LOWER(column) with a lower-cased term. Use it, instead of writing where('…', 'like', …) directly, in any query that must run on more than one database.
  • Words shorter than two characters are ignored. A one-character term matches nearly every page and costs a full scan.
  • % and _ in a query are matched literally. LikeSearch::escape() escapes them, so a search for 100% looks for the text 100%.

Ranking

Each result's score is the number of times each query word appears in the page's search text, plus ten points for each time a query word appears in the page's title. A search for "contact" therefore ranks the page titled Contact above a page that mentions the word once.

The database returns up to three times the requested limit of matching pages. DatabaseSearch scores those candidates in PHP, sorts them by score, and returns the requested number.

Reindexing after an upgrade

Versions written before the search feature existed have no search text, and no code rewrites them automatically, because versions are immutable.

  1. In a terminal at the site's root, fill in the search text for every version that has none:

Shell

php artisan cms:reindex

The command prints a progress bar and then "Reindexed N version(s).", or "Nothing to reindex." when every version already has search text.

  1. In the same terminal, after adding a block type or changing the text an existing block contributes, rebuild the search text of every version:

Shell

php artisan cms:reindex --all

Reindexing does not change any timestamp; the command saves with saveQuietly(). Rewriting the index is not an edit. A changed date would appear in the version history, and through the sitemap's lastmod value it would tell every crawler that the page had changed.

Limitations

Limitation Detail
Text inside a snippet is not searchable Search covers routable content types only, and a snippet has no URL, so it cannot be a result. Indexing a snippet's text into every page that embeds it would go out of date as soon as the snippet was edited, because versions are immutable and nothing rewrites the pages that embed a snippet. The package does not implement a partial solution that would return out-of-date results.
Matching uses LIKE, not a full-text index MySQL, PostgreSQL and SQLite each need a different full-text implementation, which would mean three migrations and three query builders. LIKE works on all three, returns correct results, and performs adequately for sites of a few hundred to a few thousand pages. It will not perform adequately at ten thousand pages.
No stemming, synonyms or fuzzy matching A search for "guitar" does not find "guitars".
No pagination The block shows the highest-scoring results, up to its limit.

Replacing the search backend

Mcg\FilamentCms\Contracts\SiteSearch is bound to DatabaseSearch as a singleton in FilamentCmsServiceProvider. Binding a different implementation puts Scout, Meilisearch, Algolia or Typesense behind the same Search block and the same views.

  1. In a class of the application, implement the contract's one method:

PHP

public function search(
    string $terms,
    ?string $siteId = null,
    ?string $locale = null,
    int $limit = 20,
): Collection; // of Mcg\FilamentCms\Support\SearchResult
  1. In a service provider of the application, bind the class:

PHP

use Mcg\FilamentCms\Contracts\SiteSearch;

$this->app->singleton(SiteSearch::class, ScoutSearch::class);

Each SearchResult carries the Content model, a title, a URL, a plain-text excerpt and an integer score. Return the collection already sorted; the views do not sort it again.

Return the excerpt and title as plain text. The view escapes each one with e() and then wraps matches of the query in highlight markup, in that order. The search results page displays text written by other people, and the view is the one place where that text is turned into markup. HTML returned in an excerpt is therefore displayed as escaped text, not rendered.

Caching

A page that carries the Search block is never stored in the shared page cache, with or without a query:

  1. q is not in cms.cache.allowed_query_params (the shipped value is []), so CachesCmsPages does not cache a request that carries ?q=.
  2. The Search block's isCacheable() returns false. VersionCacheability::allows() returns false for any version that contains such a block, at any depth, so CachesCmsPages renders the page and marks the response X-Cms-Cache: BYPASS without storing it. This applies to the search page without a query as well.