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

Defining a custom entity

The fields of a custom entity definition, what each one controls and which you can change once records exist, and how a record takes its layout and layout areas exactly as a page does.

Who this is for. Developers and content modellers setting up a new definition. After this page you can fill in a definition so that nothing has to be undone once authors start entering records.

Identifier, name and icon

The identifier is the name code uses for the type: in a template query ($_query->findCustomEntityRecordsBy('store')), in the variables the listing and detail templates receive, and in the API paths. It starts with a letter, followed by letters, digits, hyphens or underscores, two characters at least. These words are reserved, because they would shadow a fixed API path: definitions, definition, dynamic, activate-revision, record-revision, duplicate-record and check-record-slug.

Choose it with care: the identifier cannot be changed once the definition has any records.

The name and the icon are what authors see in the admin, and you can change them at any time. The plural forms are derived for you: a definition with the identifier store lists its records as stores, both in the listing template and in the API path.

Paths and URL addressing

Two paths give records their URLs. The path is the prefix of the listing, such as /stores; the record path is the prefix of each record's detail page, such as /store/richmond. Either can be left empty, and that route then does not exist. Both also need the definition to be URL addressable (on by default), which gives each record a slug.

The routes are rebuilt from the definitions on every request, so a changed path works on the next request. Nothing redirects the old URLs: when records were already linked or indexed under the old path, add redirects by hand (Rewrites, redirects and which address wins).

Turning URL addressing on for a definition that already has records does not give them slugs. Instead, the next save of each record that has no slug is refused until a slug is entered.

The layout and the listing template

A definition names two templates, in two different ways:

  • The template is a plain path on the definition, such as stores/index.html. The listing route renders it. Leave it empty and the listing answers 404.
  • The layout is an ordinary layout record, and the detail route renders the layout's template. A definition cannot be saved without one: if you do not choose a layout, one is created for it, named Custom entity: <identifier>.

A record's layout areas work as a page's

A record takes its layout areas exactly as a page does. Input types, layout areas, groups, attributes, components and collections, the shorthand and live preview: everything in The chain from input type to template and the rest of Guide 4 applies unchanged. A team that can build a page layout can build this one; there is no separate approach for custom entities.

Content lives in revisions here too. {$store->openingHours} reads the openingHours area of the record's active revision, as {$page->intro} does on a page, and the explicit getters, such as getAreaText('openingHours'), read the same revision. How revisions are made, published and approved is in Revisions and the active revision.

The one difference: the layout belongs to the definition, not to the revision. A page's author picks a layout per page; every record of a definition renders through the same layout and the same detail template. What varies between records is their content, never their layout.

While you build, a record can tell you which definition it belongs to and which template renders it:

Template code templates/stores/show.html
{$store->getEntityIdentifier()} uses {$store->getRecordTemplate()}
{{ store.getEntityIdentifier() }} uses {{ store.getRecordTemplate() }}

When one detail template is not enough

A definition has exactly one layout, so it has one detail template, and no setting gives some of its records another. Two patterns give the effect:

  • Branch inside the template. Add a select layout area, such as kind, and include a different partial for each value. The records share their fields and differ in presentation.
  • Make separate definitions when the kinds need different fields. Two definitions with two layouts are clearer than one layout full of layout areas that only some records use.

The comparison reads the stored option key with getAreaText(), because the shorthand wraps the value for live preview and the comparison would then fail while an author edits:

Template code templates/stores/show.html
{if $store->getAreaText('kind') == 'outlet'}
  {include 'stores/partials/outlet.html'}
{else}
  {include 'stores/partials/standard.html'}
{/if}
{% if store.getAreaText('kind') == 'outlet' %}
  {% include 'stores/partials/outlet.html' %}
{% else %}
  {% include 'stores/partials/standard.html' %}
{% endif %}

The switches

SwitchDefaultWhat it doesChanging it later
SitedOnEach record belongs to one site. Off, records belong to the whole account.Changes only what later saves check: existing records keep the site they have.
PublishableOnRecords have a publish state, and unpublished ones are hidden from visitors. Off, the admin hides the publish controls.Turning it off makes every record visible at once, unpublished ones included.
PositionedOffRecords have a manual order, and the listing uses it.On: every record without a position is given one. Off: stored positions are kept, so turning it on again restores the order.
Categorisable, TaggableOffRecords can carry categories or tags, and the listing can be filtered by them.Free to change.
URL addressableOnRecords have slugs, and the listing and detail routes can exist.On later: the next save of each record without a slug is refused until it has one.
RevisionsAlwaysEvery edit makes a new revision; visitors see the active one.There is no switch: revisions cannot be turned off.

A parent definition

A definition can name a parent definition. Every record of the child definition must then have a record of the parent definition as its parent: a test belongs to an enrolment, a branch to a region. Parent and child records covers what that means for records and templates.

Cycles are refused. Nothing else guards the parent once records exist: you can change or clear a definition's parent at any time, and existing records keep the parent they already had, even when it is now of the wrong type. Settle the hierarchy before authors start.

In a hierarchy, the root definition's switches win

Sited, Publishable and Positioned are copied from the root definition onto every definition below it, each time any definition in the tree is saved. A child definition cannot keep values of its own for these three: set them on the root.

What you can change later

SettingOnce records exist
IdentifierCannot be changed.
Name, iconChange at any time.
Path, record pathChange at any time; the old URLs stop working unless you redirect them.
Layout areasAs on any layout: Where content is stored, and what a change does to it.
SitedChange at any time; affects later saves only.
URL addressableTurned on later, each record needs a slug at its next save.
PositionedChange at any time, in either direction.
Parent definitionChange at any time, unguarded; existing records keep their parents.
The definition itselfDeleting it deletes its layout, the definitions below it and every record entered under them.
Go deeper

Was this page helpful?

Your answer goes to the docs team.