Guide 07 · Developer docs

Members, accounts and gated content

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 01 · The identity model

Profile data: custom fields or a related record

Custom fields on the person cover most member profile data; a related record is right when the data repeats or has a life of its own. The rule for choosing, a worked case for each, the profile route that lets members edit their own details, and what that means for anything that grants access.

Who this is for. The content modeller deciding where a member's extra data lives, and the theme developer building the profile screen. Afterwards you can choose between a custom field and a record, and know exactly what a member is able to change.

The rule

The dataModel it as
One value per member, owned and kept up to date by the member: a mobile number, a preferred store, communication preferencesA custom field on the person
Something the member has several of, or with its own dates, status or history: vehicles, pets, children enrolled in classes, certificatesRecords of a custom entity type, related to the member
Anything that grants access or value: a paid flag, a membership tier, a credit balance, a completion statusNever a field the member can edit: a group, or a record created by the server (below)

Custom fields for the person are defined under Customization › Custom Fields, for the Person entity. The same screen defines fields for users, authors and addresses. A field takes text in any of its editors (text, select, switch, date and the rest), an image, a file, a record or a list of records.

Worked case: a mobile number and a marketing preference

Two custom fields on the person, mobile (Text) and marketingOptIn (Switch). Reading them takes the field's name and a default for a member who has never set it:

Template code
{$person = $user->getPerson()}
<dl>
  <dt>Mobile</dt><dd>{$person->getCustomFieldText('mobile', 'Not given')|escape}</dd>
  <dt>Marketing emails</dt><dd>{if $person->getCustomFieldBoolean('marketingOptIn', false)}Yes{else}No{/if}</dd>
</dl>
{% set person = user.getPerson() %}
<dl>
  <dt>Mobile</dt><dd>{{ person.getCustomFieldText('mobile', 'Not given')|e }}</dd>
  <dt>Marketing emails</dt><dd>{{ person.getCustomFieldBoolean('marketingOptIn', false) ? 'Yes' : 'No' }}</dd>
</dl>

The member edits them through the profile route, below. Each custom field value is posted as one entry under person[customFieldValues]: a value the member has never set names its custom field and input type by id, and an existing value names its own id and its content's id. A partial that writes the hidden fields for a field of text category:

Template code templates/includes/account/custom-field.html, included with field, person and index
{$name = 'person[customFieldValues]['|cat:$index|cat:']'}
{$value = $person->getCustomFieldValue($field)}
{if is_null($value) || $value->isNewRecord()}
  <input type="hidden" name="{$name}[customField]" value="{$field->getId()}">
  <input type="hidden" name="{$name}[content][type]" value="{$field->getType()->getId()}">
  {$text = ''}
{else}
  <input type="hidden" name="{$name}[id]" value="{$value->getId()}">
  <input type="hidden" name="{$name}[content][id]" value="{$value->getContent()->getId()}">
  {$text = $value->getContent()->getText()}
{/if}
<label>{$field->getLabel()|default:$field->getName()|escape}
  <input name="{$name}[content][text]" value="{$text|escape}">
</label>
{% set name = 'person[customFieldValues][' ~ index ~ ']' %}
{% set value = person.getCustomFieldValue(field) %}
{% if value is null or value.isNewRecord() %}
  <input type="hidden" name="{{ name }}[customField]" value="{{ field.getId() }}">
  <input type="hidden" name="{{ name }}[content][type]" value="{{ field.getType().getId() }}">
  {% set text = '' %}
{% else %}
  <input type="hidden" name="{{ name }}[id]" value="{{ value.getId() }}">
  <input type="hidden" name="{{ name }}[content][id]" value="{{ value.getContent().getId() }}">
  {% set text = value.getContent().getText() %}
{% endif %}
<label>{{ (field.getLabel() ?: field.getName())|e }}
  <input name="{{ name }}[content][text]" value="{{ text|e }}">
</label>

Loop over $person->getCustomFields() to include it once per field, rather than writing field ids into templates; ids differ between environments. A switch posts 1 in [content][text] when ticked, and a multiselect posts its keys as [content][values][].

A members' club records each member's vehicles: registration, make, year, and an inspection date that changes every year. A member may have several, each has its own history, and the club lists them. That is a custom entity type vehicle, one record per vehicle:

  • Relating it to the member. Give each record a slug that begins with the member's person id, p<person id>-<registration>, so the member's vehicles can be found in one query.
  • Creating it. The member fills in a Forms module form; a job on the completed submission creates the record (Creating records from a submission). The submission records the signed-in user who sent it, so take the member from there, never from a field in the form, which the member could change.
  • Keeping it private. Switch off URL Addressable on the type, give members read permission on its records, and list them only on member pages, as for entitlement records (The model).
Template code templates/my-account/vehicles.html
{$prefix = 'p'|cat:$user->getPerson()->getId()|cat:'-'}
{$vehicles = $_query->findCustomEntityRecordsBy('vehicle', ['slug_contains' => $prefix], ['position' => 'ASC'])}
{foreach $vehicles as $vehicle}
  {if strpos($vehicle->getSlug(), $prefix) === 0}
    <p>{$vehicle->getName()|escape}, inspection due {$vehicle->getAreaDate('inspectionDue')|format:'date'}</p>
  {/if}
{foreachelse}
  <p>No vehicles yet.</p>
{/foreach}
{% set prefix = 'p' ~ user.getPerson().getId() ~ '-' %}
{% set vehicles = _query.findCustomEntityRecordsBy('vehicle', {'slug_contains': prefix}, {'position': 'ASC'}) %}
{% for vehicle in vehicles|filter(v => v.getSlug() starts with prefix) %}
  <p>{{ vehicle.getName()|e }}, inspection due {{ vehicle.getAreaDate('inspectionDue') ? vehicle.getAreaDate('inspectionDue')|date('j F Y') : '' }}</p>
{% else %}
  <p>No vehicles yet.</p>
{% endfor %}

The query matches slugs that contain the prefix, so the loop keeps only those that begin with it. A custom field could not do this: it holds one value per member, has no dates or history of its own, and the member could rewrite it.

The profile route

A member updates their own details with PUT /profile, route name updateProfile, posted from a form as a POST with _method=PUT and the CSRF token. It renders users/profile.html, which the framework does not ship, with user. On success it redirects back to the profile with "Profile Updated". Three settings in the Users module's Frontend group shape it:

SettingDefaultEffect
Update ProfileOnOff, the PUT answers 404
Require PasswordOnThe form must send currentPassword; a wrong one re-renders the form with a 422 and "Does not match your current password"
Changeable propertiesTitle, gender, first, middle and last name, primary address, addresses, custom field valuesThe person's fields the route accepts. Email can be added; it is not there by default

The person's fields are posted as person[firstName] and so on. The user's username, type and status, and the person's groups, cannot be changed through this route whatever is posted.

A page that takes a form shows the form, and Account routes and their templates lists the route with the others.

Members can edit their own custom fields

With Custom field values among the changeable properties, which is the default, the profile route accepts a value for any custom field on the member's person, not only the fields your form shows. A member who edits the request can set any of them. Never keep anything that grants access or value in a person custom field. Keep it in a group or in a record created by the server, or remove Custom field values from Changeable properties if no custom field is for members to edit. The same route also accepts the user's own custom field values, and no setting turns that off: never keep access in a user custom field either.

Go deeper

Was this page helpful?

Your answer goes to the docs team.