The Enrollments module adds canonical user-to-post enrollment rows, an effective activity projection, and a structure-aware post-side enrollment projection. It is designed to work with levels, access gates, Bricks Builder, redirects, and Drip without introducing a second derived structure table or per-request ancestry scans on hot paths.
Runtime Ownership
- Canonical enrollment source rows:
wp_brm_enrollments - Effective user-post activity projection:
wp_brm_enrollment_activity - Effective post-side requirement projection:
wp_brm_post_data - Canonical authored post-side rule state: dedicated post meta keys such as
_brm_enrollment_rule_mode
This split is intentional. Canonical writes do not live in wp_brm_post_data, because that table is part of the derived runtime projection layer. The authored tri-state rule for a post now survives normal WordPress editing states because it is stored on the post itself and then projected into wp_brm_post_data.
Key Services
EnrollmentService: canonical write service for source rows, state refresh, events, scheduling side effectsEnrollmentQueryService: read model for source rows and effective enrollment stateEnrollmentRequirementService: authored rule normalization plus local target resolutionEnrollmentRequirementProjectionService: computes and writes effective enrollment owner/target projection towp_brm_post_dataEnrollmentActivityService: maintains current-access timing projectionEnrollmentScheduleService: exact timestamp due-bucket queue for scheduled activation/expiryEnrollmentAutomationService: level-driven automatic enrollment rules
Authored Rule Model
Each post uses a tri-state authored rule:
requiredinheritdisabled
When the rule is required, the post also defines a target mode:
current_postparent_leveltop_leveltracking_levelspecific_post
The authored API lives in EnrollmentRequirementService and should be used instead of direct writes to PostDataService.
$rule = EnrollmentRequirementService::get_instance()->get_authored_post_enrollment_rule( $post_id );
EnrollmentRequirementService::get_instance()->save_authored_post_enrollment_rule(
$post_id,
array(
'enrollment_rule_mode' => 'required',
'enrollment_target_mode' => 'current_post',
'enrollment_target_post_id' => 0,
)
);
Effective Inheritance Semantics
inherit does not mean “copy the parent’s literal target mode and reinterpret it relative to the child.” That would break current_post semantics. Instead, the child reuses the ancestor’s already-resolved effective anchor.
Examples:
- Course =
required + current_post, Lesson =inherit→ lesson checks course enrollment - Module =
required + current_post, Lesson =inherit→ lesson checks module enrollment - Ancestor =
required + specific_post→ descendant checks the same specific anchor - Ancestor =
disabled→ inherited descendants are not enrollment-gated unless they define their ownrequiredrule
This effective rule is calculated by EnrollmentRequirementProjectionService::calculate_effective_post_enrollment_projection() and stored as:
enrollment_requiredeffective_enrollment_owner_post_ideffective_enrollment_target_post_id
Why the Projection Matters
Hot paths such as access checks, Bricks conditions, dynamic tags, and query loops should not walk ancestor chains every time. They should read the effective projection from wp_brm_post_data.
That is why runtime consumers read:
ContentAccessGateServiceBricksConditionsRegistrarBricksQueryFiltersEnrollmentDynamicTagsDripServicewhen usingpost_enrollment_activated
Only rebuild/cascade flows should do ancestry work, and even there the code should stay inside the services that own it.
Canonical Enrollment Writes
Source rows live in wp_brm_enrollments. One row represents one grant source for one user and one anchor post.
Important fields:
user_idpost_idstatussourcesource_refenrolled_atactivated_atexpires_at
Use EnrollmentService for all writes. Do not mutate the tables directly from UI controllers or Bricks integration code.
$result = EnrollmentService::get_instance()->upsert_post_enrollment_source(
$user_id,
$post_id,
array(
'source' => 'manual',
'source_ref' => 'support-ticket-193',
'activated_at' => '2026-03-26 09:00:00',
'expires_at' => '2026-06-26 09:00:00',
)
);
The service recalculates effective state, updates activity projection, emits events, schedules due work, and invalidates caches.
Effective Enrollment State
EnrollmentQueryService collapses multiple source rows into one effective state for a given user_id + post_id.
Main rules:
- active rows win over scheduled rows
- scheduled rows win over paused or terminal rows
- multiple active rows collapse to one effective state with
source = multiple - the activity projection provides stable
current_access_started_atfor automations and drip timing
Public Runtime API
brm_user_has_active_post_enrollment( $user_id, $post_id );
brm_get_user_post_enrollment_state( $user_id, $post_id );
brm_get_user_enrolled_post_ids( $user_id, array( 'status' => 'active' ) );
brm_get_post_enrolled_user_ids( $post_id, array( 'status' => 'scheduled' ) );
brm_enroll_user_in_post( $user_id, $post_id, $args );
brm_update_post_enrollment_source( $user_id, $post_id, $args );
brm_cancel_post_enrollment_source( $user_id, $post_id, 'manual', '' );
These wrappers already guard against the module being disabled.
Bricks Integration
The module exposes enrollment-aware Bricks features in four places:
- Query controls: post loops and user loops can filter by enrollment state
- Conditions: active, scheduled, and explicit status checks
- Dynamic tags: enrollment state, dates, source, count, URLs
- Form actions: advanced enrollment writes
The simple frontend self-service path is not a Bricks Form requirement anymore. A normal Bricks Button can use:
{brm_enrollment:enroll_url}{brm_enrollment:unenroll_url}
Those URLs resolve against the effective enrollment target for the current post context.
Frontend Action Security Model
The self-service action controller still starts from a URL because that is what makes the Bricks button UX simple, but the write is not completed as a raw GET-side mutation anymore.
Current flow:
- the button opens a signed action URL
- the controller validates the nonce and login state
- the request is bridged into a tiny POST submit page
- the actual enrollment mutation happens on POST
- the user is redirected back to the original page
This keeps the UX simple while avoiding direct GET-side writes.
Drip Integration
Drip keeps ownership of release timing. Enrollments keep ownership of access.
The key integration is the post_enrollment_activated trigger. When used, DripService looks up the current effective enrollment target and reads current_access_started_at from wp_brm_enrollment_activity.
This is what makes course-level anchor enrollment work for inherited lessons: drip timing can follow the anchor enrollment date instead of the child post itself.
Admin Surfaces
BricksMembers → Enrollments: manual enrollment, current enrollments, level enrollment rules- BRM post meta box: tri-state authoring plus effective preview
- Structure editor: tri-state authoring plus descendant inherit cascade controls
BricksMembers → Access Control: enrollment redirect section
Keep these surfaces aligned with existing BRM patterns. Use the existing card structure, search pickers, filter rows, and tab behavior instead of inventing feature-specific admin markup conventions.
Extension Rules
- Use
EnrollmentRequirementServicefor authored rule reads/writes - Use
EnrollmentRequirementProjectionServicefor effective projection refresh - Use
EnrollmentServicefor source-row writes - Do not store canonical authored state in JSON blobs or treat
wp_brm_post_dataas canonical write storage - Do not broad-scan structure meta on normal runtime requests
- Prefer the effective projection for hot-path reads