justinholtweb / craft-holmes
Investigate Craft's search index — compare it against the content it came from, search it directly, and put it right again.
Package info
github.com/justinholtweb/craft-holmes
Type:craft-plugin
pkg:composer/justinholtweb/craft-holmes
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
Requires (Dev)
- craftcms/ecs: dev-main
- craftcms/phpstan: dev-main
- phpunit/phpunit: ^10.5
README
Investigate Craft's search index — compare it against the content it came from, search it directly, and put it right again.
Craft's search index is a table nobody looks at. Content goes in when an element is saved, searches come out, and when the wrong things come out — or the right things don't — there's nothing to inspect. Holmes gives you somewhere to look.
What it does
Compares the index against its sources. Every element that exists, against every element the index knows about. Rows pointing at content that was deleted. Elements that were never indexed at all. Fields that were added after the content was last saved, so their content isn't searchable on older entries. Fields removed from a layout whose old keywords are still matching searches. And, in a deep pass, each element's keywords recomputed from the live content and diffed word by word against what's stored.
Searches the index directly. Type a query, and Holmes runs it through Craft's own search — then shows you how Craft parsed it, whether each term went through the full-text index or fell back to LIKE and why, the SQL it actually ran, every index row that matched, and the relevance score Craft gave each element. The answer to "why doesn't this entry come up?" is usually right there.
Puts it right. Reindex one element, one section, one site, or everything, through Craft's own indexer. Delete orphaned rows. Work off Craft's deferred index queue, including the jobs a dead worker left reserved that Craft will otherwise skip forever.
Requirements
Craft CMS 5.3.0 or later, PHP 8.2 or later. MySQL and PostgreSQL are both supported.
Installation
composer require justinholtweb/craft-holmes php craft plugin/install holmes
Using it
Holmes adds a Holmes item to the control panel:
- Overview — how big the index is, what it's made of, and how much of each element type is actually in it.
- Audits — run an audit and read the report.
- Search — the search console.
- Sync — reindexing and repairs.
- Settings — admin only.
Audits
A quick audit is set comparisons in SQL. It finds orphaned rows, unindexed elements, partly indexed elements, stale field rows, rows for attributes an element type no longer claims, elements with no keywords at all, rows that hit the storage ceiling, and Craft's own pending index backlog. It doesn't load a single element, and finishes in seconds on a large site.
A deep audit does all of that and then reads each element, works out the keywords Craft would write for it right now, and compares them with what's stored. It's the only way to catch content that changed without the index being told — an import that wrote to the database directly, a plugin update that changed how a field produces keywords, a queue that was failing quietly. It costs one element load apiece, so it runs on the queue and stops at a configurable number of elements per type, newest first.
Findings come with a severity and, where Holmes can do something about it, a fix:
| Severity | Means |
|---|---|
| Critical | Content that cannot be found, or index rows that will never be cleaned up |
| Warning | Something is out of step and searches are affected |
| Notice | Worth knowing, working as designed |
Search console
Craft's search syntax works as-is, because it is Craft doing the work:
baker street both words
"baker street" the phrase
-moriarty excluding this
baker* starting with
title:baker only in the title
title::baker the title is exactly this
For each term you get the strategy Craft will use and why. A term shorter than the database's minimum full-text word length falls back to LIKE; so does a stop word, a phrase, a leading wildcard, an exclusion, or an exact match. Holmes works this out by asking Craft to build the clause and reading what came back, rather than keeping its own copy of the rules.
Sync
Reindexing always goes through Craft's own indexer, so what lands in the table is exactly what a save would have written. Sources are Craft's own — the sections, volumes and groups from the element index sidebars — so element types from other plugins work without Holmes knowing anything about them.
Console commands
# what's in the index, and how much of the site it covers php craft holmes/index # run a query and show the working php craft holmes/index/search "baker street" --site=default php craft holmes/index/element 1234 # audits php craft holmes/audit php craft holmes/audit --mode=deep --deep-limit=0 php craft holmes/audit --checks=orphaned-rows,unindexed-elements php craft holmes/audit --fix php craft holmes/audit/checks php craft holmes/audit/show 12 # reindexing and repairs php craft holmes/sync/sources php craft holmes/sync/reindex --element-type='craft\elements\Entry' --source=section:<uid> php craft holmes/sync/all php craft holmes/sync/orphans php craft holmes/sync/queue
--fail-on=critical makes holmes/audit exit non-zero when it finds something, which is
what you want in CI after a deploy or a content migration:
php craft holmes/audit --mode=deep --fail-on=critical
In your templates
{% set rows = craft.holmes.rows(entry.id, currentSite.id) %}
{% for row in rows %}
{{ row.getLabel() }}: {{ row.getTrimmedKeywords() }}
{% endfor %}
{% if not craft.holmes.isIndexed(entry.id) %}
<p>This entry can't be found by search.</p>
{% endif %}
{% set result = craft.holmes.search('baker street') %}
{{ result.totalRows }} rows, {{ result.elementCount }} elements
craft.holmes also gives you stats(), coverage(), compare(element), queue() and
latestAudit().
Extending
Holmes has a check registry and events for every stage. Adding your own drift check is a class and an event handler. See docs/EXTENDING.md.
Notes
- Trashed elements keep their index rows. That's Craft's doing, so a restore brings the element back searchable. Holmes reports the count as a notice, not a fault.
- Nested elements — Matrix blocks and the like — usually index to empty rows of their own, because their content reaches search through the element that owns them. Holmes notes those rather than warning about them.
- A plugin can suppress index rows through Craft's
beforeIndexKeywordsevent, and some do. Holmes fires the same event when it recomputes, so it expects exactly the rows Craft would write. - Some element types return more than one instance per ID. Solspace Calendar expands a recurring event into one instance per occurrence, all sharing an element ID and a single index row. Holmes reports such a pair once, and only if no instance of it agrees with what's stored.
License
See LICENSE.md.