Skip to main content

Member Portal Template Reference

Everything a Member Portal template may rely on. Read this next to the theme authoring guide, which explains how a theme is put together; this file is the reference for what is inside a template.

Templates are Twig 3. They are rendered with autoescape: 'html' — every variable you print is escaped for you — and with strict_variables: false, so reading a variable that does not exist yields nothing rather than an error.

Where templates are resolved from​

The loader looks in two places, in order:

  1. Include/themes/<active theme>/templates/
  2. Include/themes/default/templates/

The first hit wins, so a theme file replaces the core file at the same relative path. The namespace @default always addresses the second list, which is how an override extends the very template it replaces:

{% extends "@default/home.html.twig" %}

The page shell​

Every page extends layout.html.twig, which a theme may also override.

Blocks​

BlockWhat goes in itFilled by default
head_extraExtra <link> / <meta> / <style> for this pageempty
headerThe church identity barpartials/header.html.twig
navThe portal navigationpartials/nav.html.twig
heroA full-width band under the navigationempty
contentThe page itself. Every page template fills this.empty
sidebarAn optional column beside the contentempty
footerThe church contact line and the church's social linkspartials/footer.html.twig
scripts_extraExtra <script> for this page — with nonce="{{ nonce() }}"empty

Partials​

Each is a separate file, so a theme can replace one without touching the others:

PartialRenders
partials/header.html.twigChurch logo and name, and the account menu ("Hello <first name>" → Email History, Change Password, Admin Console for staff, Sign out)
partials/nav.html.twigThe nav entries, and the toggle target #portal-nav
partials/footer.html.twigChurch contact details on the leading edge, church.socialLinks as icon links on the trailing edge
partials/flash.html.twigThe fixed notice container, holding this request's flash messages. A theme that overrides layout.html.twig must keep this include — without it there is no container, and every message the portal raises goes unseen.

What the layout already loads​

The layout supplies all of this; load nothing a second time:

  • ChurchCRM's core CSS and JS bundle (Tabler, Bootstrap 5, jQuery), the RTL variant when the locale is right-to-left
  • moment
  • portal.min.css, then the active theme's theme.css when it has one
  • bootbox (confirmations and prompts), i18next and the locale loader, then portal.min.js, then the active theme's theme.js when it has one
  • whatever <head> and footer HTML the enabled plugins inject

portal.min.js also publishes window.CRM.portalToast(message, type) — type being success, warning, danger or info (the default). Call it from a theme's theme.js to show a message the way the portal shows all of them: a toast in the fixed top-right stack that fades away on its own.

Page IDs and classes worth knowing​

SelectorWhat it is
.portal-body<body>
.portal-shellThe column that holds header, nav, main and footer
.portal-containerThe width-limited wrapper used by every band
#portal-nav / #portal-nav-toggleThe navigation and the button that opens it on a phone
#portal-account / #portal-account-toggle / #portal-account-menuThe header's account menu: its wrapper, the "Hello <first name>" button and the role="menu" dropdown. portal.min.js binds to these ids, so a theme that overrides partials/header.html.twig must keep them
#portal-mainThe <main> element
.portal-cardThe standard content card
.portal-subnavA page's own secondary tab bar, e.g. the two volunteering pages
.portal-page-titleThe <h1> at the top of a page's content
.portal-detail-listA <dl> of label/value pairs — one column on a phone, two from the tablet breakpoint up
.portal-card-detailsThe same <dl> inside a card: one column at every width, because a card is a column and a long email address has nowhere to wrap
.portal-contact-list / .portal-contact-itemThe church office's contact lines on family/none.html.twig — an icon and a mailto: or tel: link
.portal-form / .portal-field / .portal-inputA self-service form, one of its fields, and the control inside it
.portal-field-errorThe inline message for a field; it is hidden while empty
.portal-button / .portal-button-quietThe primary and secondary action buttons
.portal-avatar / .portal-avatar-initialsA member's photo, and the initials shown when there is none
.portal-member-list / .portal-memberThe family members list; the member's own row also carries .is-self
.portal-member-badge / .portal-member-badge-pendingThe "You" badge on a member row, and the "Waiting for review" badge on a proposed member
.portal-dialogA <dialog> in the portal's own chrome
.portal-breadcrumbThe single "← back" link above a nested page's title (My Teams)
.portal-team-card / .portal-team-ministryOne team on My Teams, and the ministry line under a team's name
#portal-toasts / .portal-flashThe fixed notice container, and one notice in it. position: fixed, so a notice never moves the page; see theme authoring guide for its colour tokens

Hooks the page bundles look for​

portal-profile.min.js and portal-family.min.js bind to these ids, so a theme that overrides one of those pages must keep them if it wants the page to work:

IdOn
portal-profile-form, portal-profile-saveprofile/edit.html.twig
portal-photo-input, portal-photo-preview, portal-photo-errorprofile/edit.html.twig
portal-family-form, portal-family-savefamily/edit.html.twig
portal-confirm-form, portal-confirm-submit, portal-confirm-comment, portal-confirm-comment-fieldfamily/confirm.html.twig
portal-add-member-dialog, portal-add-member-form, portal-add-member-open, portal-add-member-submit, portal-add-member-cancelfamily/index.html.twig
portal-team, portal-team-tabs, nav-item-positions / -volunteers / -schedules / -occurrencesteams/team.html.twig

A field's inline message is the element with data-error-for="<field name>" inside the form; a read-only value the bundle refreshes after a save is the element with data-field="<field name>".


Functions​

These, and only these, are callable from a portal template. There is no PHP, no filesystem access, no database access and no session.

FunctionReturns
url(path)A path inside this installation, with the install's root path in front: url('/portal/profile')
asset(path)A core ChurchCRM asset URL, cache-busted: asset('/skin/v2/portal.min.js')
theme_asset(path)A file from the active theme, cache-busted: theme_asset('images/hero.jpg'). If the active theme does not carry the file but the system theme does, you get the system theme's copy.
csrf_field()The hidden input every portal form must contain. Safe HTML — do not escape it.
nonce()This request's Content-Security-Policy nonce. Every inline <script> needs nonce="{{ nonce() }}".
gettext(text)The translation of text
ngettext(singular, plural, count)The translation of singular/plural for count
email_wrap (filter)An email address, escaped, that may wrap after the @ and before each . instead of mid-word: {{ profile.email|email_wrap }}. Safe HTML — do not escape it again.

Globals​

Available in every template, including yours.

church​

The church's identity, from Admin → Church Information.

FieldTypeNotes
namestringMay be empty on a brand-new install
address, city, state, zipstring
phone, emailstring
websitestring
logoUrlstringThe uploaded church logo, or ChurchCRM's stock image when none is set
logoOnDarkUrlstringThe same church logo, but when none is set ChurchCRM's light-on-dark mark instead of the stock image. The default header, which is dark, uses this one
socialLinkslistThe church's social accounts, already filtered to the ones that are set and ordered X, YouTube, Facebook, Instagram. Each entry is {id, label, url, icon} — icon is a Font Awesome Free brand class such as fa-brands fa-facebook. Empty when the church has configured none, so {% if church.socialLinks is not empty %} is the whole guard you need.

member​

The signed-in person. Always the session's own person — no template, and no portal route, ever addresses somebody else.

FieldTypeNotes
idintThe person id
firstName, lastNamestring
fullNamestringFormatted per the installation's name format
familyNamestringThe family's surname; empty when the person has no family
emailstring
avatarUrlstring/api/portal/me/photo, cache-busted; empty when no photo has been uploaded — render initials instead
familyIdint0 when the person has no family
isTeamLeaderbooltrue when this person leads at least one volunteer team. Also true on a self-service login — that is the point of it
isStaffbooltrue for a login that also has the admin shell — the account menu offers it "Admin Console"

An ordered list of entries. Render all of them; the list is already filtered to what this member may see.

FieldTypeNotes
idstringStable identifier, e.g. home
labelstringAlready translated
urlstringReady to use in href
iconstringA Font Awesome class, e.g. fa-solid fa-house
activebooltrue for the page being rendered
badgestringA short count or flag; empty when there is nothing to show

flash​

One-shot messages queued by the previous request. Reading them empties the list, so a reload never repeats them.

FieldTypeNotes
typestringinfo, success, warning or danger
messagestringAlready translated

portal​

Facts about this request and this installation.

FieldTypeNotes
rootPathstringThe install's root path — '' at a domain root, /churchcrm in a subdirectory
themeNamestringThe active theme's folder name
localestringe.g. en_US
isRTLboolRight-to-left locale
colorModestringauto, light or dark — the member's own choice
developerModebooltrue when the template cache is off
hasThemeCssboolWhether a theme.css is available to link
hasThemeJsboolWhether a theme.js is available to load
pluginHeadHTMLThe <head> content enabled plugins inject — print it verbatim
pluginFooterHTMLThe footer content enabled plugins inject — print it verbatim
bootstrapJsonJSONThe window.CRM seed the core bundles expect
localeConfigJsonJSONThe argument for window.CRM.loadLocaleFiles(…)

The last four are pre-rendered fragments the layout emits as-is. Print them without |escape and without |raw — they are already marked safe. If you override layout.html.twig, keep emitting all four, or plugins and translations will stop working on your pages.


Page templates​

TemplateRendered forIts own variables
home.html.twigGET /portalpageTitle, upcomingEvents, hasVisibleCalendars, showVolunteering, familySummary, profile
calendar/index.html.twigGET /portal/calendarpageTitle, calendars, hasCalendars, calendarConfigJson
volunteer/schedule.html.twigGET /portal/volunteer/schedulepageTitle, activeTab
volunteer/opportunities.html.twigGET /portal/volunteer/opportunitiespageTitle, activeTab
volunteer/partials/tabs.html.twigincluded by both volunteering pagesactiveTab
profile/index.html.twigGET /portal/profilepageTitle, profile
profile/edit.html.twigGET /portal/profile/editpageTitle, profile
profile/password.html.twigGET/POST /portal/profile/password (every role); also GET/POST /v2/user/current/changepassword for a self-service sessionpageTitle, formAction, minPasswordLength, oldPasswordError, newPasswordError
profile/password-changed.html.twigEither of those routes, after a successful changepageTitle
profile/two-factor.html.twigGET /portal/profile/two-factor (every role); also GET /v2/user/current/manage2fa for a self-service sessionpageTitle
family/index.html.twigGET /portal/familypageTitle, family, members, canEdit, canConfirm, familyRoles, defaultNewMemberRoleId
family/edit.html.twigGET /portal/family/editpageTitle, family, members, canEdit, countries
family/confirm.html.twigGET /portal/family/confirmpageTitle, family, members, canEdit, canConfirm
teams/index.html.twigGET /portal/teamspageTitle, teams
teams/team.html.twigGET /portal/teams/{teamId}pageTitle, team
teams/occurrence.html.twigGET /portal/teams/{teamId}/occurrences/{occurrenceId}pageTitle, team, occurrence
family/none.html.twigAll three family URLs, for a member with no familypageTitle, officeEmail, officePhone, officePhoneHref
email/index.html.twigGET /portal/email-historypageTitle, emails, page, pages, total, previousUrl, nextUrl
email/show.html.twigGET /portal/email-history/{id}pageTitle, email, body, hasBody
errors/403.html.twigA page this member may not openpageTitle
errors/404.html.twigAn unknown portal URL (and 405)pageTitle
errors/500.html.twigAn unexpected failurepageTitle
errors/unavailable.html.twigShown to members when the active theme fails to rendernone
errors/theme-error.html.twigShown to administrators when the active theme fails to renderthemeName, file, line, message

pageTitle is the page's own title; the layout puts the church's name after it.

formAction on profile/password.html.twig is the URL that page's form posts back to. It exists because the same template serves two routes: the portal's own page, and the forced first-login change at /v2/user/current/changepassword, which is pinned to its own URL until it completes. An override must post to formAction, not to a hard-coded path, or a forced password change will loop.

profile​

The signed-in member's own person record, as GET /api/portal/me returns it.

FieldTypeNotes
idintThe person id
title, firstName, middleName, lastName, suffixstring
email, workEmailstring
homePhone, cellPhone, workPhonestring
birthdaystringISO YYYY-MM-DD, empty when unknown
fullNamestringFormatted per the installation's name format
familyRolestringe.g. Spouse; Unassigned when the person has no role
familyIdint0 when the person has no family
familyNamestringThe family's surname
photoUrlstring/api/portal/me/photo, cache-busted; empty when no photo has been uploaded — render initials instead
hasPhotoboolWhether a photo has been uploaded
canEditBirthdayboolMirrors bPortalAllowBirthdayEdit; when false the birthday is neither shown nor accepted

family​

The member's own family. Never another family: the routes take no family id.

FieldTypeNotes
idint
namestringThe family's surname
address1, address2, city, state, zip, countrystring
homePhone, emailstring
weddingDatestringISO YYYY-MM-DD, empty when unset

members​

Everyone in the member's family, adults first, as Family::getPeopleSorted() orders them.

FieldTypeNotes
idint
fullNamestring
rolestringThe family-role name
email, cellPhonestring
photoUrlstring/api/portal/family/members/{id}/photo (or /api/portal/me/photo for the member's own row), cache-busted; empty when that member has no photo
initialsstringThe two-letter stand-in for a missing photo
isSelfbooltrue for the signed-in member's own row
isAdultbooltrue for a head or spouse — the roles sDirRoleHead and sDirRoleSpouse name
awaitingReviewbooltrue for someone a member proposed with "Add a family member" whom staff have not approved yet (People → Self Registrations)

canEdit, canConfirm, countries, familyRoles, familySummary​

VariableTypeNotes
canEditboolWhether this member may change the family's details — true only for an adult of the family
canConfirmboolWhether the "Confirm your family details" card is offered
countriesmapCountry code → country name, for the address form's <select>
familyRoleslist{id, name} for each family role, for the "add a family member" form
defaultNewMemberRoleIdintThe role that form starts on — the configured child role, not head of household
familySummaryobjectOn the home page only: {name, memberCount}, or null when the member has no family
profileobjectOn the home page too: the same profile the Profile page gets, or null for an account with no person record

A member with no family​

/portal/family, /portal/family/edit and /portal/family/confirm all render family/none.html.twig when the acting member has no family record — and when the account has no person record at all. It is an ordinary portal page: HTTP 200, the usual chrome, "My Family" still the active navigation entry. Only URLs that really do not exist get errors/404.html.twig.

VariableTypeNotes
officeEmailstringAdmin → Church Information's email. Empty when unset — leave the line out rather than linking to nowhere
officePhonestringThe church's phone, exactly as it was typed. Print this
officePhoneHrefstringThe same number reduced to what a tel: link can dial — digits, plus a leading + for an international number. Empty when the church configured no phone, or typed one with no digits in it

With neither configured the system theme drops both lines and says only "Please contact the church office."

The home page's Profile card​

The card prints the member's own details rather than a sentence describing them. It reads them out of profile — the very view-model profile/index.html.twig renders, so the two pages can never disagree — and skips every empty field: with nothing on file it says "No contact details on file yet." The birthday follows the Profile page and appears only while profile.canEditBirthday is true, and the family role is shown only when profile.familyId is non-zero. Values sit in <dd data-field="…"> inside #portal-home-profile-details, with the same field names the Profile page uses.

teams, team and occurrence (My Teams)​

teams is the list My Teams renders, one entry per team this member runs, ordered by ministry then team.

FieldTypeWhat it is
idintThe team
namestringThe team's name
ministryId / ministryNameint / stringThe ministry above it
activeboolFalse for a deactivated team; the card says so
positionCountintHow many positions the team owns, active or not
nextOccurrenceobject{id, date} for the soonest scheduled date from today, or null
urlstringThe team's page, root-path aware

team on the two nested pages is {id, name, description, active, ministryId, ministryName} — description is '' when the team has none.

occurrence is {id, scheduleName, status, date, start, end, eventId, eventTitle, eventLocation}. status is 'scheduled' or 'cancelled'; start is YYYY-MM-DD HH:MM and end is HH:MM, both '' for an occurrence with no resolved window; eventId is 0 when the date is not linked to a calendar event, and the two event fields are '' then.

The two theme-failure pages are always rendered from the system theme, so a broken theme cannot break the page that reports it. A theme may still override them — its version is used everywhere except when that theme is the one that failed.

The home page's calendar card​

upcomingEvents is the next three events from the calendars the church shares — {title, when, start, allDay, calendarName}, where when is a date string in the installation's own format and start the raw local start. The system theme prints when inside <time datetime="{{ event.start }}" data-format="daytime"> (day for an all-day event), which the portal's script rewrites in the church's locale ("Sunday, October 4 at 9:30 AM"), matching the volunteering cards. It is empty when nothing is coming up, and hasVisibleCalendars is false when the church has shared no calendar at all; the two cases read differently to a member, so the system theme says "Nothing is on the calendar just now." for the first and "No calendar has been shared with members yet." for the second.

The card is only rendered when portal.showCalendar is on.

The calendar page​

calendars is the legend: {key, name, color, isMine} for each calendar an administrator switched on, in the order Admin → Member Portal → Calendars lists them, with color a CSS colour ready for a swatch and key the calendar:<id> / system:<id> string that every event of that calendar carries. hasCalendars is false when nothing is shared, and the page then says so instead of drawing a grid.

The events are not in the template. FullCalendar fetches GET /api/portal/calendar/events?from=…&to=… for the window it is showing, so paging through months costs one small request each. An override must therefore keep three things, which the page's bundle looks for:

KeepWhy
<div id="portal-calendar">Where FullCalendar renders
<section id="portal-calendar-detail"> and its portal-calendar-detail-* idsThe panel an event click fills in — title, when, where, calendar, details
window.CRM.portalCalendar = {{ calendarConfigJson }} plus asset('/skin/v2/portal-calendar.min.js')The endpoint, the church's timezone, the window cap, and the two subscription endpoints

Each legend entry may also be a switch. A <button type="button" data-calendar-key="{{ calendar.key }}" aria-pressed="true"> hides and shows that calendar's events without reloading, and the bundle remembers the choice in the member's browser (localStorage, key churchcrm.portal.calendar.hidden). The bundle sets aria-pressed itself; style the off state with [aria-pressed="false"]. The default theme sets the swatch colour as --portal-calendar-swatch on the swatch, so the off state can draw it hollow. A legend without such buttons stays a plain legend and every event is shown, whatever the browser stored earlier.

Subscribing to the calendar​

The Subscribe button opens a dialog where a member ticks the calendars they want in their own calendar app and gets one address back. The bundle draws the checkboxes and fills in the address from GET /api/portal/calendar/subscription, so none of it is server-rendered: the feed address is a bearer secret and is fetched only when the dialog opens.

A theme that keeps the feature must keep these ids:

KeepWhat it is
#portal-calendar-subscribeThe button that opens the dialog
#portal-calendar-subscribe-dialogThe dialog itself
#portal-calendar-subscribe-formA <form> carrying {{ csrf_field() }} — the bundle reads the token out of it
#portal-calendar-subscribe-choicesThe empty container the checkboxes are drawn into
#portal-calendar-subscribe-errorWhere a refused save is reported
#portal-calendar-subscribe-save, #portal-calendar-subscribe-closeSave, and dismiss
#portal-calendar-subscribe-resultThe address block, hidden until there is an address
#portal-calendar-subscribe-urlA read-only <input> holding the feed address
#portal-calendar-subscribe-copyCopy to clipboard (falls back to selecting the text)
#portal-calendar-subscribe-open-rowThe paragraph holding the link below. Starts hidden; the bundle reveals it only when the feed address is https
#portal-calendar-subscribe-openAn <a> whose href the bundle sets to the webcal:// address
#portal-calendar-subscribe-manual-hintThe sentence shown instead of that link when the site is on plain http — Apple's calendar clients rewrite webcal:// to https:// and never fall back, so the one-tap link cannot work there
#portal-calendar-subscribe-resetOpens the confirmation below
#portal-calendar-reset-dialog with #portal-calendar-reset-confirm / #portal-calendar-reset-cancel"Get a new calendar address?"

A theme that wants none of this drops the button and both dialogs together; the bundle does nothing when the button is absent.

calendarConfigJson is a pre-rendered fragment like the four above: print it inside a <script nonce="{{ nonce() }}"> without |escape and without |raw. Link asset('/skin/v2/portal-calendar.min.css') from head_extra — that is FullCalendar's own stylesheet, not portal styling.

A theme that would rather draw its own calendar can drop all of this and call the same endpoint itself; it is session-gated and needs no token.

The volunteering pages​

showVolunteering is true when the installation offers the volunteering pages; the home page renders its "My volunteering" card only then. activeTab is 'schedule' or 'opportunities' and is what the tab partial highlights.

The two volunteering pages are container markup and nothing else. Their ids — #volunteer-my-schedule, #assignments-loading / -error / -empty / -content, #substitute-modal and friends on one page; #volunteer-opportunities, #help-wanted-section / -content, #opportunities-loading / -error / -empty / -content on the other — are read by skin/v2/portal-volunteer-schedule.min.js and skin/v2/portal-volunteer-opportunities.min.js, which fill them from /api/ministries/me/*. A theme overriding either template must keep every id, or the page renders empty. Override the wrapper, the headings and the surrounding layout freely.

The team and occurrence pages are container markup too. teams/team.html.twig is filled by skin/v2/portal-teams.min.js and teams/occurrence.html.twig by skin/v2/ministries-occurrence.min.js — the admin occurrence page's bundle, unchanged. Both address their markup by id, and those ids are deliberately the same ones the admin ministry and occurrence views carry, so the same components can draw either. A theme overriding one of these templates must keep every id it finds there: volunteerPositionsTable, volunteerQualificationsTable, volunteerSchedulesTable, volunteerOccurrencesTable and their -loading / -error / -empty / -table-wrapper state blocks; positionModal and scheduleModal with their *-form-* fields; and on the occurrence page requirements-*, swaps-*, volunteer-assign-modal and volunteer-needs-modal. Override the wrapper, the headings, the tab strip and the surrounding layout freely.

A control a theme removes is not an error: every one of those bundles looks its controls up by id and does nothing when one is absent. That is how the portal's own templates leave out the things a team leader may not do.

Every page the epic promised is in this table now.

Email History​

email/index.html.twig is a paginated list of what the church has emailed this member — reached from the header's account menu, not from the main navigation, so no nav entry is active while it is open. Only mail that was sent is listed, and rows carry no delivery status: queued, failed and skipped mail is for staff, and an unsent row's detail URL is the same 404 as somebody else's.

emails is the page's rows, newest first. Every field is a finished string; nothing here needs formatting or reasoning about in a template:

FieldTypeNotes
idintUse it to build the detail URL
subjectstringEmpty for an email that was sent without one — print (no subject)
kindLabelstringThe kind in words: Message, Birthday greeting, Password reset link, …
addressstringThe address the message went to
dateSentstringFormatted in the installation's date format, with the time
hasBodyboolFalse for the account emails, whose content is never stored

page, pages and total drive "Page X of Y"; previousUrl and nextUrl are query strings (?page=2) and are empty strings at the ends of the list, which is how the template knows not to draw that link. The list is server-rendered — there is no bundle on this page — but the same rows are available to a theme that would rather fetch them, at GET /api/portal/me/emails?page=&limit=.

The list is one <table class="portal-table"> at every width. On a phone _portal.scss unrolls it into a stack of blocks, taking each cell's column name from its data-label attribute, so an override that keeps the table markup gets the phone layout for free.

email/show.html.twig is one email: email is a single row in the shape above, hasBody says whether there is anything to show, and body is the stored HTML of the message.

body goes in exactly one place: the srcdoc of an iframe with an empty sandbox attribute. It is markup somebody else wrote, kept as it was sent. Twig's autoescaping is what makes the attribute safe; |raw on it, or printing it anywhere in the document, hands a member's browser foreign markup with the portal's own origin behind it. The frame does not grow to fit its content — an empty sandbox puts the document in its own opaque origin, so its height cannot be measured from the page, and the two ways to measure it (allow-same-origin, or a script inside the frame) are the two permissions this page must not grant. It scrolls instead.

Scope: the page shows the rows addressed to this person. Email sent to the family's shared address is not included — see src/api/routes/portal/portal-emails.php.


Compatibility​

Core may add a block, a variable or a global at any time; that never breaks a theme. Renaming or removing one is called out in the release notes, and the old name keeps working for one release.

The validator's "this version of ChurchCRM does not use a template at this path" warning is how a theme finds out that a template it overrode has moved — run Check on Admin → Member Portal after upgrading.

Can't find what you need? Ask in Discord, or open an issue on the ChurchCRM repository.