justinholtweb / craft-caffeine
Instant faceted search and filtering for Craft — model an index in the control panel, and Caffeine keeps a self-contained JSON search artifact up to date and served, so refinements resolve in milliseconds without an element query.
Package info
github.com/justinholtweb/craft-caffeine
Type:craft-plugin
pkg:composer/justinholtweb/craft-caffeine
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- craftcms/cms: ^5.3.0
Requires (Dev)
- craftcms/ecs: dev-main
- craftcms/phpstan: dev-main
- pestphp/pest: ^3.5
This package is auto-updated.
Last update: 2026-08-14 21:06:47 UTC
README
Instant faceted search and filtering for Craft CMS.
Model an index in the control panel. Caffeine keeps a self-contained search artifact up to date and served, and refinements resolve against it — never against the database.
{% caffeine 'products' as search %}
{{ search.searchBox() }}
{{ search.refinementList('brand') }}
{{ search.rangeInput('price') }}
{% caffeineresults %}
{% for hit in search.hits %}
{% include '_cards/product' with { hit: hit } only %}
{% endfor %}
{{ search.pagination() }}
{% endcaffeineresults %}
{% endcaffeine %}
The problem
A faceted listing needs a count next to every value — "Acme (12)", "Globex (4)" — and those counts depend on every other refinement. Done with element queries, that is one query per facet value per interaction, and it is why filtered listings built on Sprig feel slow at exactly the moment the visitor is exploring fastest.
Caffeine moves the work to index time. A background build maps your content into records, compiles them into one artifact, and publishes it. From then on a refinement is set arithmetic over postings lists — on the server for the first paint, in the browser for everything after.
What makes it work
The full-text index is built in PHP and ships inside the artifact. Tokenising happens once, on the server, so the browser needs no search library at all — just a binary search over a sorted token array. This is the load-bearing idea.
One spec, two engines, one fixture suite. docs/QUERY_SPEC.md is authoritative;
src/search/Engine.php and src/web/assets/runtime/src/engine.js both implement it, and
tests/Conformance/ runs against both. The server renders the first paint and the browser takes
over — any disagreement would show up as the page rearranging itself under the visitor, so the
five pieces of logic that exist in both languages are pinned against each other by fixtures: the
tokeniser, the varint codec, the facet-value projection, the URL codec and the haversine.
Everything works without JavaScript. Every facet is a real <a href>, every widget a real
form. The runtime intercepts controls that already work, which is why it is a few kilobytes and
why turning it off degrades to page loads rather than to nothing.
It survives a full-page cache. Cached HTML embeds a stable pointer, never a versioned URL. The payload it names is content-addressed and immutable, so it can be cached forever; the pointer is a few hundred bytes and is the only thing that must not be. A page cached months ago still finds today's index.
Measured
A synthetic catalogue: 200 brands, 12 colours, three tags of fifty per record, a numeric price, a
boolean, a four-level hierarchy, near-unique titles, and a four-field payload. Reproduce with
tests/bench/artifact-size.php and tests/bench/query-latency.php; hardware is a 2023 Apple
Silicon laptop running PHP 8.2 and Node 24 in Docker, so treat these as shape, not as guarantees.
Build and size
| Records | Build | Artifact (gzipped) | vs plain JSON |
|---|---|---|---|
| 1,000 | 13 ms | 56.9 KB | −14% |
| 10,000 | 183 ms | 535.4 KB | −27% |
| 100,000 | 2.9 s | 5.21 MB | −31% |
Integer lists are stored as base64 delta varints rather than JSON arrays — gzip cannot recover
that difference on its own, because it cannot see that [1,2,3] is three small numbers rather
than seven characters.
Query latency
Median, over the same artifact, for six representative queries.
| 1,000 | 10,000 | 100,000 | |
|---|---|---|---|
| Unrefined listing (PHP / JS) | 1.4 / 0.7 ms | 8.1 / 3.5 ms | 128 / 33 ms |
| One refinement | 0.4 / 0.2 ms | 2.3 / 1.8 ms | 38 / 18 ms |
| Two facets and a range | 0.1 / 0.1 ms | 0.8 / 1.1 ms | 8 / 14 ms |
| Text query | 0.2 / 0.1 ms | 1.4 / 1.1 ms | 18 / 14 ms |
| Sorted, page 21 | 1.3 / 0.5 ms | 9.6 / 3.7 ms | 133 / 40 ms |
| Browser decode, once on load | 5 ms | 23 ms | 165 ms |
How to read this. Narrowing is nearly free — the more refined the query, the less work either engine does. The expensive case is the unrefined listing, because it touches every record.
Which is what sets the transport budget: the client transport is comfortable to roughly 50,000
records, where the artifact is a few hundred kilobytes and decode is under 100 ms. Past that the
artifact is megabytes and the browser is doing too much work on load, so use the htmx transport
and let the server answer. At 100,000 records a server-side query is 8–130 ms — fine behind a page
cache, and the honest ceiling for this design without the memory-mapped endpoint transport.
Installation
composer require justinholtweb/craft-caffeine php craft plugin/install caffeine
Requires Craft 5.3+ and PHP 8.2+.
What it can index
Entries, categories, tags, assets, users and Commerce products, plus anything a plugin registers. Field types that would otherwise index as an opaque object — links from Hyper, FreeLink or Craft's own Link field, Google Maps addresses, Money, colours, dropdowns — are read by value extractors that match on shape rather than class name, so a link field is a link field whatever produced it. Facets can be strings, hierarchies, numbers, booleans, dates or coordinates, the last filtered by distance from a point. Indexes can declare stopwords and synonyms.
Then define an index in Caffeine → Indexes, and:
php craft caffeine/index/build --all # map content into records php craft caffeine/artifact/publish # compile and publish the artifact
After that it maintains itself: element saves mark the affected records stale and a debounced
queue job rebuilds and republishes. A resave/entries over 5,000 entries collapses to one job.
Console commands
caffeine/index/status |
Record counts, and what is stale. |
caffeine/index/build [handle] [--all] |
Map content into records. |
caffeine/index/preview <handle> <id> |
What Caffeine would build for one element. |
caffeine/artifact/publish [handle] [--force] |
Compile and publish. |
caffeine/artifact/status |
What is live, and whether it is behind the CMS. |
caffeine/artifact/verify <handle> |
Check the published artifact against a fresh compile. |
caffeine/artifact/versions <handle> |
Recent versions. |
caffeine/artifact/prune [handle] |
Retire old versions. |
Transports
Set per index.
htmx (default) |
A refinement fetches a fragment; hit markup stays in Twig. HTMX itself is optional — the bundled runtime does its own fetch and swap. |
client |
The browser fetches the artifact once and answers everything locally. Zero round-trips. |
algolia-json |
Serves the Algolia response shape, so InstantSearch, React/Vue InstantSearch and Autocomplete.js work unmodified. |
The wire format is the Algolia search response throughout, which also means a site can graduate to Typesense, Meilisearch or Algolia later without rewriting its front end.
Documentation
docs/TWIG.md |
Tags, widgets, URLs, transports, cached pages. |
docs/EXTENDING.md |
Add a field type, an element type, or your own markup. |
docs/MIGRATING-FROM-SPRIG.md |
A straight translation, with the trade stated plainly. |
docs/CACHING.md |
Serving artifacts, and the Blitz recipe. |
docs/QUERY_SPEC.md |
What a query means. Authoritative for both engines. |
docs/ARTIFACT.md |
The wire format and how publishing works. |
Editions
Lite is free: one index, entries only. Pro is $149, $119 to renew: unlimited indexes, every element type, all three transports.
License
This plugin is released under the Craft License.