Custom Lists: Design and UserMeta Persistence
Overview
CustomList and CustomListValue provide organisation-scoped lookup values that events can attach to each EventParticipant in up to three configurable slots (custom_1_id, custom_2_id, custom_3_id). Typical uses are Club, Team, School, and any other organisation-specific classification the operator wants to capture at registration.
This document covers:
-
the two entities and their relationships,
-
how an
Eventconfigures which slots are used and whichCustomListpopulates each, -
how a selection made during registration is both stored on the
EventParticipantand persisted to the Person viawp_usermeta, so the value can be carried forward automatically to the rider’s next event.
|
Throughout this document Person and User refer to the same natural person. The current backing table is |
Entities
CustomList
Organisation-scoped container of lookup values.
| Field | Purpose |
|---|---|
|
Primary key |
|
Unique internal name of the list (e.g. "WP Clubs 2026") |
|
Label shown on-screen or in reports (e.g. "Club") |
|
|
|
Key into |
|
Owning |
CustomList implements OrganizationalSecured — access is scoped by organisation.
CustomListValue
An entry within a single list.
| Field | Purpose |
|---|---|
|
Primary key |
|
Authoritative human-readable name. Used as the |
|
Optional short code (e.g. "WPCC"). Used as a filter or in condensed displays when |
|
Parent |
CustomListValue is not directly organisation-secured — it inherits its access scope from its parent CustomList.
Event configuration
Event exposes three slots. Each slot has three config attributes:
| Slot | Column | Meaning |
|---|---|---|
1 |
|
Pointer to the |
1 |
|
Display label for this slot on forms and reports (may differ from the list’s own |
1 |
|
|
2 |
|
Pointer for slot 2 |
2 |
|
Display label for slot 2 |
2 |
|
Required flag for slot 2 |
3 |
|
Pointer for slot 3 |
3 |
|
Display label for slot 3 |
3 |
|
Required flag for slot 3 |
If custom_list_{n}_id is NULL, slot n is unused on that event. An event can use any subset of the three slots; the slot index is just a positional identifier, not a stable meaning across events.
EventParticipant storage
For each slot the Event has configured, the rider’s selection is stored as a foreign key on the EventParticipant:
-
custom_1_id→CustomListValue.id -
custom_2_id→CustomListValue.id -
custom_3_id→CustomListValue.id
Each of these is optional; NULL is valid when the corresponding slot is unused or not required. The reference must point to a CustomListValue whose parent CustomList matches the event’s configured custom_list_{n}_id. The integrity of this relationship is a registration-flow invariant, not a database constraint.
UserMeta Persistence
CustomList.meta_key is the linchpin that lets a rider’s current club/team/school/etc. persist across events and get pre-populated on the next registration without the rider having to re-enter it.
The contract
-
On registration or EP creation (including EP import), for every slot the Event has configured with a
custom_list_{n}_id:-
Resolve the
CustomListand read itsmeta_key. -
Upsert
wp_usermeta(user_id = person.id, meta_key = CustomList.meta_key, meta_value = CustomListValue.name). -
The upsert is "latest wins" — a new selection overwrites any earlier value stored under the same
meta_keyfor the same person.
-
-
On EP creation for a subsequent event that also uses a slot configured with a
CustomList:-
Read the
CustomList.meta_keyfor each configured slot. -
Pull the current
wp_usermeta.meta_valuefor(user_id, meta_key). -
Find a
CustomListValueon the currently-configured list wherename = meta_value. -
If found, pre-populate
EventParticipant.custom_{n}_idwith that CLV’s id. -
If no UserMeta row exists, or no name match is found on the currently-configured list, leave
custom_{n}_id = NULL.
-
meta_value is the CLV name, not the id
The value stored in wp_usermeta is the CustomListValue.name, not its id. Two consequences:
-
Names are human-readable — the value stays meaningful if the admin ever inspects the raw
wp_usermetatable, and it matches the convention used by the legacywpca_membersplugin. -
List PKs can change freely between seasons. The organisation commonly creates a new
CustomListper season (e.g. "WP Clubs 2025" → "WP Clubs 2026") to keep the roster clean. Because the UserMeta row points to a name, a rider whose club name is unchanged between seasons will be matched against the new list transparently. Only when the club name itself changes (a rename, a merger) does the lookup miss.
Unmatched lookups
If Phase 2 cannot find a CustomListValue on the current event’s list with name = meta_value, the EP field is left NULL. Deliberate choices:
-
No auto-creation of CLV rows. The list is curated; introducing a row because a UserMeta value does not match could quietly propagate mis-spellings or obsolete clubs.
-
No error. A registration flow that blocks on an unmatched historical value would be hostile; the rider can simply pick again from the current list.
-
Observable. The EP import response surfaces an unset-but-expected slot in its issues list so an operator can triage.
Stages where the contract applies
The contract fires wherever an EventParticipant row is created or has its custom_{n}_id set:
-
The registration flow (
EventFormController.onDone()and equivalents). -
The EP import path (
EventParticipantImportXLS.readStream()). -
The admin EP edit screen, when a slot value is changed.
The save on EventParticipant and the upsert on wp_usermeta are in the same transaction so the two writes cannot diverge.
One-directional flow
The contract applies forward only:
-
EP change → UserMeta upsert: yes.
-
UserMeta edit (from an admin UI or elsewhere) → propagate back into existing EPs: no.
An admin who wants to correct a rider’s club on an already-created EP edits the EP directly. This keeps the flow predictable and avoids "hidden" changes to historical events when a rider updates their profile.
Worked example
WPCA 2026 Autumn Road League:
-
Organisation: WP Cycling (
organisation_id = 4). -
Two lists configured across the series:
-
CustomList id 1504— "WP Clubs 2026",meta_key = "club_name". -
CustomList id 1505— "WP Teams 2026",meta_key = "team_name".
-
-
Each Road League
Eventhas:-
custom_list_1_id = 1504,custom_list_1_name = "Club",custom_list_1_required = "0"(optional). -
custom_list_2_id = 1505,custom_list_2_name = "Team",custom_list_2_required = "N"(optional).
-
Rider Jane Doe registers for event 119 (Duynefontein) and picks "WPCC" as her club and "Gravel Devils" as her team:
-
EventParticipant.custom_1_idis set to theCustomListValuewithname = "WPCC"on list 1504. -
EventParticipant.custom_2_idis set to theCustomListValuewithname = "Gravel Devils"on list 1505. -
wp_usermetafor Jane is upserted with:-
(meta_key = "club_name", meta_value = "WPCC"), -
(meta_key = "team_name", meta_value = "Gravel Devils").
-
Two weeks later Jane registers for event 120 (Redhill), which is configured identically. The registration flow reads Jane’s wp_usermeta and pre-populates both slots — no manual entry. If the Road Commissioner later creates "WP Clubs 2027" (a new CustomList with meta_key = "club_name" and a fresh PK) that still contains a CLV named "WPCC", the same carry-forward works across the seasonal list swap because the lookup is by name.
If Jane changes her club to "BMT" at event 120, the registration flow:
-
writes the new
custom_1_idon the new EP, -
overwrites the
club_nameUserMeta row with"BMT"(latest wins), -
does not touch her existing EP for event 119 (which still shows "WPCC" — the record of how she raced that day).
Related documentation
-
Common Entities — entity reference for
CustomListandCustomListValue. -
Form System Architecture — how registration-flow
FormFieldclasses present and validate CustomList selections. -
Event Participant Import — how the EP import path resolves CustomList columns and triggers the UserMeta contract.