Extending

Three extension points: teach it a field type, give it a new element type, or replace its markup. All three are events or files.

Value extractors

The problem they solve: a field whose value is an object indexes as whatever __toString() happens to give. Often that is the class name, sometimes an empty string, and the facet built from it is useless in a way that is hard to diagnose from the outside.

An extractor turns that object into a primary — the scalar the value stands for, used by facets and sorting — and named parts, which a dotted path can reach.

The built-ins match on shape, not class name, deliberately: half the field types worth handling belong to plugins Caffeine cannot depend on, and a link field is a link field whether it came from Hyper, FreeLink, Craft’s own Link field or something written last week.

ExtractorMatchesPrimaryParts
CollectiongetAll() / all()the list
Addresshas lat and lngformatted addresslat, lng, city, state, zip, country
MoneygetAmount() + getCurrency()amount in major unitsamount, minor, currency
ColourgetHex() + getRgb()the hexhex, rgb
Optionvalue + label + selectedthe stored valuevalue, label
LinkgetUrl()the URLurl, text, type

Order is fixed: collections unwrap first so everything after sees a single item, and the link extractor runs last because it matches on little more than “has a getUrl()”, which is true of more things than are actually links.

Money divides out the currency’s subunit scale, because money is stored in minor units and a price facet that is wrong by two orders of magnitude still looks plausible.

Writing one

use justinholtweb\caffeine\extractors\ExtractedValue;
use justinholtweb\caffeine\extractors\ValueExtractorInterface;

class RatingExtractor implements ValueExtractorInterface
{
    public static function supports(object $value): bool
    {
        // Never assume the class exists — this runs on every site.
        return $value instanceof \acme\ratings\models\Rating;
    }

    public function extract(object $value): ?ExtractedValue
    {
        return new ExtractedValue($value->stars, [
            'stars' => $value->stars,
            'count' => $value->reviewCount,
        ]);
    }
}
Event::on(Extractors::class, Extractors::EVENT_REGISTER_EXTRACTORS,
    function(RegisterExtractorsEvent $event) {
        $event->extractors[] = RatingExtractor::class;
    });

Yours are checked before the built-ins, so this is also how you override the way one of them reads your field. Return null to decline a value you turn out not to understand — the next extractor gets a turn. An extractor that throws is skipped and logged rather than failing the build.

Sources

A source supplies the elements an index is built from. Six ship with the plugin, and a third-party one is configured in the control panel exactly like they are — implement handle(), displayName(), elementType(), query() and covers(), then register it on Sources::EVENT_REGISTER_SOURCES.

Three things are easy to get wrong:

covers() asks a different question from query(). The build re-loads changed elements without the status filter, precisely so it can notice one that has just stopped qualifying and remove it. covers() is that check, per element. An index whose source answers it carelessly grows and never shrinks.

Override statusFor() if your element type has no live status. live is an entry concept. Asking a category, asset or user query for it returns nothing at all rather than erroring — an empty index with no explanation.

Anything beyond entries is Pro. The registry handles that for you, so your source is available exactly when the licence allows it.

Markup

Put a template of the same name under _caffeine/ in your own templates directory. See Widgets.

If you are changing the query engine

Read the query specification first, and change it before the code. There are two engines and they must agree exactly: the server renders the first paint and the browser takes over, so any disagreement shows up as the page rearranging itself under the visitor.

Five pieces of logic exist in both languages, each pinned by fixtures: the tokeniser, the varint codec, the facet-value projection, the URL codec and the haversine. composer conformance runs both halves — the PHP pass compiles the artifacts the JavaScript pass consumes, so the order matters.