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:
- the values of the attributes named by the content type's
searchableAttributes(), where the value is a string - 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
- In the admin panel, open Pages and, on the table screen, click New Page.
- In the create form, enter a title and set the slug to
search. - In the block builder, click Add block and choose Search.
- 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
LIKEis case-sensitive, and itsILIKEoperator does not exist on the other two databases.Mcg\FilamentCms\Support\LikeSearch::contains()handles the difference in one place by comparingLOWER(column)with a lower-cased term. Use it, instead of writingwhere('β¦', '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 for100%looks for the text100%.
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.
- In a terminal at the site's root, fill in the search text for every version that has none:
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.
- In the same terminal, after adding a block type or changing the text an existing block contributes, rebuild the search text of every version:
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.
- In a class of the application, implement the contract's one method:
public function search(
string $terms,
?string $siteId = null,
?string $locale = null,
int $limit = 20,
): Collection; // of Mcg\FilamentCms\Support\SearchResult
- In a service provider of the application, bind the class:
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:
qis not incms.cache.allowed_query_params(the shipped value is[]), soCachesCmsPagesdoes not cache a request that carries?q=.- The Search block's
isCacheable()returnsfalse.VersionCacheability::allows()returnsfalsefor any version that contains such a block, at any depth, soCachesCmsPagesrenders the page and marks the responseX-Cms-Cache: BYPASSwithout storing it. This applies to the search page without a query as well.