Guide 05 · Developer docs

Content types and custom entities

Which built-in type fits which purpose, how categories, tags, slugs, custom URLs and SEO settings behave across them, how revisions and publishing gate what the public sees, how to model a domain the platform has no type for, and where uploaded files live.

L1–L3
Unit 04 · Custom entities

Querying records from a template

Read custom entity records on any page with the query helper's two custom entity methods: criteria, order and limit, and what each call costs.

Who this is for. Developers showing records outside their own listing and detail pages: testimonials on the home page, the stores near a product, the enrolments of a course. After this page you can fetch exactly the records a template needs, in one query, and know when the work belongs somewhere else.

Two methods for custom entity records

Every template has the query helper, $_query (_query in Twig). Custom entity records have two methods of their own on it; findBy() and findOneBy(), which take an entity class name, are for everything else.

PHP
findCustomEntityRecordsBy(string $identifier, array $criteria = [], ?array $orderBy = null, ?int $limit = null, ?int $offset = null)
findCustomEntityRecordBy(string $identifier, array $criteria, ?array $orderBy = null)

The first returns a collection of records, the second one record or null. Both find the definition by its identifier first, and an identifier that matches no definition is an error. Both add the definition to the criteria themselves, and, for a sited definition, the current site, so the records of other sites never appear.

Template code
{$openStores = $_query->findCustomEntityRecordsBy('store', ['tags.slug' => 'open-sunday'], ['position' => 'ASC'], 12)}
{$flagship = $_query->findCustomEntityRecordBy('store', ['slug' => 'richmond'])}
{% set openStores = _query.findCustomEntityRecordsBy('store', {'tags.slug': 'open-sunday'}, {'position': 'ASC'}, 12) %}
{% set flagship = _query.findCustomEntityRecordBy('store', {'slug': 'richmond'}) %}

Widgets, which older sites used to list records in a layout area, are deprecated; a template reads records with $_query instead.

Criteria

A criterion is a field, or a dotted path into a relation, and the value it must have:

CriterionMatches
slug, position, created, published, parentThe record's own fields
tags.slugRecords with that tag
activeRevision.nameRecords whose active revision has that name
relationships.definition.identifier with relationships.records.idRecords whose relationship of that identifier links to that record (Relationships between records)

What each call costs

Every call is a query, and nothing is kept between calls: the same call written twice runs twice. Three habits keep a template cheap:

  • Hoist the call. Make it once, at the top of the template, and loop over the result. A call inside a loop runs once per iteration.
  • Let the query order and limit. Pass orderBy and limit rather than fetching everything and sorting or slicing it afterwards.
  • Do not fetch everything to count it. A common mistake is a component that calls the method once without a limit to count the records for its pagination, and again with a limit and offset for the page it shows. The first call loads every record. For a paged list of one definition, give the definition a listing path and template, which pages for you (Listing and detail templates). For a short list, make one limited call and count what it returned.
Template code templates/components/featured-stores.html
{$featured = $_query->findCustomEntityRecordsBy('store', ['tags.slug' => 'featured'], ['position' => 'ASC'], 6)}
{if $featured->count() > 0}
  <ul>
    {foreach $featured as $store}
      <li><a href="{$store->getFullPath()}">{$store->getName()|escape}</a></li>
    {/foreach}
  </ul>
{/if}
{% set featured = _query.findCustomEntityRecordsBy('store', {'tags.slug': 'featured'}, {'position': 'ASC'}, 6) %}
{% if featured.count() > 0 %}
  <ul>
    {% for store in featured %}
      <li><a href="{{ store.getFullPath() }}">{{ store.getName()|e }}</a></li>
    {% endfor %}
  </ul>
{% endif %}

When the work belongs elsewhere

Criteria filter one definition's records by their fields and relations. When a block needs records of several definitions joined and filtered in ways criteria cannot express, stop composing queries in the template. An Orchestration job that runs on the event that changes the data can write a precomputed record instead, which the template then reads with one call.

Go deeper

Was this page helpful?

Your answer goes to the docs team.