Free Craft CMS 5 plugin

Take the Measure of Your Search Index

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.

Craft CMS 5.3+ · PHP 8.2+ · MySQL and PostgreSQL · no runtime dependencies

Holmes

Every Element, Against Every Row

An audit compares the index with the content behind it and tells you what doesn’t line up — with a severity, and, where Holmes can do something about it, a fix.

Audit #128

Deep pass · 3 sites · 214,880 rows checked · 1,500 elements recomputed · finished in 4m 12s

Needs attention
2 Critical
3 Warning
1 Notice
  • critical

    Unindexed elements

    412 entries in Blog have no index rows in English at all

    They cannot be found by any search, however they are spelled.

    Reindex
  • critical

    Orphaned rows

    1,204 index rows whose element no longer exists in that site

    Nothing will ever update or remove these on its own.

    Delete rows
  • warning

    Keyword drift

    Entry #8842 — Body keywords differ from the live content

    14 words missing, 3 words the content no longer has.

    Reindex
  • warning

    Partly indexed elements

    96 entries missing a row for Ingredients, added to the layout in March

    That field’s content cannot be searched on those elements.

    Reindex
  • warning

    Stale field rows

    38 rows for Old subtitle, a field no longer in the layout

    Content taken out of a field can still be found by searching for it.

    Delete rows
  • notice

    Empty keywords

    2,317 nested elements index to empty rows

    Working as designed — their content reaches search through the element that owns them.

    No action
Findings are capped per check — a site with 400,000 orphaned rows wants one line saying so, not 400,000 of them. The count is what you act on.

A quick audit is set comparisons in SQL. It doesn’t load a single element and finishes in seconds on a large site. A deep audit also recomputes each element’s keywords and diffs them — the only way to catch content that changed without the index being told.

Why Doesn’t This Entry Come Up?

Type a query and Holmes runs it through Craft’s own search — then shows how Craft parsed it, what each term will do, the SQL it ran, and the score every element got.

"baker street" holmes -moriarty the inv* title::study
Site: English

6 terms · 4 elements · 7 rows matched · 11 ms

How Craft will run each term

Term Strategy Why
"baker street" LIKE A phrase — full-text can’t hold word order
holmes Full-text Long enough, and not a stop word
-moriarty LIKE An exclusion
the LIKE A stop word in this database’s list
inv* LIKE Below the 4-character minimum word length
title::study LIKE An exact match on one attribute

The SQL Craft ran

SELECT elementId, MAX(score) AS score
FROM searchindex
WHERE siteId = 1
  AND (MATCH(keywords) AGAINST('holmes' IN BOOLEAN MODE)
       OR keywords LIKE '% baker street %'
       OR keywords LIKE '% the %'
       OR keywords LIKE '% inv%')
  AND keywords NOT LIKE '% moriarty %'
GROUP BY elementId

Rows that matched, and the score each element got

Element Type Row Score
#8842 A Study in Scarlet Entry title 9.42
#8842 A Study in Scarlet Entry field:body 9.42
#9117 221B Baker Street Entry title 6.08
#4410 Baker Street, revisited Entry field:summary 2.71

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

Word by Word

Open any element and Holmes recomputes the keywords Craft would write for it right now, then diffs them against what’s actually stored.

A Study in Scarlet

Entry #8842 · Blog · English · 3 index rows

2 rows disagree
title Matches

a study in scarlet

field:17 Body Drift 14 missing · 3 extra

Stored

there is a scarlet thread of murder running through the colourless skein of life and our duty is to unravel it and isolate it and expose every inch of it

What Craft would write now

there is a scarlet thread of murder running through the colorless skein of life and our duty is to unravel it and isolate it and expose every inch the study was revised in march and nobody told the index

field:41 Ingredients No row

The field is in this element’s layout and is searchable, but there is no index row for it — it was added after this entry was last saved. Nothing in that field can be searched on this entry.

Missing

In the live content, not in the index

Extra

In the index, not in the content any more

Agrees

Stored exactly as Craft would write it

Three Things, Done Properly

Everything Holmes does is against one table — searchindex — and always through Craft’s own machinery.

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 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.

Recomputes Keywords and Diffs Them

A deep pass 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.

Searches the Index, and Shows the Working

Craft’s search syntax, run through Craft’s search. You get the parsed terms, whether each one uses the full-text index or falls back to LIKE and why, the SQL that actually ran, every row that matched, and the relevance score each element was given.

Reindexes Through Craft, Never Around It

Holmes never writes to searchindex itself. It calls Craft’s indexer or pushes Craft’s own job, so what lands in the table is exactly what a save would have written. Reindex one element, one section, one site, or everything.

Sources Are Craft’s Own

The sections, volumes and groups you reindex by come from the element index sidebars — the same list the control panel uses. Element types from other plugins work without Holmes knowing anything about them.

Unsticks the Deferred Index Queue

Craft defers some reindexing to a queue. When a worker dies holding a job, its claim on those rows is permanent and Craft will skip them forever, with no UI to clear it. Holmes releases them before draining the queue.

Runs in CI

holmes/audit --fail-on=critical exits non-zero when it finds something, which is what you want after a deploy or a content migration. Every screen has a console equivalent.

Open at Every Seam

A check registry you can add your own checks to, and events around every stage — before and after an audit, after each check’s findings, before and after any change to the index, and after a search console query. The services are all public API.

Ten Checks

Each one answers a single question about the index. Nine of them are pure SQL; one loads elements, and only runs in a deep pass.

Unindexed elements

unindexed-elements

Elements that exist in a site but have no search index rows there at all. They cannot be found by any search, however they are spelled.

Orphaned rows

orphaned-rows

Index rows whose element no longer exists in that site. Nothing will ever update or remove these on its own, and they make every search do more work than it needs to.

Keyword drift

Deep only
keyword-drift

Recomputes each element’s keywords and compares them with what is stored. Catches content that changed without the index being told — the one thing set comparisons cannot see.

Partly indexed elements

missing-field-rows

Elements missing an index row for a searchable field in their layout — usually a field added after the content was last saved. That field’s content cannot be searched on those elements.

Stale field rows

stale-field-rows

Index rows for fields that were deleted, made non-searchable, or removed from the element’s layout. Craft leaves these behind, so content you have taken out of a field can still be found by searching for it.

Empty keywords

empty-keywords

Elements whose index rows all exist but are empty. They look indexed, and are impossible to find. Nested elements are only noted, since their content is normally searchable through the element that owns them.

Revision rows

revision-rows

Index rows belonging to element revisions. Craft never indexes revisions, so these are always leftovers — and they can put an old version of an entry into search results.

Unknown attribute rows

unknown-attribute-rows

Index rows for element attributes the element type no longer says are searchable — left over from an older Craft, an uninstalled plugin, or a changed element type.

Truncated keywords

truncated-keywords

Index rows at the storage ceiling, where Craft has cut the keywords off. Everything past the cut is unsearchable, silently.

Pending index queue

queue-backlog

Elements Craft has queued for reindexing but not yet processed. A backlog means the index is behind the content right now; reserved rows mean a queue worker died holding the job.

Severity depends on what a check finds, not on the check itself. Full detail in the checks reference.

Two Commands

No configuration required. Every default is the safe one.

composer require justinholtweb/craft-holmes
php craft plugin/install holmes

Then, if you’d rather not leave the terminal

# 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

# audit, and fix what can be fixed
php craft holmes/audit --mode=deep
php craft holmes/audit --fix

# non-zero exit when it finds something — for CI after a deploy
php craft holmes/audit --mode=deep --fail-on=critical

Full command reference in the docs.

Frequently Asked Questions

Common questions about Holmes.

Nothing. Holmes is free, with no paid edition and no feature held back.

A quick audit is read-only — set comparisons in SQL, no elements loaded, no writes. A deep audit loads elements to recompute their keywords, which costs one element load apiece, so it runs on the queue and stops at a configurable number per element type. Nothing changes the index until you ask it to.

Quick is all the set comparisons: orphaned rows, unindexed elements, partly indexed elements, stale field rows, rows for attributes an element type no longer claims, empty keywords, rows that hit the storage ceiling, and Craft’s pending index backlog. It finishes in seconds on a large site. Deep does all of that and then recomputes each element’s keywords and diffs them against what’s stored — the only way to catch content that changed without the index being told.

Never. Reindexing calls Craft’s own indexer or pushes Craft’s UpdateSearchIndex job, so what lands in the table is exactly what a save would have written. The only rows Holmes deletes directly are ones whose element no longer exists.

Yes. Sources come from Craft’s own element index sidebars, and recomputation goes through the same searchable-attributes and field-layout machinery Craft uses, so an element type from another plugin works without Holmes knowing anything about it.

No. A plugin can drop index rows through Craft’s beforeIndexKeywords event, and some do — Formie drops empty ones. Holmes fires the same event when it recomputes, so it expects exactly the rows Craft would write. If you write such a handler, keep it free of side effects: Holmes will call it during audits.

Because Craft keeps their index rows on purpose, so that restoring an element brings it back searchable. Holmes reports the count as a notice rather than a fault.

That’s normal. Nested elements 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, so a real site’s report doesn’t drown in them.

Yes — a check is one class and one event handler. There are also events before and after an audit, after each check’s findings, before and after any change to the index, and after a search console query. The before-audit and before-sync events are cancellable.

Craft CMS 5.3 or later and PHP 8.2 or later. MySQL and PostgreSQL are both supported. There are no runtime dependencies beyond Craft itself.

Find Out What’s Actually in There

Holmes is free, and installs in two commands.

Craft CMS 5.3+ · PHP 8.2+ · MySQL and PostgreSQL