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

Worked example: a course catalogue

A complete learning site built from three custom entity definitions, a book, jobs and templates, with no PHP: courses, enrolments related to a course and a learner, tests as children of an enrolment, and certificates issued by a job.

Who this is for. Site builders and developers who want to see the whole of this unit at work in one model. After this page you can design an application on custom entities and say why each part takes the shape it does.

A learning site of this shape runs in production on Core dna. Learners enrol on courses, read the lessons, take tests and receive a certificate when they pass. None of it is framework code: the definitions, layouts and jobs are configured in the admin, and the only files are templates.

The model

PartBuilt asSet up
CourseA definition with a path and a record pathLayout areas for the description, price and pass mark, and a record layout area pointing at the book that holds the lessons. An optional list relationship, prerequisites, to other courses.
EnrolmentA definition with a record path and no pathA relationship course (minimum 1, maximum 1, allowed entity Course). A record layout area user holding the learner. Layout areas progress, passScore, passDate and certPath.
TestA definition whose parent definition is Enrolment, with a record path of its ownLayout areas for the learner's answers and the result.
CertificateAn Orchestration jobRuns when a test is submitted; on a pass, it renders the certificate and emails it.

Why each part takes its shape

  • The lessons are a book. Books already have units, pages and a reading order. A course points at its book through a record layout area rather than rebuilding lesson order inside a custom entity.
  • The enrolment is a record of its own, not a few layout areas on the member, because it has a lifecycle of its own (progress, a pass date, a certificate) and children of its own.
  • The enrolment has no listing path. Nobody browses a list of every enrolment; each learner sees their own. With the path empty, the listing route does not exist.
  • The course is a relationship; the learner is a layout area. An enrolment and a course both stand on their own, so a relationship definition with a maximum of 1 links them. A relationship can only target custom entity records, so the learner, a member, is a record layout area on the enrolment.
  • A test is a child, not a relationship. It cannot exist without its enrolment. With Enrolment as the parent definition of Test, every test's parent must be an enrolment, and the admin offers + Add Test on each enrolment. Test has its own record path, because a child's URL is not nested under its parent's.
  • The certificate is a job, because issuing it is an event with side effects, a file and an email, not content.

The enrolment page

The enrolment's detail template shows the course, the learner's progress and the tests taken. The course comes from the relationship, the tests from getChildren():

Template code templates/enrolments/show.html
{$course = $enrolment->course}
{if $course}
  <h1>{$course->getName()|escape}</h1>
{/if}
<p>Progress: {$enrolment->progress}%</p>
<h2>Tests</h2>
<ul>
  {foreach $enrolment->getChildren() as $test}
    <li><a href="{$test->getFullPath()}">{$test->getName()|escape}</a></li>
  {foreachelse}
    <li>No tests taken yet.</li>
  {/foreach}
</ul>
{% set course = null %}
{% for rel in enrolment.getAllRelationships() %}
  {% if rel.getDefinition().getIdentifier() == 'course' %}
    {% set course = rel.getRecord() %}
  {% endif %}
{% endfor %}
{% if course %}
  <h1>{{ course.getName()|e }}</h1>
{% endif %}
<p>Progress: {{ enrolment.progress }}%</p>
<h2>Tests</h2>
<ul>
  {% for test in enrolment.getChildren() %}
    <li><a href="{{ test.getFullPath() }}">{{ test.getName()|e }}</a></li>
  {% else %}
    <li>No tests taken yet.</li>
  {% endfor %}
</ul>

The page belongs to one learner. The production template compares the enrolment's user record layout area with the signed-in member's id ($_currentUser->getId()) and sends anyone else, apart from an admin, to their account page. The test template posts the learner's answers to a job's web event.

The jobs

An Orchestration job receives the web event of a submitted test. It marks the answers and updates the enrolment's score and pass date; on a pass, it renders the certificate as a file and emails it to the learner. A second job, on the enrolment's update event, keeps the learner's certificates page current. The jobs' payload templates live in templates/hooks/, a folder named after the module's old name, Hooks.

The learner's courses

A learner's account page lists their enrolments. That link runs from the member to the enrolments, through a records custom field on the user, because a relationship definition cannot start or end at a member. Custom fields covers custom fields.

What was written

Three definitions, their layouts, two jobs and the templates. No controller, no module and no PHP: everything else is configuration in the admin.

Go deeper

Was this page helpful?

Your answer goes to the docs team.