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 02 · The search page

The search route and template

The route that renders a search view, the template you write for it and the variables it receives, a minimal page in both engines, declaring the parameters that keep the page cache from serving one visitor's results to everyone, and the same results as JSON.

Who this is for. The theme developer building a site's search results page. Afterwards you can render a working results page in Smarty or Twig, know what each variable offers, and keep the page cache from serving the wrong results.

The route and the template

GET /search/<view slug> runs the view with that slug on the current site, passing it every query parameter of the request, and renders search/search.html from your site's templates. The framework ships no copy of that file, so a site without one cannot show results. Every view on the site renders through the same template; branch on $view->getSlug() where two views need different markup. (search is the route's default prefix; a site's route settings in the admin can give the module another.)

  • A slug that matches no view on the site is a 404, and so is a bare /search.
  • A page number past the last page is a 404, as on every paginated listing (Pagination and result counts).
  • If the search itself fails, because Elasticsearch is unreachable, the view is misconfigured or the page is too deep to fetch, the page still renders, with no results and HTTP 200, is never cached, and the error goes to the platform's log (A failed search).

There is no page record behind this route, so nothing supplies a title, a meta description or a breadcrumb. The template sets them.

What the template receives

VariableWhat it is
viewThe search view: getName(), getSlug(), getIndex() (master or facets), isFacetsIndex(), getPageSize(), getSettings()
responseThe results: getResultItems(), getTotalCount(), getFrom() (the offset), getSize() (the page size) and getFacets(), which is empty unless the view is a Faceted Search view
_paginationThe page links, already carrying the query and the filters: getPagesCount(), getPages(n), getCurrentPage(), getPreviousPage(), getNextPage(), getRecordCount()

Each result item reads its search document by field name:

MethodReturns
getText('name')A field as text; a list is joined with a comma and a space. null when the document has no such field
getTexts('tags.name')A field as a list of strings
getInteger(), getIntegers(), getFloat(), getFloats()A numeric field, or its list
getDate('published')A date field as a date in the site's timezone
getHighlightText(), getHighlightTexts()The highlighted version of a field, or the plain field when it has no highlight (Highlighting)
getScore()The relevance score: the same for every result when the search had no query, and null when the results are sorted by a field other than _score
getRecord(), getRelatedRecords()The record itself, and the records chosen in its record areas, loaded from the database

Render from the document wherever you can. getRecord() costs a database query per result, and returns null when the record has been deleted or unpublished since it was indexed, so load it only for what the document does not hold, and skip the result when it comes back empty.

A minimal search page

Template code search/search.html
{extends 'includes/frontend/base.html'}
{block 'content'}
  {$_response->preventCaching()}
  {$query = $_request->getParameter('q')}
  {$hasQuery = is_string($query) && trim($query) !== ''}
  <h1>Search</h1>
  <form role="search" method="get" action="/search/{$view->getSlug()|escape:'url'}">
    <label for="search-q">Search this site</label>
    <input id="search-q" type="search" name="q" value="{if $hasQuery}{$query|escape}{/if}">
    <button type="submit">Search</button>
  </form>
  {if $hasQuery}
    <p id="search-status" role="status">{$response->getTotalCount()} results for &ldquo;{$query|escape}&rdquo;</p>
    <ol class="search-results" start="{$response->getFrom() + 1}">
      {foreach $response->getResultItems() as $item}
        <li>
          <a href="{$item->getText('fullPath')|escape}">{$item->getText('name')|escape}</a>
          {$summary = $item->getText('fields.summary')}
          {if $summary}<p>{$summary|truncate:200|escape}</p>{/if}
        </li>
      {/foreach}
    </ol>
    {if $_pagination->getPagesCount() > 1}
      <nav aria-label="Result pages">
        {foreach $_pagination->getPages(7) as $p}
          {if $p->isCurrent()}<span aria-current="page">{$p->getNumber()}</span>
          {else}<a href="{$p->getUrl()|escape}">{$p->getNumber()}</a>{/if}
        {/foreach}
      </nav>
    {/if}
  {/if}
{/block}
{% extends 'includes/frontend/base.html' %}
{% block content %}
  {% do _response.preventCaching() %}
  {% set query = _request.getParameter('q') %}
  {% set hasQuery = query is not null and query is not iterable and query|trim != '' %}
  <h1>Search</h1>
  <form role="search" method="get" action="/search/{{ view.getSlug()|e('url') }}">
    <label for="search-q">Search this site</label>
    <input id="search-q" type="search" name="q" value="{{ hasQuery ? query|e : '' }}">
    <button type="submit">Search</button>
  </form>
  {% if hasQuery %}
    <p id="search-status" role="status">{{ response.getTotalCount() }} results for &ldquo;{{ query|e }}&rdquo;</p>
    <ol class="search-results" start="{{ response.getFrom() + 1 }}">
      {% for item in response.getResultItems() %}
        <li>
          <a href="{{ item.getText('fullPath')|e }}">{{ item.getText('name')|e }}</a>
          {% set summary = item.getText('fields.summary') %}
          {% if summary %}<p>{{ summary|u.truncate(200, '...', false)|e }}</p>{% endif %}
        </li>
      {% endfor %}
    </ol>
    {% if _pagination.getPagesCount() > 1 %}
      <nav aria-label="Result pages">
        {% for p in _pagination.getPages(7) %}
          {% if p.isCurrent() %}<span aria-current="page">{{ p.getNumber() }}</span>
          {% else %}<a href="{{ p.getUrl()|e }}">{{ p.getNumber() }}</a>{% endif %}
        {% endfor %}
      </nav>
    {% endif %}
  {% endif %}
{% endblock %}

Use your own base template and block name. Four details carry weight:

  • The query is printed back escaped, in the box and in the status line, because it came from the URL and anyone can send a link with markup in it. Neither engine escapes output on Core dna unless you ask.
  • The page tests for a query, not for a count. With no query the view lists everything it covers, and an empty query finds nothing; both should show the starting state (Empty and zero-result states). A query sent as a list (?q[]=x) is not a string, and the test treats it as no query rather than failing.
  • The pagination is guarded. On an empty result isCurrent(), getNextPage() and their siblings throw, so print page links only when there is more than one page.
  • Caching is prevented, for the reason below.

Page sizes, the result count and its cap at 10,000, previous and next links, and how deep a visitor can page are on Pagination and result counts.

Caching: declare every parameter the page reads

The page cache stores a page rendered for a signed-out visitor under its path plus only the query parameters the template declared. A template declares a parameter by reading it through $_request->getParameter(); the search controller reads q, page, the sort and every filter itself, so declare each one your template depends on as well, or the cache stores a single copy under /search/site-search and serves the first visitor's results to everyone else until the copy expires, a day by default. This never shows on a development environment, where nothing is cached, so check it deliberately, below.

Three ways out:

  • Prevent caching on the search page, as the example does. Every search reaches Elasticsearch, which is what it is for. This is the right default.
  • Exclude the route: add ^\/search\/ to Cache settings › Response cache exclusions, with each slash escaped: the platform wraps every pattern in slashes, so an unescaped slash breaks the pattern and it matches nothing. It stops new copies being stored but does not remove copies already stored, so clear the cache after adding it.
  • Declare every parameter that changes the page, if search traffic is heavy enough that repeated searches matter: q, page, orderBy and each filter. A dot in a parameter name reaches PHP as an underscore, so tags.name is declared with $_request->getParameter('tags_name'). Miss one, and the cache ignores that parameter.

To check a page: request it twice signed out with different queries, compare the results, and read the X-Cache-Status header. More is on What makes a response uncacheable.

JSON from the same URL

Requested with Accept: application/json, the same route answers JSON instead of rendering the template: data.view, the view with its settings, and data.response, with totalCount, resultItems (each item's document, with _id, _score and highlight) and facets. It needs no API credential and works for signed-in members as for anyone, which makes it the endpoint for a search-as-you-type box on the site's own pages. Two details:

  • An empty list comes back as {}, not []: a search with no results answers "resultItems": {}. Read the items with Object.values(), or check totalCount first.
  • JSON answers are never cached, but a cached HTML copy of the same URL is served to a JSON request, because the cache does not look at the Accept header. One more reason to prevent caching on the search page.
JavaScript
async function searchTitles(term) {
  const res = await fetch('/search/site-search?q=' + encodeURIComponent(term), {
    headers: { Accept: 'application/json' },
    credentials: 'same-origin'
  });
  if (!res.ok) return [];
  const { data } = await res.json();
  // resultItems is {} when nothing matched, so read it with Object.values()
  return Object.values(data.response.resultItems)
    .map(item => ({ name: item.name, url: item.fullPath }));
}

The route answers 200 with no results when the search fails. When a caller needs to tell an outage from an empty answer, use the API instead, which answers 500 on a failure (Three ways to run a view).

Go deeper

Was this page helpful?

Your answer goes to the docs team.