Search
This page 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.
Mcg\FilamentCms\Contracts\SiteSearch is bound to DatabaseSearch, which runs LIKE queries against the search_text column written onto each version when it is saved. It works on SQLite, PostgreSQL and MySQL, and performs adequately for sites of up to a few thousand pages.
How it works
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
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 stored on the version because a version is immutable, so the indexed text cannot fall out of step with the blocks it was built from.
- Blocks inside containers are indexed.
searchTextFor()walks the whole block tree, and a block inside a container is indexed in its own right. - Hidden blocks contribute nothing. 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. - Only published versions are searched. A query runs against the published version of each page of a routable content type.
Putting a search box on the site
- 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. The conventional path is/search. - In the block builder, click Add block and choose Search.
- In the page header, click Save & publish.
The page that holds the Search block becomes the results page. It renders a search form and, when the request carries a ?q= query parameter, the results.
The block has three fields: placeholder (default "Search this site", up to 120 characters), button label (default "Search", up to 60 characters) and limit (default 20, an integer from 1 to 100). 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.
Matching
- 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 is case-insensitive on all three supported databases.
- 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. 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.
After upgrading: reindex
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. The command prints a progress bar and then "Reindexed N version(s).", or "Nothing to reindex." when every version already has search text.
php artisan cms:reindex
In the same terminal, after adding a block type or changing the text an existing block type contributes, rebuild the search text of every version:
php artisan cms:reindex --all
Reindexing does not change any timestamp; the command saves with saveQuietly(). 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.
Known limits
- 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.
- 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.LIKEworks on all three and performs adequately for 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.
Swapping in something bigger
Mcg\FilamentCms\Contracts\SiteSearch is bound to DatabaseSearch as a singleton in FilamentCmsServiceProvider. To put Scout, Meilisearch, Algolia or Typesense behind the same Search block and the same views, bind a different implementation in a service provider of the application:
$this->app->singleton(\Mcg\FilamentCms\Contracts\SiteSearch::class, ScoutSearch::class);
The implementation provides 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
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. HTML returned in an excerpt is 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.