Facets
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 language code. Each description is monolingual, so the same product appears once per language.
- 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. - 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.
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:
keyword→ a filter / facet field (fields.{identifier}): aggregated into facet values, filterable, and sortable.long/double/date/boolean→ filter / facet fields too (fields.{identifier}): these are exact (non-analyzed) types, so they aggregate and filter directly, no subfield needed. (Sorting in a View is currently offered forkeywordonly.)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.
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 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('price', $productDescription->getProduct()->getPrice())}
{* Skip discontinued products entirely *}
{if $productDescription->getProduct()->isDiscontinued()}
{$index->skip('Discontinued product')}
{/if}
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).
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.)
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.
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. When off, an empty filter means "match documents where this field is empty".
- Values — default filter values applied by the view.
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.
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 can target _score plus the keyword facet fields. Other facet types (which are filterable but not sortable) and the core fields are not sortable in a facets view. (_score defaults to descending; every other field defaults to ascending.)
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.
How filters combine: multiple values for the same field are OR-ed together (a single Elasticsearch terms query), while different fields are AND-ed. There is no cross-field OR — every filtered field must match. (minimumShouldMatch affects only the full-text multi_match, not filters.)
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.
- 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.
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. 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.