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

Relationships between records

A relationship definition is a typed link from records of one definition to records of another, with a minimum and a maximum. This page shows how to define one, what a save checks, and how to read it from both ends in a template.

Who this is for. Content modellers turning a flat list of records into a domain model, and developers rendering it. After this page you can choose a relationship's limits, predict when a save is refused, and read related records in either direction without loading more than you need.

What a relationship definition is

Records on their own are a flat list. A relationship definition says that records of one definition may point at records of another: an enrolment points at a course, a course at the courses it requires first. It belongs to the definition on the pointing side, the owning side, and has these fields:

FieldWhat it controls
IdentifierThe name templates and the API use for the relationship, such as course.
NameWhat authors see.
PositionThe order of the relationships on a record.
Minimum recordsHow many related records a save requires. 0 makes the relationship optional.
Maximum recordsHow many it allows. Empty means no limit; 1 makes it a single relationship.
Allowed entitiesThe definitions whose records may be linked. At least one is required.

Three properties shape everything else:

  • Targets are always custom entity records. Allowed entities lists definitions, never pages, users or products. To point a record at a page, a member or a product, give its layout a record or records layout area instead.
  • A relationship belongs to the record, not to a revision. Changing it changes the live record at once; there is no revision to activate.
  • A relationship has a direction. The owning side holds the link and reads it directly. The other side finds it only by asking which records point at it, which is a query (reading from the other side).

Creating one

Relationship definitions are added on the owning definition in the admin. The learning site in this unit's worked example has two:

Owning definitionIdentifierMinimumMaximumAllowed entities
enrolmentcourse11course
courseprerequisites0emptycourse

A relationship may target its own definition, as prerequisites does. Choose the limits from what the model means, because templates see them too:

Minimum and maximumMeansThe Smarty shorthand returns
0 and 1Optional, one recordThe record, or null
1 and 1Required, one recordThe record
0 and emptyAn optional listA collection, possibly empty
n and mBetween n and m recordsA collection

Changing the maximum to or from 1 later changes what the shorthand returns, from a record to a collection or back, so every template that reads the relationship has to change with it.

What a save checks

  • The minimum, twice. A save is refused when a relationship has fewer records than its minimum, and also when the record has no relationship at all for a relationship definition whose minimum is above 0. An enrolment that never had a course set is refused, not only one whose course was removed.
  • Each linked record's definition. A linked record whose definition is not among the allowed entities is refused, record by record.
  • The maximum. A save with more linked records than the maximum is refused, reported against record for a single relationship and against records for a list.

The minimum's message names the relationship: A minimum of 1 need to provided for the relationship course, but got no values when none is linked, and A minimum of <n> need to provided for the relationship <identifier>; only got <count> when some are linked but too few. It is reported against record for a single relationship and against records for a list. Over the API, a refused relationship write is a 422 that names the relationship (Custom entities over the API and MCP).

Reading from the owning side

In Smarty, a relationship's identifier works as a property of the record, like the layout area shorthand: {$enrolment->course} returns the course record when the maximum is 1, and a collection of records otherwise. In Twig, enrolment.course prints nothing, so a Twig template reads a relationship with the explicit getters.

Template code templates/enrolments/show.html
{$course = $enrolment->course}
{if $course}
  <a href="{$course->getFullPath()}">{$course->getName()|escape}</a>
{/if}
{% set course = null %}
{% for rel in enrolment.getAllRelationships() %}
  {% if rel.getDefinition().getIdentifier() == 'course' %}
    {% set course = rel.getRecord() %}
  {% endif %}
{% endfor %}
{% if course %}
  <a href="{{ course.getFullPath() }}">{{ course.getName()|e }}</a>
{% endif %}

The Smarty shorthand is invisible to isset() and |default, and Twig's is defined reports the relationship missing too, even when it resolves. When a relationship may be empty, assign it and test the value, as above.

A list relationship returns a collection, which a loop reads:

Template code templates/courses/show.html
<ul>
  {foreach $course->prerequisites as $required}
    <li><a href="{$required->getFullPath()}">{$required->getName()|escape}</a></li>
  {/foreach}
</ul>
<ul>
  {% for rel in course.getRelationships() %}
    {% if rel.getDefinition().getIdentifier() == 'prerequisites' %}
      {% for required in rel.getRecords() %}
        <li><a href="{{ required.getFullPath() }}">{{ required.getName()|e }}</a></li>
      {% endfor %}
    {% endif %}
  {% endfor %}
</ul>

The getters

CallReturns
getAllRelationships()One relationship per relationship definition, in position order, empty ones included.
getRelationships()Only the relationships that have at least one record.
getDefinition() on a relationshipIts relationship definition; getIdentifier() gives the identifier.
getRecords() on a relationshipThe related records, as a collection.
getRecord() on a relationshipThe first related record, or null when there is none.

Reading from the other side

A course page that lists its enrolments reads the relationship backwards. Query the owning side, with the relationship as a criterion and a limit:

Template code templates/courses/show.html
{$enrolments = $_query->findCustomEntityRecordsBy('enrolment', [
  'relationships.definition.identifier' => 'course',
  'relationships.records.id' => $course->getId()
], ['created' => 'DESC'], 20)}
{% set enrolments = _query.findCustomEntityRecordsBy('enrolment', {
  'relationships.definition.identifier': 'course',
  'relationships.records.id': course.getId()
}, {'created': 'DESC'}, 20) %}
Do not ask a popular record for its inverse relationships

getInverseRelationships() loads every relationship that points at the record, with no limit, so on a course with many enrolments it loads all of them. The query above takes criteria, an order and a limit; Querying records from a template covers its cost.

Relationship, record layout area or parent

UseWhenExample
A relationship definitionBoth sides are custom entity records that stand on their own, and the link is what you model.An enrolment and its course
A record or records layout areaThe target is a page, a member, a product or anything else that is not a custom entity record. The value lives in the revision, like any layout area's.An enrolment's learner
A parent and its childrenThe child cannot exist without its parent and is edited from it (Parent and child records).A test within an enrolment
Go deeper

Was this page helpful?

Your answer goes to the docs team.