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
The identity modelPage 4 of 36Updated
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 data
Model it as
One value per member, owned and kept up to date by the member: a mobile number, a preferred store, communication preferences
A 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, certificates
Records 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 status
Never 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:
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 codetemplates/includes/account/custom-field.html, included with field, person and index
{% 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][].
Worked case: a member's vehicles
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).
{% 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:
Setting
Default
Effect
Update Profile
On
Off, the PUT answers 404
Require Password
On
The form must send currentPassword; a wrong one re-renders the form with a 422 and "Does not match your current password"
Changeable properties
Title, gender, first, middle and last name, primary address, addresses, custom field values
The 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.
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.