Faceted Search (Facets Index)
Functionality
Faceted search adds the ability to attach facets to your product catalog — the filterable, countable attributes (brand, color, size, price band, material, …) that power a "refine your results" sidebar. Where the master index indexes many entity types for general full-text discovery, the facets index is a separate, product-focused index built to return aggregated facet values alongside the search results.
The two indices are completely separate Elasticsearch indices. They are configured, reindexed, and queried independently.
A facets-index search returns three things in one response: the matching records, the total count, and a collection of facets — each facet carrying its values, the document count per value, and which values are currently selected.
Supported entities
The facets index supports a single entity:
- Product (
Products\Entities\ProductDescription)
Both plain product descriptions and variant descriptions are supported. Only published (and SEO-ready) descriptions are indexed. Whether products, variants, or both are indexed is controlled by the Indexing Scope setting.
Because the index is shaped around ProductDescription, the write side reads product / variant / language / category data straight off that entity. Therefore, for a single SKU product all its descriptions (all languages) will be indexed.
Core fields set
Every facets-index document carries this guaranteed set of core fields (declared in FacetsIndexField::CORE_FIELDS). They are populated automatically from the product description — you don't configure them:
- entityClass — for example,
Products\Entities\ProductDescription - id — the ProductDescription ID (also the Elasticsearch document
_id) - productId — always the parent Product ID (a variant resolves to its parent product)
- variantId — the ProductVariant ID, or
nullfor a plain product description. A non-null value is the single marker for "this document is a variant" — there is no separateisVariantfield. - name — the description name
- language.code — 2-char ISO language code (
en,fr,de). Each description is monolingual, so the same product appears once per language. -
language.locale — the language's lcid: effectively a lowercase BCP 47 tag —
en-au,fr-ca,de-de, or a barejafor a language with no regional variant. This is the field to filter on when you mean one specific language, becauselanguage.codeis not unique: the languages table carries 13 rows with codeen(English - Australia, English - Great Britain, English - United States, …) and 19 withes. On a site that supports two regional variants of the same language,language.code = "en"matches both documents of the same product — the product list itself stays unique (results are grouped per product), buttotalCountinflates and a page of 10 comes back with fewer than 10 products.language.locale = "en-au"matches exactly one.Both language fields are the only core keywords matched case-insensitively —
language.locale=fr-CA,fr-caandFR-CAall match the same documents, because BCP 47 language tags are case-insensitive by definition. Every other core field is matched exactly (name.keyword=Trail Runnerwill not matchtrail runner).The values you read back are always lowercase —
"code": "en","locale": "en-au", neveren-AU. That is how they are stored, not a side effect of the case-insensitive matching, so unlike a folded facet value you do not need Labels to present them. If you are emitting the locale into anhreflangattribute or a language switcher, lowercase is valid BCP 47 and needs no conversion. - fullPath — slugged relative URL, built from the primary-category hierarchy plus the product/variant slug
- categoryPath — the primary category breadcrumb, root-first. Each node carries
id(category description ID),categoryId(category ID),name, andfullPath(the category's URL, ready to use as a breadcrumb link). Only the primary category chain is indexed — a product's wider category membership and its tags are not core fields. Expose those as facets through the mapping template if you need them. -
price — the product's final selling price (the lower of its base and special price), with one field per currency the site prices in, keyed by the lowercased ISO 4217 code:
price.usd,price.eur,price.gbp. Amounts are in major units —49.99, not4999. A currency the product has no price for is absent from the document rather than present asnull, so a filter on it matches nothing — the right answer for "not sold in that currency"; a product with no pricing at all carries an emptypriceobject. For a variant the fallback is per currency: it uses its own pricing row where it has one and inherits the parent product's where it does not, so it can override USD while inheriting EUR.This is a catalog price: it precedes any cart, so it carries no tax and no discount. It is a plain number — filterable by exact value and by range, and sortable — but it is not a facet: no value buckets are aggregated for it, so it never appears in the response's
facetscollection.Why the currency is part of the field name. The obvious shape — a list of
{amount, currency}pairs — cannot be filtered correctly. Elasticsearch flattens an array of objects into parallel value lists and does not record which element each value came from, so a product priced USD 42.00 / EUR 60.00 would flatten toprice.amount: [42.0, 60.0]andprice.currency: ["eur", "usd"]and would match a search for "EUR under 45" — 42.00 satisfies the amount,eursatisfies the currency, and nothing connects them. The response would still show the correct pairs, so the fault would be invisible. Naming the currency removes the pairing altogether, andprice.euris then an ordinary number that filters and sorts with no special handling.Two operational notes. Adding a currency to the site's supported currencies changes the index mapping and needs a Rebuild — see Rebuild required. And a price change on its own does not reindex the product — see Indexing process.
- indexed — UTC datetime the document was indexed. Changes on every reindex.
- fields — an object holding facet value. This is where your configured facets live. You search/filter them with
fields.{identifier}(e.g.fields.brand), exactly like the master index'sfields.fieldName.
The core fields are filterable as well as returned — they are offered in a view's filter-field list next to your facets, addressed by their own names rather than a fields. prefix (language.locale, productId, categoryPath.categoryId, price.usd, indexed, …). Text-typed core fields (name, fullPath, entityClass, categoryPath.name, categoryPath.fullPath) are filtered through their exact-match .keyword subfield, e.g. name.keyword. The numeric and date core fields — id, productId, variantId, categoryPath.id, categoryPath.categoryId, price.{code} and indexed — additionally accept range filters (price.usd=<=100, indexed=>=now-1d).
Facets (facets index fields)
A facet is defined as a facets index field. Manage them in the admin at:
/cdna-admin/search/facets-index-fields
Each facet field has the following configuration:
- Identifier — a unique, slug-like key (no spaces). This is how the facet is addressed everywhere: it is stored under
fields.{identifier}, queried/filtered asfields.{identifier}, and returned in the facet'sfilterproperty. - Name — human-readable display name (e.g. "Brand Name").
- Type — the Elasticsearch field type:
- keyword — stored as-is, no analysis. The default and most common facet type.
- long — whole numbers (IDs, counts).
- double — floating-point numbers (e.g. price).
- date — date values.
- boolean —
true/false. - text — long strings for full-text search (not a typical facet — used when you want the value full-text searchable).
- Fulltext — when enabled, a
.textsubfield is created next to the field, so the same field (e.g. akeyword) can serve both as a filterable facet and be full-text searchable. (Follows the standard Elasticsearch.textsubfield convention.) Toggling this on a non-textfield changes the index mapping, so it flags Rebuild required (see below); on atextfield it has no effect (already full-text) and does not flag a rebuild. - Case Sensitive — this affects exact filtering only. The full-text search box is always case-insensitive — searching
XLorxlmatches the same documents, the same as on the master index — so this toggle is irrelevant fortextand Fulltext fields. By default keyword values are case-sensitive (?size=XLwill not matchxl). Turn this off to apply a case-insensitive normalizer (lowercase+asciifolding) so casing (and accents) no longer matter. (Applies tokeywordonly.) Two consequences: (1) Elasticsearch then returns facet values folded — an indexedXLcomes back asxl,Caféascafe— so use Labels to present them nicely; the hydrator folds your predefined values, label keys, and the request filter values the same way so they still line up. (2) Toggling this on a keyword field changes the index mapping, so it flags Rebuild required (see below). - Hidden — by default every facet is both filterable and displayed in results. Hide a facet to use it for filtering only, without rendering it as a visible facet.
- Limit — how many values the facet returns (the Elasticsearch
termsaggregationsize). Default is 10. - Values — a JSON array of predefined values. This list does not limit what gets indexed — any value your mapping template emits is indexed regardless. What it does is twofold: (1) it seeds those values into the returned facet even when the current search matched no documents for them (they appear with
count = 0), giving a stable, always-visible value list (e.g. all sizes S–XXL); and (2) any returned value that is in this list is flagged aspredefinedin the response, so the rendering layer can tell configured values apart from on-the-fly ones. - Labels — a JSON object mapping a raw value to a presentable label. For example
{"tbc": "To Be Confirmed"}displays the indexed valuetbcas "To Be Confirmed".
Case sensitivity of Values and Labels. Whether a returned value matches an entry in either list follows the facet's Case Sensitive setting. When the facet is case-sensitive, both lists are matched as-is — only an exact-case value is flagged
predefinedor relabelled. When the facet is case-insensitive, casing is ignored:xl,XL,xLandXlall match a single list entry, so each of them is flaggedpredefinedand each receives the label you configured (e.g. the label you set forXl).
- Order — how Elasticsearch sorts the facet's value buckets:
_count:desc(highest count first),_count:asc,_key:asc(A–Z / 0–9),_key:desc(Z–A / 9–0).- Output Order — a "post-return" sort applied to the values after they come back from Elasticsearch and before rendering. Same options as Order. This is what reorders predefined-but-unavailable values into place.
- Unit of Measure — any string, e.g.
mm,kg,in. Returned on the facet for display.- Properties — arbitrary JSON attached to the facet, exposed at query time on
$facet->properties. Use it freely for rendering hints — a template name, a color palette, group labels, etc. Example:{"template": "slider.html", "colors": {"primary": "red", "secondary": "green"}}Access it in templates via chained magic properties:
$facet->properties->template,$facet->properties->colors->primary. Missing keys (top-level or nested) safely resolve tonull, so you don't needisset()guards.
How a field's type maps to what you can do with it
A facets index field's type decides whether it becomes a filter/facet, a full-text field, or both — and whether it accepts range filters:
keyword→ a filter / facet field (fields.{identifier}): aggregated into facet values, filterable by exact value, and sortable.long/double/date→ filter / facet fields too (fields.{identifier}): these are exact (non-analyzed) types, so they aggregate and filter directly, no subfield needed — and they additionally accept range filters (fields.weight=>=1,<5). Note that a numeric facet still aggregates as atermslist of every distinct value; range facets — bucketing a number into bands with a count per band — are not available.boolean→ a filter / facet field (fields.{identifier}), filtered bytrue/false(see Boolean filter fields). No range.text→ a full-text search field (fields.{identifier}): matched byq/searchFields; not a filter or facet, because an analyzed field cannot back atermsaggregation. To filter on a textual value, declare it as akeywordinstead.- any non-
texttype + Fulltext → both: filterable/facetable asfields.{identifier}, and full-text searchable via the addedfields.{identifier}.textsubfield.
Every filter field — of any type, core fields included — is also sortable; see Searching.
This mirrors the master index's type→capability routing, with one deliberate difference in the subfield convention. The master index makes every text field dual-use automatically, adding an exact-match .keyword subfield (full-text on fields.{name}, filtering/sorting on fields.{name}.keyword). The facets index is the other way round: its filter/facet types are exact by default, and you opt in to full-text on one with the Fulltext flag, which adds a .text subfield (fields.{identifier}.text). In short — master appends .keyword for filtering; facets appends .text for full-text.
Facets mapping
"Facets mapping" is the process of telling the facets index what to put into the fields object for each product. It has two layers.
1. The Elasticsearch mapping
The facets index mapping is strict ("dynamic": "strict") — fields are never created automatically; an unmapped field throws. The mapping is built from:
- the core fields (above), plus
- one
doubleper supported currency underprice.{code}, plus - one entry per facets index field, appended under
fields.{identifier}using that field's type.
Type specifics:
- keyword fields get an
ignore_abovelimit, and — when Case Sensitive is off — acase_insensitivenormalizer. - fulltext-enabled fields gain a
.textsubfield (fields.{identifier}.text). - text fields are mapped as plain
text(no subfield — text is already full-text searchable).
2. The mapping template
The actual extraction is driven by a Smarty mapping template, configured per index at:
/cdna-admin/search/configure-index/facets
The template runs once per product description at index time. It receives two variables:
| Variable | What it is |
|---|---|
$productDescription |
The product description being indexed (the Frontend ProductDescription model surface — the same one your frontend product templates use). |
$index |
The facets data builder/collector. Use it to add values to the index or to skip the record. |
The collector exposes:
$index->set('identifier', value)— add a value (or array of values) to the named facet field. The value is coerced to the facet's configured type, sanitized (trimmed, tags stripped), and length-limited. Unknown facet identifiers, non-scalar values, empty values, and type mismatches are reported as messages and skipped. Elasticsearch treats a single value and an array identically, so you can pass either.$index->skip('reason')— exclude the current record from the index (it will not be indexed, and is removed if already present). There is no "include" counterpart — a record is indexable by default; a template can only narrow that down.
Example mapping template:
{$index->set('brand', $productDescription->getProduct()->getBrandName())}
{$index->set('color', $productDescription->getCustomFieldValue('color'))}
{$index->set('weight', $productDescription->getProduct()->getWeight())}
{* Skip discontinued products entirely *}
{if $productDescription->getProduct()->isDiscontinued()}
{$index->skip('Discontinued product')}
{/if}
The product's price does not need a facet — it is a core field, indexed automatically as
price.{code}for every supported currency. Define a price facet only if you want something the core field does not give you (a price band label such as "under $50", for instance).
Type coercion notes when calling set():
keyword/text— trimmed, HTML-stripped, and truncated to a max length (keyword to theignore_abovelimit; text to 2048 chars). Empty strings are skipped.long/double— coerced through a safe string first (so an empty string does not silently become0), then cast.boolean— parsed leniently:true/false,1/0,"on"/"off", etc. Unparseable values (e.g."banana") are skipped with a message.date— acceptsDateTimeobjects, numeric timestamps, and date strings.Y-m-dstrings are detected automatically; otherwise ISO-8601 (ATOM) is used. Predefined values should not be set for dates.
Indexing Scope
On the same configure page, Indexing Scope controls which records the facets index covers:
- Products only
- Variants only
- Products and variants (default)
A record outside the configured scope is not indexed.
Preview
The configure page has a Preview feature. Pick a product description, click Preview, and you'll see, for the unsaved template currently on screen:
- Extracted Data — the full document that would be written to Elasticsearch (core fields +
fields). - Rendered Template — the raw output the template engine produced (handy for
print_r()-style debugging). - Messages — per-facet success / warning / error messages from the data builder.
- Will be Indexed — Yes/No, reflecting the real index-time gates (publish/SEO policy + indexing scope + any template
skip()). - Basic info about the chosen record (description, product, variant, SKU, layout), each linking to its admin editor.
Important: saving the mapping template does not automatically reindex existing documents. Open the indices page and run Reindex when you're ready to apply the new template to the data.
Indexing process
Every time you CRUD a product description it is indexed into the facets index — subject to three gates, applied in order:
- Publish / SEO policy — only published, SEO-ready descriptions are indexed.
- Indexing scope — products only / variants only / both.
- Template rules — the mapping template may
skip()the record (and if the template crashes or produces no data, the record is excluded defensively).
A price change alone does not reindex. The facets index reacts to a product description being saved, and pricing lives on the product (or variant), not on the description. Editing a price on the product form, or writing one through the API, a hook or an import, therefore leaves the indexed price.{code} values as they were until the description is saved again or you run a Reindex — and until then a price filter or sort answers from the previously indexed amounts. Schedule a Reindex after bulk price updates.
You can reindex on demand from the indices page:
/cdna-admin/search/indices
From there you can Reindex (reindex all records batch by batch without dropping the index — unavailable records are removed when the run completes) or Rebuild (drop the index and its data, then reindex from scratch — data appears partially during the run). Both are available for the current site or for all sites.
"Rebuild required" — mapping-breaking facet field changes
Three facet-field changes invalidate the existing Elasticsearch mapping, so the index is flagged Rebuild required (a banner appears on the indices page; click Rebuild to recreate the mapping and reindex). They share one trait: a plain Reindex cannot apply them — only a Rebuild (which recreates the index from the current mapping) can.
- Changing a facet field's Type — rewrites the field definition outright.
- Toggling Case Sensitive on a keyword field — adds or removes the
case_insensitivenormalizer, which Elasticsearch refuses to change on an existing field. (On non-keyword types the toggle has no mapping effect, so it does not flag a rebuild.) - Toggling Fulltext on a non-text field — adds or removes the
.textsubfield. Elasticsearch will not back-fill a newly added subfield onto existing documents, and a Reindex does not re-apply the mapping, so the change takes effect only after a Rebuild. (On atextfield the flag is a no-op, so it does not flag a rebuild.)
A fourth change breaks the mapping the same way but is not a facet-field edit, so it does not raise the banner — run the Rebuild by hand:
- Adding a currency to the site's supported currencies (the
Products.supportedCurrenciessetting). Thepricecore field carries onedoubleper supported currency, so the currency list is part of the mapping. Until the index is rebuilt the newprice.{code}field does not exist — and because the mapping is strict, a Reindex does not quietly index the documents without it: every document carrying the new currency is rejected. Rebuild right after changing the currency list.
Notably — and unlike the master index — other changes to a facet field (adding, removing, renaming a field, editing its labels/values/order, etc.) do not trigger an automatic reindex. This is by design: facet data extraction is governed by the mapping template, not by the field row, and the template does not auto-re-run when a field row changes. After editing facet fields or the template, run a Reindex when you want the change reflected in the data.
A change to the core fields set itself arrives with a framework release rather than an admin edit, so it does not raise the banner. When release notes say the core fields changed, run a Rebuild — a Reindex does not re-apply the mapping, so a newly added core field would stay absent from the index. (The
pricecore field arrived this way: an index built before it needs one Rebuild beforeprice.{code}can be filtered or sorted.)
Views
As with the master index, a View specifies how you search the indexed data — page size, full-text settings, filter fields, sorting, highlighting — and which parameters are (un)changeable. The difference is the index a view targets.
Create a facets-index view at:
/cdna-admin/search/add-view/facets
(A master-index view is created at /cdna-admin/search/add-view/master.)
Everything described in the master-index documentation — pagination, full-text search, search fields, sort fields, highlight — applies to facets-index views too. The facets-specific behaviour lives in the filter fields, which are your facets:
For each filter field (facet) in the view you set:
- Renderable — when on, the facet is returned to the rendering layer (its values + counts appear in the response). When renderable but not changeable, the facet is still returned but marked disabled (
isDisabled() === true) so the UI can show it greyed-out. - Changeable — whether the value can be set/overridden via the request (e.g. URL query). When off, the view's configured value is always used.
- Ignore empty — when on (default), an empty filter value is dropped from the query, so a search form that submits every input whether or not the user filled it in behaves as expected. When off, an empty value is kept and matches no documents, so the search returns an empty result. It does not mean "match documents where this field is empty" — that cannot currently be expressed.
- Values — default filter values applied by the view. On a
long,doubleordatefield a default may be a range bound, written with the operator in front of the value —>=10,<100— because the view stores its values as plain strings, exactly the form a request uses. A view for a "clearance" page can pinprice.usdto<=20this way.
Only a facets-index view builds aggregations. For each renderable filter field, the view adds an Elasticsearch terms aggregation using that facet field's configured Limit (size) and Order.
The core fields appear in the same filter-field list. They carry no facet configuration of their own, so leave them not renderable and use them purely to scope a view — a language-specific view pins language.locale, a catalog page pins categoryPath.categoryId, a budget page pins price.usd to <=50.
Searching
Search a facets view exactly as you search a master view:
/search/{ViewSlug}?q=phrase— frontend- the same in the API
$_search->search('{ViewSlug}', $parameters)— from within a template
Use q for the full-text phrase. Use limit / page for pagination. Use searchFields, orderBy, highlightFields, minimumShouldMatch, tieBreaker, fuzzy exactly as documented for the master index — they are shared query mechanics.
q is optional. When you omit q, no full-text query is applied, but filters and aggregations still run — so a category or landing page can request just filtered results plus facet counts, e.g. /search/products?fields.brand=nike with no search phrase at all.
Sortable fields. orderBy accepts _score plus every filter field — the sort list is the filter list plus _score: every non-text facet and every core field, price.usd and indexed included. The View form's sort-field picker offers the same list. The direction follows the field after a colon; _score defaults to descending, every other field to ascending.
/search/products?q=shoes&orderBy=price.usd:asc
/search/products?categoryPath.categoryId=12&orderBy=price.eur:desc,name.keyword:asc
A product that is not priced in the sort currency has no price.{code} field and is listed last, whichever direction you sort in.
Filtering by facet values
Apply a facet filter by sending the facet's filter name (fields.{identifier}) as a query parameter. As with all filter fields, you can pass multiple values comma-separated or as an array, and the field must be a changeable filter field in the view:
/search/products?q=shoes&fields.brand=nike,adidas&fields.color=black
Array syntax (
fields.brand[]=nike&fields.brand[]=adidas) works too. Unknown or non-changeable fields are ignored.
Range filters. A long, double or date facet — and every numeric or date core field — can be filtered by a band instead of a list of values. Put a comparison operator in front of the value: > (greater than), >= (greater than or equal), < (less than), <= (less than or equal). A lower and an upper bound on the same field are joined with a comma; the operator may also be written as the array key ([gt], [gte], [lt], [lte]):
/search/products?fields.weight=>=1,<5 1 ≤ weight < 5
/search/products?fields.weight[gte]=1&fields.weight[lt]=5 the same, key spelling
/search/products?fields.released=>=2026-01-01 a date facet
/search/products?indexed=>=now-1d a date core field, Elasticsearch date math
An exact value and a bound on the same field intersect — fields.weight=>=1,<5,2.5 matches only products whose weight is exactly 2.5 — so a range can only ever narrow a list of values, never widen it. On a keyword facet an operator has no meaning: fields.brand=>nike filters by the literal value >nike. The complete rules — one bound per side, what an unreadable bound does, and what happens when the two spellings are mixed — are on the Query Parameter Syntax page.
Range filters and facet selection. A facet value is marked selected by matching the request's exact values against the aggregation buckets. A range bound is not a bucket key, so a range filter never marks any value as selected — the facet's buckets are narrowed by the range like every other filter, but selected stays false on all of them. If you render a numeric facet as a slider or a min/max pair, keep the chosen band in your own UI state (the URL, for example) rather than reading it back from the facet.
How filters combine: multiple values for the same field are OR-ed together (a single Elasticsearch terms query), while different fields are AND-ed. A range and exact values on the same field are also AND-ed. There is no cross-field OR — every filtered field must match. (minimumShouldMatch affects only the full-text multi_match, not filters.)
Filtering by core fields
Core fields are filtered the same way, under their own names — no fields. prefix:
/search/products?q=shoes&language.locale=fr-ca
/search/products?categoryPath.categoryId=12,15
The one to know is language.locale. Because each description is monolingual, a multi-language catalog holds one document per product per language, and an unfiltered search returns all of them. Pin language.locale (in the view, or per request) to the locale you are rendering, so one product contributes exactly one document to the counts:
/search/products?q=chaussures&language.locale=fr-ca
Filtering on language.code=fr instead is the coarser form: it matches every regional variant of French the site supports at once. That is what you want for "any French", and what you do not want when the site supports two variants of one language — see the language.locale note under Core fields set.
Casing does not matter on either language field — fr-CA and fr-ca are the same filter. Every other core field is case-sensitive, so name.keyword and the categoryPath keywords must match the stored casing exactly.
Filtering by price
Price is filtered per currency — the field you filter on is the currency you are rendering in — and almost always as a range:
/search/products?price.usd=<=100 at most USD 100
/search/products?price.usd=>=50,<=100 USD 50 to 100 inclusive
/search/products?price.eur[lt]=20 under EUR 20, key spelling
/search/products?q=running&price.usd=<80&orderBy=price.usd:asc
/search/products?price.usd=49.99 exactly USD 49.99 — rarely useful
Amounts are in major units with a dot as the decimal separator — 49.99, never 4999 and never 1,000 (the comma would split the value in two). A product that is not priced in the requested currency has no price.{code} field and is excluded by any filter on it. The price core field is indexed only in the currencies listed in the site's supported currencies; a currency you add later needs a Rebuild before it can be filtered.
Because the filter is a plain range on a core field, no facet buckets come back for price — the response's facets collection never contains it. Render the price control from your own state (the request's bounds) and, if you want counts per band, define a separate keyword facet whose mapping template writes a band label (under-50, 50-100, …).
The search response
A facets search returns a SearchResponse, serialised as:
{
"totalCount": 42,
"resultItems": [ ... ],
"facets": [ ... ]
}
(facets is always empty for a master-index search.)
Facet shape
Each entry in facets looks like:
{
"identifier": "brand",
"filter": "fields.brand",
"name": "Brand Name",
"type": "keyword",
"unit": null,
"disabled": false,
"properties": { "template": "checkboxes.html" },
"values": [ ... ],
"availableValues": [ ... ],
"predefinedValues": [ ... ]
}
- filter — the exact key to use in filter requests (
fields.brand). - disabled —
truewhen the facet is renderable but not changeable. - properties — your arbitrary JSON bag (see Properties above).
- values — the full value list: predefined values plus any values Elasticsearch returned for the current result. Predefined values the current search has narrowed out are included here with
count = 0. - availableValues — the subset of values the current result actually contains (
count > 0), preserving the configured output order. - predefinedValues — the subset that came from the facet's configured Values list.
In templates you'd typically use getAvailableValues() to render only the values that still have results, or getValues() to render everything (showing zero-count predefined values greyed out).
Facet value shape
Each value:
{
"value": "nike",
"label": "Nike",
"count": 12,
"selected": true,
"predefined": false,
"available": true
}
- value — the raw indexed value.
- label — the display label, with the facet's Labels override applied if one exists; otherwise the raw value.
- count — number of documents matching this value in the current result (
0for predefined values the current narrow eliminated). - selected — whether this value is currently active in the applied filters. Only an exact filter value selects; a range bound on the same facet does not (see Range filters and facet selection).
- predefined — whether the value came from the configured Values list (vs. being discovered in an Elasticsearch bucket).
- available —
truewhencount > 0.
A value can be both selected and unavailable (
count = 0) — that's the normal "the active filter has narrowed the result to zero" UX, where the filter pill stays visible.
ResultItem shape
Each entry in resultItems is the raw Elasticsearch hit: three wrapper keys plus the indexed document (_source) spread onto the same level.
{
"_id": "products.entities.productdescription.789",
"_score": 1.0,
"highlight": null,
"entityClass": "Products\\Entities\\ProductDescription",
"id": 789,
"productId": 42,
"variantId": null,
"name": "Trail Runner",
"language": { "code": "en", "locale": "en-au" },
"fullPath": "/shoes/trail-runner",
"categoryPath": [
{ "id": 5, "categoryId": 3, "name": "Shoes", "fullPath": "/shoes" }
],
"price": { "usd": 129.95, "eur": 119.0 },
"indexed": "2026-07-01T09:15:00+00:00",
"fields": {
"brand": "nike",
"color": ["black", "white"],
"weight": 0.85
}
}
- _id — the Elasticsearch document id: the lower-cased, dot-joined entity class plus the record id, e.g.
products.entities.productdescription.789. The numeric ProductDescription id itself is theidfield. - _score — the relevance score. With no
qphrase the request is filter-only and carries no meaningful text ranking; higher when aqphrase matched. - highlight — highlighted snippets for any
highlightFields(only meaningful on a Fulltext /textfacet);nullwhen nothing was highlighted. - everything else — the indexed document (
_source): the core fields (entityClass,id,productId,variantId,name,language.code,language.locale,fullPath,categoryPath,price,indexed) plus thefieldsobject holding your configured facet values.variantIdisnullfor a plain product description and set for a variant.priceholds one key per currency the product is priced in — a currency it is not sold in is simply missing, and a product with no pricing at all has"price": {}.
Reading a result item in a template
A result item is the same object as on the master index and exposes the same typed accessor methods — see ResultItem shape there for the full list (getText(), getTexts(), getInteger(), getFloat(), getDate(), getHighlightText(), getRecord(), …). Field arguments use dot notation and every reader returns null when the field is missing.
The calls you'll typically use on a facets result item:
| Call | Returns |
|---|---|
$item->getRecord() |
the full ProductDescription model behind the hit (null if it no longer exists) |
$item->getText('name') |
the description name |
$item->getInteger('productId') |
the parent Product id |
$item->getInteger('variantId') |
the variant id, or null for a plain product description |
$item->getText('language.locale') |
the document's locale, e.g. fr-ca — the value to echo into hreflang or a language switcher |
$item->getFloat('price.usd') |
the price in one currency, in major units — null when the product is not priced in it |
$item->getText('fields.brand') |
a keyword facet value |
$item->getFloat('fields.weight') |
a double facet value |
$item->getTexts('fields.color') |
a multi-valued facet as an array |
$item->getDate('indexed') |
when the document was indexed, in the site timezone |
{foreach $response->getResultItems() as $item}
{$price = $item->getFloat('price.usd')}
<a href="{$item->getText('fullPath')}">{$item->getText('name')}</a>
{if $price !== null}<span class="price">${$price|number_format:2}</span>{/if}
{/foreach}
Conclusion
The facets index complements the master index with a product-focused, aggregation-driven search. You define facets as facets index fields, decide what gets indexed into them through a per-product mapping template (with live preview and an indexing-scope switch), and search them through facets-index views that turn renderable filter fields into Elasticsearch aggregations. Numeric and date fields — the per-currency price core field above all — take range filters and sort as numbers. The response hands you fully-formed facet objects — values, counts, selection state, labels, units, and your own arbitrary rendering properties — ready to drive a refine-results UI.