Guide 04 · Developer docs

Input types, layouts, components and images

The platform's central abstraction, in one line: an input type defines what an editor can enter, a layout area binds one input type to a named slot, a layout groups areas and points at a template, a record or component holds the content for each area, and the template reads it back. Read this book straight after the template languages.

L1–L3
Unit 05 · Components, collections and global components

Computed blocks with the query helper

A block whose content is computed rather than authored is a component whose template queries records with $_query, configured by an area or two. The four query methods, the entities they may read, keeping the query out of the loop, and when the result can go stale.

Who this is for. Developers building blocks such as latest posts or recent reviews, which list records instead of holding authored content. After this page you can build one as an ordinary component, let authors configure it, and know what it costs and when it can lag behind the records.

A component that queries

Older sites built such blocks as widgets. Widgets are deprecated: a computed block is now an ordinary component whose template fetches the records it lists. Authors place it in a collection like any other block, and its layout's areas configure what it fetches, such as how many records and in which order.

The template fetches records with the query helper, $_query (_query in Twig), which every template receives. It needs no setup, and it returns the same response models a record's own template gets, so the shorthand and the getters work on what it returns.

MethodReturns
findBy(entity, criteria = [], orderBy = null, limit = null, offset = null)A collection of records
findOneBy(entity, criteria, orderBy = null)One record, or null
findCustomEntityRecordsBy(identifier, criteria = [], orderBy = null, limit = null, offset = null)A collection of custom entity records
findCustomEntityRecordBy(identifier, criteria, orderBy = null)One custom entity record, or null

entity is the entity's class name, such as Blogs\Entities\Post; identifier is a custom entity type's identifier, such as review. criteria maps fields to the values to match, and orderBy maps fields to ASC or DESC.

Example: latest posts

A Latest posts component layout with three areas:

AreaInput typeConfigures
headingTextThe block's heading
limitNumberHow many posts to show
orderSelect, options newest::Newest first and oldest::Oldest firstThe order
Template code components/latest-posts.html
{$limit = intval($component->getAreaText('limit'))}
{if $limit < 1}{$limit = 3}{/if}
{$direction = ($component->getAreaText('order') == 'oldest') ? 'ASC' : 'DESC'}
{$posts = $_query->findBy('Blogs\Entities\Post', [], ['created' => $direction], $limit)}

{if count($posts)}
<section class="latest-posts">
    <h2>{$component->heading}</h2>
    <ul>
    {foreach $posts as $post}
        <li><a href="{$post->url}">{$post->name}</a></li>
    {/foreach}
    </ul>
</section>
{/if}
{% set limit = component.getAreaText('limit')|integer %}
{% if limit < 1 %}{% set limit = 3 %}{% endif %}
{% set direction = component.getAreaText('order') == 'oldest' ? 'ASC' : 'DESC' %}
{% set posts = _query.findBy('Blogs\\Entities\\Post', {}, {'created': direction}, limit) %}

{% if posts|length %}
<section class="latest-posts">
    <h2>{{ component.heading }}</h2>
    <ul>
    {% for post in posts %}
        <li><a href="{{ post.url }}">{{ post.name }}</a></li>
    {% endfor %}
    </ul>
</section>
{% endif %}

Three habits matter:

  • Query once, before the markup. Assign the result to a variable and loop over the variable. A query written inside the loop runs once per row.
  • Read configuration with getAreaText(). These values feed a calculation and a query, not the page, so they must be the plain stored text; in live preview the shorthand would hand over the wrapped text instead. A Number area stores text, so convert it with intval() in Smarty (a function: the security policy does not allow PHP modifiers) or |integer in Twig, and fall back to a default when it is empty or zero.
  • Map select keys onto query values in the template, as order does above, so authors choose from labels and never type a field name.

The heading, by contrast, is printed with the shorthand: it is output, and needs no argument. $post->url and $post->name are the post's own getters, not areas.

What findBy may read

findBy() and findOneBy() accept only a fixed list of entities. The class name is the module, Entities and the entity, as in Pages\Entities\Page or Faq\Entities\Question.

ModuleEntities
BlogsBlog, Comment, Post
BooksBook, Page, Unit
CommonCategory, Country, Currency, CustomField, CustomUrl, GlobalComponent, InputType, Language, Layout, LayoutCategory, State, Tag
FaqPage, Question
FilesFile, Filename, Folder, Image
FormsForm
GlobalsRoute
HooksHook (a job: the module is now Orchestration, the class keeps its old name)
InventoryWarehouse
MenusMenu, Element
PagesPage
ProductsCategory, CategoryDescription, Product, ProductDescription, ProductVariant
RewritesRewrite
SearchView
SitesSite

Users and people are not on the list, so a template cannot list members this way. Any other class name throws an exception whose message reads Entity, the name, then my not be queried.

Custom entity records have methods of their own: findCustomEntityRecordsBy() and findCustomEntityRecordBy() take the custom entity type's identifier and query its records directly, outside this list.

This list is not the one a record or records area offers its author. Which entities a record picker may point at is set on the input type, from a separate list in a different place. The two overlap heavily, but an entity on one is not guaranteed to be on the other.

Example: paging custom entity records

A reviews block that pages through the custom entity type review, with a Number area perPage. The page number comes from the query string:

Template code components/reviews.html
{$_response->addCacheVaryParameter('page')}
{$pageNumber = max(1, intval($_request->getParameter('page')))}
{$perPage = intval($component->getAreaText('perPage'))}
{if $perPage < 1}{$perPage = 5}{/if}
{$offset = ($pageNumber - 1) * $perPage}

{$reviews = $_query->findCustomEntityRecordsBy('review', [], ['created' => 'DESC'], $perPage, $offset)}
{$totalPages = ceil(count($_query->findCustomEntityRecordsBy('review')) / $perPage)}

{foreach $reviews as $review}
    <blockquote>
        {$review->quote}
        <footer>{$review->personName}</footer>
    </blockquote>
{/foreach}
{if $pageNumber < $totalPages}
    <a href="?page={$pageNumber + 1}">Older reviews</a>
{/if}
{% do _response.addCacheVaryParameter('page') %}
{% set pageNumber = max(1, _request.getParameter('page')|integer) %}
{% set perPage = component.getAreaText('perPage')|integer %}
{% if perPage < 1 %}{% set perPage = 5 %}{% endif %}
{% set offset = (pageNumber - 1) * perPage %}

{% set reviews = _query.findCustomEntityRecordsBy('review', {}, {'created': 'DESC'}, perPage, offset) %}
{% set totalPages = (_query.findCustomEntityRecordsBy('review')|length / perPage)|round(0, 'ceil') %}

{% for review in reviews %}
    <blockquote>
        {{ review.quote }}
        <footer>{{ review.personName }}</footer>
    </blockquote>
{% endfor %}
{% if pageNumber < totalPages %}
    <a href="?page={{ pageNumber + 1 }}">Older reviews</a>
{% endif %}

Three things in this example are easy to miss:

  • The page cache must know about the parameter. The page cache ignores a query string unless something registers it as a vary parameter. $_request->getParameter() registers the key it reads on its own, so the explicit addCacheVaryParameter('page') call above is not doing separate work here; it is the one to reach for when a parameter is read some other way.
  • Counting costs a second query. The methods have no count-only form, so the total comes from fetching the whole set and counting it. On a large set, ask whether the block needs a page count at all: to show only an Older link, fetch one record more than you display and check whether it came back.
  • The page number has its own variable name. It is $pageNumber, never $page: in a component template $page is the page being rendered, and overwriting it breaks everything after it that reads the page.

When a computed block lags behind

A computed block is as fresh as the cached copy of the page it is on, and the two kinds of method differ here:

  • findBy() and findOneBy() register the entity they query with the page cache. A new record of that entity on the site refreshes the cached page, as does an edit to a record the page already shows.
  • findCustomEntityRecordsBy() and findCustomEntityRecordBy() do not register the custom entity. An edit to a record the page already shows still refreshes the page, but a record created after the page was cached does not appear until something else refreshes it.
New custom entity records can be missing

A block listing the latest custom entity records can show the list as it stood when the page was cached, without the newest records. Keep that in mind when choosing between a custom entity and a module such as Blogs for content that must appear the moment it is published.

Go deeper

Was this page helpful?

Your answer goes to the docs team.