Extending
Everything Holmes does is behind a seam. Checks come from a registry you can add to, every stage raises an event, and the services are all public API. The namespace throughout is justinholtweb\holmes.
Writing your own check
A check answers one question about the index and returns findings. It may also know how to put its own findings right in bulk, which is what “Fix all” calls.
use justinholtweb\holmes\checks\BaseCheck;
use justinholtweb\holmes\checks\CheckContext;
use justinholtweb\holmes\models\Issue;
class LonelyProductsCheck extends BaseCheck
{
public static function handle(): string
{
return 'lonely-products';
}
public static function label(): string
{
return 'Products with no indexed variants';
}
public static function description(): string
{
return 'A product whose variants are missing from the index can’t be found by SKU.';
}
protected function defaultSeverity(): string
{
return Issue::SEVERITY_WARNING;
}
protected function defaultFix(): string
{
return Issue::FIX_REINDEX;
}
public function run(CheckContext $context): array
{
$issues = [];
foreach ($this->findThem($context) as $row) {
$issues[] = $this->issue([
'summary' => "Product #{$row['id']} has no variant keywords",
'elementId' => (int)$row['id'],
'elementType' => Product::class,
'siteId' => (int)$row['siteId'],
]);
if (count($issues) >= $context->limit()) {
break;
}
}
return $issues;
}
}
Register it — or take one of Holmes’ out entirely:
use justinholtweb\holmes\events\RegisterChecksEvent;
use justinholtweb\holmes\services\Checks;
use yii\base\Event;
Event::on(
Checks::class,
Checks::EVENT_REGISTER_CHECKS,
function(RegisterChecksEvent $event) {
$event->checks[] = new LonelyProductsCheck();
$event->checks = array_filter(
$event->checks,
fn($check) => $check::handle() !== 'truncated-keywords'
);
}
);
What CheckContext gives you
| Method | What it’s for |
|---|---|
siteIds() | The site IDs in scope — always concrete, never empty |
hasSiteFilter() | Whether the run is narrowed to particular sites |
elementTypes() | Element type classes in scope, or null for all |
indexQuery() | A searchindex query, already scoped to the run’s sites |
applyElementTypeFilter($query) | Applies the type scope to a query with elements joined |
liveElementCondition() | The conditions that make an element one Craft keeps indexed |
limit() | How many findings this check may record |
isDeep(), deepLimit(), batchSize() | Deep-pass budget |
siteName(), fieldName(), elementTypeLabel() | Memoized lookups for readable summaries |
progress($message, $fraction) | Reports progress to the console or the queue job |
rowsChecked, elementsChecked | Counters for the report header — add to them |
Rules worth following
Count everything, list some of it. Run a COUNT for the headline and a LIMITed query for the detail. A site with 400,000 orphaned rows wants one line saying so, not 400,000 Issue objects in memory. BaseCheck::overflowIssue() builds the “and N more” line.
Set isDeep() if you load elements. Deep checks are skipped in quick mode, which is what keeps a quick audit quick.
Rediscover the scope in fixAll(). Don’t work from the report — a report is a snapshot, and by the time someone clicks the button half of it may be stale. Every built-in check runs its own detection again as it repairs.
Use isAvailable() for version-dependent checks. The queue backlog check returns false on installs older than Craft 5.7, where the deferred index queue doesn’t exist.
Events
Adding to or filtering findings
Audits::EVENT_DEFINE_ISSUES fires after each check, with just that check’s findings. This is the hook for “yes, we know — that’s expected here”.
use justinholtweb\holmes\events\DefineIssuesEvent;
use justinholtweb\holmes\services\Audits;
Event::on(Audits::class, Audits::EVENT_DEFINE_ISSUES, function(DefineIssuesEvent $event) {
if ($event->check::handle() !== 'empty-keywords') {
return;
}
// Our imported archive entries are deliberately blank.
$event->issues = array_filter(
$event->issues,
fn($issue) => $issue->elementId < 100000
);
});
Around a whole audit
EVENT_BEFORE_AUDIT is cancellable and can amend the config before anything runs. EVENT_AFTER_AUDIT gets every finding, and can rewrite the lot.
Event::on(Audits::class, Audits::EVENT_BEFORE_AUDIT, function(AuditEvent $event) {
// Never audit the archive site.
$event->config->siteIds = [1, 2];
// Or refuse outright:
// $event->isValid = false;
});
Event::on(Audits::class, Audits::EVENT_AFTER_AUDIT, function(AuditEvent $event) {
$critical = array_filter($event->issues, fn($i) => $i->severity === 'critical');
if ($critical) {
MyAlerts::page('Search index: ' . count($critical) . ' critical findings');
}
});
Around a change to the index
Sync::EVENT_BEFORE_SYNC is cancellable, which is the seam for “nobody reindexes the whole site from the control panel on production”.
use justinholtweb\holmes\events\SyncEvent;
use justinholtweb\holmes\services\Sync;
Event::on(Sync::class, Sync::EVENT_BEFORE_SYNC, function(SyncEvent $event) {
if (
Craft::$app->env === 'production' &&
$event->operation === SyncEvent::OPERATION_REINDEX &&
count($event->elementIds) > 5000
) {
$event->isValid = false;
}
});
Event::on(Sync::class, Sync::EVENT_AFTER_SYNC, function(SyncEvent $event) {
Craft::info("Holmes {$event->operation}: {$event->affected} affected", 'audit-trail');
});
After a search console query
Event::on(Probe::class, Probe::EVENT_AFTER_PROBE, function(ProbeEvent $event) {
if ($event->result->elementCount === 0) {
MySearchLog::recordMiss($event->result->query);
}
});
Services
All reachable as Plugin::getInstance()->…, and all public API.
| Service | What it’s for |
|---|---|
index | Read-only questions about searchindex: stats(), coverage(), rowsForElement(), isIndexed(), queueStats() |
keywords | expectedFor($element), storedFor($id, $siteId), compare($element) — what Craft would write, against what’s there |
audits | run($config), getAll(), getById(), getIssues(), getLatest() |
checks | The check registry: all(), enabled($deep), get($handle) |
probe | run($query, $siteId, $elementType) — the search console |
sync | reindexElements(), reindexSource(), reindexAll(), purgeOrphans(), drainIndexQueue(), releaseStuckJobs() |
sources | Element types and their sources, as the control panel sees them |
Comparing one element yourself
$element = Entry::find()->id(123)->siteId(1)->one();
foreach (Plugin::getInstance()->keywords->compare($element) as $key => $difference) {
// $key is "attribute|fieldId" — "title|0", "field|17"
echo "$key is {$difference['status']}\n";
echo " should be: {$difference['expected']}\n";
echo " stored: {$difference['stored']}\n";
echo " missing: " . implode(' ', $difference['missing']) . "\n";
echo " extra: " . implode(' ', $difference['extra']) . "\n";
}
An empty array means the index is exactly right for that element.
How Holmes stays faithful to Craft
Worth knowing if you’re extending it, because the same rules apply to your code:
- Reindexing is always Craft’s. Holmes calls
indexElementAttributes()or pushes Craft’s ownUpdateSearchIndexjob. It never writes tosearchindexitself. - Recomputation mirrors
Search::indexElementAttributes()step for step — the same attribute list fromElementHelper::searchableAttributes(), the same grouping of field instances by field ID, the same normalization in the element’s own site language, and the same padding and truncation. - It fires
beforeIndexKeywordswhen recomputing, so handlers that rewrite or suppress keywords are honoured. Without that, Holmes would expect rows Craft was never going to write and report every one as drift. If you write such a handler, keep it free of side effects — Holmes will call it during audits.Keywords::$recomputingis true while it does, if you need to tell the difference. - Term strategy is read back from Craft, not reimplemented. To decide whether a term uses full-text or LIKE, Holmes has Craft build the clause and looks at the SQL. Craft’s stop-word list is private and could change; this can’t drift.