Guide 11 · Developer docs

Search, filtering and faceted navigation

Site search on Core dna is an Elasticsearch index that the platform fills and queries, and a page you build. What reaches the index, search views, the search page, product facets, filtered listings that need no index, and when to go beyond the built-in index.

L2
Unit 04 · Listings without the index

Filtering a listing from the query string

Many listings need filtering but not the search index. How a module's listing route filters its records from the URL, the operators and the reserved parameters, the tag and category routes that do it better, and what to get right in pagination and the page cache.

Who this is for. The theme developer adding filters to a blog, a product category, an FAQ or a custom entity listing. Afterwards you can filter a listing from the URL or from a route, page it and keep it cacheable, all without the search index.

Filtering from the query string

Not every filter needs the index. Every module's listing route, such as a blog's posts, a product category's products, an FAQ page or a custom entity's listing, applies the query parameters of the URL to its own records directly. Nothing is indexed, so the list is always current. A parameter is a field name, with an optional operator in front of it:

ParameterMeans
field=valueEqual to the value
!field=valueNot equal
%25field=valueContains the value. The operator is %, written %25 in a URL
%3Efield=2026-01-01, %3Cfield=…Greater or less than: > and <, encoded. A date field's value is read as a date
%7Cfield=a,bAny of the values (|)
%26tags.slug=coffee,giftAll of the values, on a relation that holds several, such as tags. The operator is &, which must be written %26 or it separates parameters
field=NULLHas no value

The field is named as the record type's API names it, and a dotted path reaches a relation: categories.slug, tags.name, and for a blog post's title, which belongs to its revision, activeRevision.name. On this site's how-to library, /how-to?%25activeRevision.name=facet lists the one post whose title contains "facet". PHP turns the dots in a parameter name into underscores, and the platform turns every underscore back into a dot, so a field whose own name has an underscore cannot be reached.

A parameter that names no filterable field, or names a relation that holds several records rather than a field of it (tags=news rather than tags.slug=news), is ignored, not refused, so a typo widens the list instead of breaking it. Check a filter by its effect, not by the 200.

The reserved parameters

page, limit, offset, orderBy, properties, extract and retrieveUnpublished never become filters:

  • limit sets the page size, overriding the listing's own (its module setting, or 15). It is taken as given: ?limit=0, a negative number or a word is an error page with HTTP 500, as /how-to?limit=0 is on this site, and a very large number returns everything at once. Never build it from input you do not control.
  • page picks the page, in the path (/blog/2) or as ?page=2. A page past the last one is a 404.
  • orderBy sorts, as field direction with a space, several separated by commas: ?orderBy=created asc, ?orderBy=tags.name asc,created desc. It is not the search view's colon syntax: ?orderBy=created:asc is not understood, and the listing falls back to its default order without a word. On this site /how-to?orderBy=created asc lists the oldest how-to first, and created:asc leaves the newest first. A field the record does not have is dropped the same way.

Routes that filter better: tags and categories

A listing's tag and category routes filter in the path instead, and are the better form for anything a visitor clicks: /news/tags/summer for a blog, /stores/tags/open-sunday for a custom entity listing at /stores. Several tags can be combined in the one path segment, with a different syntax by module:

  • Blog, FAQ and product listings: + or & between tags requires all of them, a comma or | any of them. Mixing the two kinds is a 400.
  • Custom entity listings: only & (all) and | (any); a + or a comma is a 404.

Each combination is its own URL, so it is cached like any page and paginates in the path, and a blog's tag route answers 404 when no post has the tag. On a blog, FAQ or product tag route the template receives tags, the tags matched, and for a single tag also tag. The template only offers the links, marks the active one and offers a way back; for a blog, getTagPostsUrl() builds the route (Add a filterable tag facet to a listing):

Template code blogs/posts/index.html
{$tags = $_query->findBy('Common\Entities\Tag', [], ['name' => 'ASC'], 30)}
<nav class="facet" aria-label="Filter by tag">
  <a href="{$blog->getFullPath()|escape}"{if !isset($tag)} aria-current="true"{/if}>All</a>
  {foreach $tags as $t}
    <a href="{$blog->getTagPostsUrl($t)|escape}"{if isset($tag) && $tag->getId() == $t->getId()} aria-current="true"{/if}>{$t->getName()|escape}</a>
  {/foreach}
</nav>
{% set tags = _query.findBy('Common\\Entities\\Tag', {}, {'name': 'ASC'}, 30) %}
<nav class="facet" aria-label="Filter by tag">
  <a href="{{ blog.getFullPath()|e }}"{{ tag is not defined ? ' aria-current="true"' : '' }}>All</a>
  {% for t in tags %}
    <a href="{{ blog.getTagPostsUrl(t)|e }}"{{ tag is defined and tag.getId() == t.getId() ? ' aria-current="true"' : '' }}>{{ t.getName()|e }}</a>
  {% endfor %}
</nav>

The tags offered here are the site's first 30 by name; once the list grows, offer a curated set from site data instead, so the facet shows only tags this listing uses.

Pagination and the page cache

  • A listing's page links drop the query string. $_pagination builds a listing's links from the path alone; only the search page carries the query string through. On a listing filtered with ?%25activeRevision.name=coffee, the built-in link to page 2 drops the filter. Prefer the tag and category routes, which paginate correctly; when you must page a query-string filter, add the current query string to each link yourself.
  • The page cache does not see the filter. The listing reads its query parameters itself, without declaring them, so on a cached site the first rendering of /blog is stored under /blog and served for every filter and every sort, until it expires. Declare each parameter the page filters or sorts on by reading it through $_request->getParameter(), with an underscore for each dot (categories_slug), or prevent caching on the page. The routes have no such problem, because each combination is its own path (What makes a response uncacheable).
Template code blogs/posts/index.html
{$order = $_request->getParameter('orderBy')}
{$filter = $_request->getParameter('categories_slug')}
{$qs = $_request->getQueryString()}
{if $_pagination->getPagesCount() > 1}
  <nav aria-label="Pages">
    {foreach $_pagination->getPages(7) as $p}
      {$href = $p->getUrl()}
      {if $qs}{$href = $href|cat:'?'|cat:$qs}{/if}
      {if $p->isCurrent()}<span aria-current="page">{$p->getNumber()}</span>
      {else}<a href="{$href|escape}">{$p->getNumber()}</a>{/if}
    {/foreach}
  </nav>
{/if}
{% set order = _request.getParameter('orderBy') %}
{% set filter = _request.getParameter('categories_slug') %}
{% set qs = _request.getQueryString() %}
{% if _pagination.getPagesCount() > 1 %}
  <nav aria-label="Pages">
    {% for p in _pagination.getPages(7) %}
      {% set href = qs ? p.getUrl() ~ '?' ~ qs : p.getUrl() %}
      {% if p.isCurrent() %}<span aria-current="page">{{ p.getNumber() }}</span>
      {% else %}<a href="{{ href|e }}">{{ p.getNumber() }}</a>{% endif %}
    {% endfor %}
  </nav>
{% endif %}

The first two lines declare orderBy and the category filter to the page cache, and the loop carries the whole query string onto each page link. Declare every parameter the page responds to; a parameter that is not declared is a page the cache will serve to the wrong visitor.

A fixed scope does not belong in the URL

Anyone can remove or change a URL parameter. When a listing must never show more than a fixed set, such as one category or one author, fix it with a route, whose parameters override the URL's, or, on a blog listing, with the filter settings of the Blogs module's front-end controllers, which override the URL's parameters too. Leave changeable only what the page's own controls change.

Where a script should remember a visitor's last filter, and why that memory belongs in sessionStorage, is on Where filter state belongs.

When a filtered listing has outgrown the query string and should become a search view is on When a listing should become a search view.

Go deeper

Was this page helpful?

Your answer goes to the docs team.