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

MethodWhat 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, elementsCheckedCounters 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.

ServiceWhat it’s for
indexRead-only questions about searchindex: stats(), coverage(), rowsForElement(), isIndexed(), queueStats()
keywordsexpectedFor($element), storedFor($id, $siteId), compare($element) — what Craft would write, against what’s there
auditsrun($config), getAll(), getById(), getIssues(), getLatest()
checksThe check registry: all(), enabled($deep), get($handle)
proberun($query, $siteId, $elementType) — the search console
syncreindexElements(), reindexSource(), reindexAll(), purgeOrphans(), drainIndexQueue(), releaseStuckJobs()
sourcesElement 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 own UpdateSearchIndex job. It never writes to searchindex itself.
  • Recomputation mirrors Search::indexElementAttributes() step for step — the same attribute list from ElementHelper::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 beforeIndexKeywords when 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::$recomputing is 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.