Building a member area on Core dna: who a member is in the data, the sign-in flows and their templates, single sign-on and passkeys, the my-account routing convention, members-only content, sessions and the page cache, and the security checks to run before launch.
L2
Unit 05 · Members-only content
Entitlements: access per record
Members-only contentPage 28 of 36Updated
When access depends on what a member bought, booked or was approved for, model it as a record relating the member to the thing, created by the server and never by the member. A course enrolment as the worked example: the model, the check in a template, and why it scales where groups do not.
Who this is for. The content modeller and theme developer building paid or approval-based access: courses, event resources, documents for one client. Afterwards you can model the grant, check it in a template, and explain to a client why a group would not do.
The model
An entitlement is a record that says "this member may see this thing", with whatever the business needs to know about the grant. For courses, that is a custom entity type enrolment with one record per member per course, for example:
Field
Holds
Slug
u<user id>-c<course id>, such as u4312-c57, so a template can find the enrolment in one lookup
course (a Record area)
The course, for reports and for listing a member's courses
status (a select)
active, refunded, withdrawn
expires (a date)
When access ends, if it ends
Three settings on the enrolment type keep it safe:
Switch off URL Addressable on the custom entity type, so enrolment records have no page of their own.
Give members read permission on enrolment records. The template's lookup runs with the visitor's permissions and finds nothing otherwise. Members cannot call the API, and with no pages of their own the records are reachable only through your templates, so the grant exposes nothing by itself.
Keep personal details off it beyond the ids, and leave the type out of every search view.
Creating entitlements
An entitlement is created by the server when access is granted, from data the member cannot choose: a job on the order or the approval that grants access, or an integration writing through the API with the slug set. Creating records from a submission shows the job action; Job event reference lists the events a job can start from.
Never let the member's own data decide access. A "paid" flag, a tier or a list of course ids stored in a custom field on the member's person record can be changed by the member: the profile route accepts the person's custom fields by default (Profile data).
The check in the template
On the course's own page, where record is the course, look the enrolment up and decide which version to render:
{% do _response.preventCaching() %}
{% set enrolled = false %}
{% if _currentUser.isLoggedIn() %}
{% set enrolment = _query.findCustomEntityRecordBy('enrolment', {'slug': 'u' ~ _currentUser.getId() ~ '-c' ~ record.getId()}) %}
{% if enrolment and enrolment.getAreaText('status') == 'active' %}
{% set expires = enrolment.getAreaDate('expires') %}
{% if not expires or expires.getTimestamp() > 'now'|date('U') %}{% set enrolled = true %}{% endif %}
{% endif %}
{% endif %}
{% if enrolled %}
{{ record.lessons }}
{% else %}
{{ record.summary }}
<a href="/courses/enrol?course={{ record.getId() }}">Enrol</a>
{% endif %}
What carries the weight:
Sign-in is tested first. A course page is public, so a signed-out visitor reaches it, and the lookup needs a user id.
preventCaching() stops the page cache storing either version. Without it, the first visitor's render, enrolled or not, is served to everyone after them (Member pages and the page cache).
Status and expiry live on the grant. Refunding or ending access is a change to one record, made by the same automation that created it.
What it protects, and what still needs the gate
This check decides what the course page shows. It does not protect the lessons anywhere else: if the lessons are records or pages of their own, listed or linked elsewhere, each must be restricted at the record too, or checked the same way on its own page (Template gating and route gating). A lesson's video or PDF needs its own answer, because files are never gated (Members-only files).
Check the course's URL requested as JSON as well: it answers with the record's data without running this template, so the protected material must not be part of what it returns (The same URL as JSON).
Why it scales where groups do not
A group per course
An enrolment per member and course
Granting access
Someone adds the person to a group in the admin
A job creates a record when the order or approval happens
Ending access
Someone remembers to remove them
A status or a date on the record
A hundred courses
A hundred groups, each wired into permissions
One custom entity type
"What has this member got, and since when?"
Not recorded
One query, with dates
Groups stay useful beside entitlements, for the roles that really are roles: staff, partners, everyone with an active subscription. When a group is the wrong tool gives the test.