Problem/Motivation
The crm_membership entity currently stores which contacts belong to a
membership using an unlimited multi-valued entity reference field
(contacts) on the membership itself, plus target_contact
for the organization (or other entity) the membership applies to.
That design does not scale for larger memberships:
-
Administrative UX — loading or saving the membership edit form
with hundreds or thousands of members forces the form layer to build and process
a very large number of widgets for a single entity. -
Request and server limits — even with higher modern limits, very
large forms increase risk of hitting practical constraints (for example PHP
max_input_vars) and make saves slow and fragile. -
Domain fit — CRM already models contact-to-contact links with
thecrm_relationshipentity (two contacts per relationship, typed
bundles, revision, limits). Representing “person is a member of organization” as
many references on one membership row duplicates that concept instead of using
the native relationship model.
For an enterprise-oriented membership workflow, the roster should be represented as
many small records (one logical link per person ↔ organization),
not as a single entity carrying an unbounded list of references.
Steps to reproduce
-
Create a
crm_membershipwithtarget_contactset to an
organization contact. -
Add a large number of contacts to the
contactsfield (hundreds or
more, depending on environment). -
Open the membership edit form and observe degraded performance; saving may become
unreliable or hit limits as the roster grows.
(Exact thresholds vary by hosting; the underlying issue is unbounded cardinality
on one entity form and submit.)
Proposed resolution
Refactor the module so that each affiliation between a member contact and
the membership’s target organization is represented by a
crm_relationship (for example an asymmetric
“Member” relationship type: person on one side, organization on the other), rather
than a delta on crm_membership.contacts.
The relationship entity carries a field referencing the current
crm_membership for that affiliation — not a growing list of every past
product on the default (“live”) field. For example, when a member upgrades to a new
plan that takes effect immediately and the previous plan is no longer active, the
field holds only the current membership (one entry in that
scenario). Prior plans are not kept as extra deltas on the live field. When the
relationship becomes inactive, the field may retain the last membership reference
in effect at deactivation, as agreed in implementation.
Historical membership values on the link are retained via
crm_relationship revisions: viewing or comparing past
revisions still shows prior values of the membership reference field (for example
the pre-upgrade plan). Revision metadata therefore supplies the audit trail without
storing a growing list of past memberships on the default field.
Follow-on work in the same effort should align membership periods
and any subset-of-members semantics (for example
applicable_contacts on periods) with the new model so “who is
covered” is not duplicated in conflicting ways.
Remaining tasks
-
Agree on relationship bundle naming, direction (contact A vs B for person/org),
and validation rules (including CRM relationship limits). -
Design the relationship field that references
crm_membership
(cardinality rules for “current” state, revisionability so prior references remain
in history, and behavior when the relationship is marked inactive). -
Replace or reimplement APIs that assume
Membership::getContacts()/contactsas the source
of truth (including membership term plugins, views, and admin flows). -
Provide admin UX for roster management at scale (inline relationship forms,
bulk operations, or delegated sub-forms — not one giant membership form). -
Write update/migration path from existing
contactsdata to
relationships (and field backfills). - Update tests and developer documentation.
User interface changes
Membership editing will move away from maintaining the full member list on the
membership entity form. Editors will manage members via relationship-centric
workflows (list/add/edit/remove links between person and organization), possibly
with bulk tools or embedded views on the membership page.
API changes
Public methods and services that expose “contacts on this membership” will need to
resolve members via crm_relationship queries (or injected services)
instead of reading contacts field items. Callers that set or append
contacts on the membership entity will need new APIs (for example “ensure
relationship exists for this contact and membership context”).
Breaking changes are expected; note a major release and migration path in the
issue and release notes.
Data model changes
Remove the contacts field from
crm_membership. Roster membership is represented only by
crm_relationship rows between person and organization, each with a
field pointing at the current crm_membership as described above.
There is no deprecation phase for contacts; existing sites migrate in
the update path.
Adjust crm_membership_period (and related views /
logic) so “applicable contacts” or equivalent semantics remain consistent — either
derived from relationships, narrower than the full roster, or redesigned with the
same migration.
A data update hook (or migration) must create relationships from existing
contacts values and preserve referential integrity with periods and
other dependents.
Issue fork crm_membership-3583135
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
jdleonardCompletely agree with the problem. The proposed resolution generally makes sense to me. The following questions/concerns, centered around the proposed storage of historical membership data as revisions of a membership Relationship, quickly came to mind:
I wonder whether it would be better to have a Relationship entity per membership period. The period for a given membership Relationship could be immutable while changes to some other details of the membership could be tracked using revisions. Listing or referencing past memberships becomes straightforward. Of course, I'm undoubtedly missing some gotchas.
Comment #3
svendecabooterI think this could potentially be a useful refactor.
I'm wondering whether this isn't also a problem with the initial premise. If there are 100s of contacts being a member of an organization, why would they all be linked in the contacts field of 1 crm_membership entity.
IMHO that should just be 100s of crm_membership entities then, each managing the membership between the Person entity and the Organization entity.
I guess there could be use cases where 100s of contacts need to be linked to the same crm_membership, e.g. if you want to renew / cancel them all at once...
If this gets refactored with using relationships, we should also make sure the UX does not get significantly more complex.
I also agree with jdleonard that the history of past memberships might get buried away too much if it is kept in revisions. This would also need a good UX / DX check then to be able to retrieve that info easily.
Comment #4
mortona2k commentedHere is a new proposal that I think will simplify things and still support the capability we need.
1. Combine Membership and MembershipPeriod, moving the date field over and dropping the rest in period.
2. Change Membership contacts and target_contact fields to reference a CRM Relationship.
A Membership entity references the MembershipType, with start and end dates for the period.
The CRM Relationship start and end date track the total lifespan of the relationship.
For example, an organization could have a "Member of" relationship created when they sign up, and can buy a 1 year premium membership.
The membership tracks the start and end date, and references the relationship to track the contact/org.
When the premium membership lapses, they can buy a new one that instantiates a new Membership.
I think having a Membership reference a Relationship will cut down on the number of relationships admins see in the UI.
There is a comment on the Membership status field about using workflow or state machine. I'm wondering if that is something Relationship needs instead? For example, the "Member of" relationship could have an inactive status after the Membership lapses.
Comment #6
mortona2k commentedMy PR is a work in progress, and atm is almost entirely vibe coded.
I'm working on cleaning up and fixing tests.