This developer note documents the BricksMembers Content Fields runtime: field definition storage, postmeta and termmeta value ownership, Content Editor integration, Structures quick edit integration, Bricks Builder surfaces, REST/headless exposure, ACF migration utilities, AI/MCP abilities, and the invariants that keep content fields separate from profile fields and ACF runtime ownership.
Since version 1.3.0.
Scope
Content Fields are BRM-owned custom fields for WordPress content objects. They store definitions in one option and values as native post meta or term meta under the public field key. They are post/page/structure-item/taxonomy-term content fields, not user profile fields.
ACF is migration-only for this module. Do not register fake ACF groups, do not bridge runtime reads through ACF, and do not write ACF reference meta for BRM-owned fields.
Runtime Owners
ContentFieldsFeatureRegistrarowns feature-local bootstrap for post editor, term editor, and REST meta registration.ContentFieldDefinitionServiceownsbrm_content_field_groups, definition normalization, key validation, key uniqueness, key immutability, andMutationContract::settings_updated( 'content_fields' ).ContentFieldValueServiceowns value sanitization, required-field validation, normal post value writes, term value writes, and subset writes used by Structures quick edit.ContentFieldRuntimeBridgeis the read adapter for dynamic tags, headless formatting, and other runtime consumers.ContentFieldExposurePolicyis the single owner for public/headless exposure decisions based onsettings.public.ContentFieldsPostEditControllerowns the WordPress post editor meta box.ContentFieldsTermEditControllerowns the WordPress taxonomy term add/edit screen integration and saves matching fields as native termmeta.StructureContentEditorDataProviderandStructureContentEditorAjaxadapt matching Content Fields into the Structure Content Editor.StructureQuickEditAjaxadapts opted-in Content Fields into the hierarchy Quick Edit modal and saves only the opted-in subset shown in that modal.ContentFieldRenameMigrationServiceowns key rename migrations: dry-run, copy old-key post meta to new-key post meta, then delete the old key.src/Modules/ContentFields/Migration/owns ACF analysis, definition mapping, value migration, and migration manifests.
Bootstrap
BootstrapCoordinator::boot_modules()bootsContentFieldsFeatureRegistrar::boot()whenModuleRegistry::is_active( 'content_fields' )is true.- The registrar calls
ContentFieldsPostEditController::register()andContentFieldsTermEditController::register(). - The registrar adds
ContentFieldRestMetaRegistrar::register()toinit. UtilityBootstrapwires Content Fields dynamic tag registration and rendering behind the same module gate.BricksModuleHookRegistrarowns Bricks query-loop, query-filter, and form-action hook registration for Content Fields.BricksConditionHookRegistrarowns Bricks condition hook registration for Content Fields and delegates throughBricksConditionsRegistrar.
No Content Fields provider class should call add_filter() or add_action() for Bricks directly when an existing registrar owns that hook family.
Definition Contract
The definition option is brm_content_field_groups. It stores an array of groups with location rules and fields. A field definition has an immutable key, a type, labels/descriptions, required state, UI hints, settings, and optional subfields.
Field keys are global across all groups and subfields. The key is the live content meta key, so changing it without migration would orphan values. ContentFieldDefinitionService rejects duplicate keys, private keys beginning with _, reserved WordPress/Bricks/BRM/ACF-like keys, and saved-key changes for an existing field ID.
Canonical settings include:
{
"public": false,
"quick_edit": false,
"bricks": {
"filter_element": false,
"query_loop_filter": false,
"condition": false
},
"choices": {},
"default_value": "",
"return_format": "id",
"min": "",
"max": "",
"step": ""
}
settings.public is the only public/headless-readable flag. REST, headless output, and type catalog checks must all read that single flag through ContentFieldExposurePolicy.
Value Storage and Writes
Scalar post values are saved as native WordPress post meta under the field key, and taxonomy-term values are saved as native term meta. This is intentional: disabling the module leaves authored scalar values in place, and Bricks native custom-field dynamic data can still read post meta keys when the user knows them.
ContentFieldValueService::save_values_for_post()validates required fields before writing any submitted values.sanitize_value()branches by field type. Media and relationship values normalize to IDs; multiple-value fields normalize to arrays; group and repeater values recursively sanitize subfields.- Normal admin, Structure Content Editor, Bricks Form, and AI/MCP value writes must delegate to
ContentFieldValueService. - Rename and import services may write copied postmeta as explicit migration utilities with manifests.
Admin and Editor Surfaces
ContentFieldsPagerenders thecontent-fieldsadmin surface.assets/admin/js/content-fields-page.jsowns the modal editor controller for the definition UI. The main admin page renders compact field-group cards; group and field edits happen in modal dialogs, and the hidden save form posts normalized definitions toContentFieldsAdminActionHandler.ContentFieldsAdminActionHandlerhandles definition saves, ACF import preparation, and rename migration requests.ContentFieldsPostEditControllerrenders matching fields in a WordPress post editor meta box, enqueues the Content Fields value UI, and saves throughsave_values_for_post().ContentFieldsTermEditControllerrenders matching fields on taxonomy term add/edit screens. Taxonomy-targeted fields save throughsave_values_for_term()and use native termmeta under the public field key.StructureContentEditorDataProvider::get_content_fields_payload()exposes matching fields, values, and renderer HTML to the Structure Content Editor so scalar, link, media, relationship, group, and repeater controls share the same value UI as the post editor.StructureContentEditorAjax::save_content_fields()writes submitted Content Fields for the selected post after nonce, request-method,edit_posts, andedit_postchecks.StructureQuickEditAjax::get_quick_edit_fields()returns a rendered Content Fields fragment for non-structured fields wheresettings.quick_edit=true.quick_edit_post_fields()saves that subset withsave_values_for_post_fields(), so required fields outside Quick Edit do not block unrelated quick edits.
The Structure Content Editor has two separate custom-field surfaces: ACF-compatible field HTML and BRM-owned Content Fields. Keep that distinction intact.
Bricks Builder Surfaces
Bricks hook registration is intentionally split by hook family:
BricksModuleHookRegistrarregisters Content Fields query types, structured loop controls, actual Query Loop controls, Filter element source hooks, and thebrm_update_content_fieldsform action.BricksConditionHookRegistrarregisters Content Fields condition options/result hooks by pairing registrar methods onBricksConditionsRegistrar.ContentFieldsBricksIntegrationis provider wiring for query-loop object types only.ContentFieldFilterElementIntegrationis the provider for thebrmContentFieldBricks Filter element source.ContentFieldQueryLoopFilterIntegrationinjects opt-in Content Field controls into actual Bricks post and term Query Loop elements.ContentFieldConditionIntegrationis the provider for opt-in Content Fields conditions.
ContentFieldsFormIntegration resolves the target post from a mapped Post ID field, the static Post ID setting, or the current post. It authorizes writes with normal edit_post access, then falls back to InstructorAccessService::user_can_edit_item_content() when the Instructor Frontend Builder module is active. It still delegates all value writes to ContentFieldValueService.
Dynamic tags are always available while the module is active. Bricks Filter element exposure, actual Query Loop controls, condition exposure, and Structures quick-edit exposure are not automatic. The definition layer normalizes unsupported opt-ins back to false, including settings.quick_edit on group and repeater fields.
{brm_cf:field_key}reads a direct stored field value.{brm_cf:field_key:label}maps select, button group, radio, and checkbox stored values to their configured choice labels.{brm_cf:group.subfield}reads a group subfield directly.{brm_cf_loop:subfield_key}reads a subfield inside a Content Fields group/repeater loop object.brmContentFieldGroupQuery,brmContentFieldRepeaterQuery,brmContentFieldGalleryQuery,brmContentFieldRelationshipQuery,brmContentFieldTaxonomyQuery, andbrmContentFieldUserQueryexpose structured, media, relationship, taxonomy, and user loop objects.brmContentFieldFilter element source lists only fields withsettings.bricks.filter_element=trueand a supported Filter element type.brmCfQl_{field_key}_mode,brmCfQl_{field_key}_value,brmCfQl_{field_key}_value2, andbrmCfQl_{field_key}_matchare generated only for supported post or term fields withsettings.bricks.query_loop_filter=true.brm_content_field__{field_key}conditions list only fields withsettings.bricks.condition=trueand a supported condition type.
Do not put \Bricks\* classes in method signatures. Use mixed parameters and runtime guards such as defined( 'BRICKS_VERSION' ), class_exists( '\Bricks\Query' ), and is_callable() where needed so PHPStan and unit tests continue to run without Bricks.
REST and Headless Exposure
ContentFieldRestMetaRegistrar registers supported public Content Fields as native post meta REST fields. Every register_post_meta() call sets an explicit auth_callback that requires edit_post for writes.
Remember that show_in_rest makes values readable to users who can read the post. The write auth_callback does not make the value private. For external/headless exposure, use settings.public deliberately and leave it false for non-public fields.
HeadlessRestContentFormatter adds content_fields to formatted content records only when the Content Fields module is active. It returns values from ContentFieldRuntimeBridge::get_public_values_for_post(), so headless output follows the same settings.public policy.
ACF Migration
ACF migration utilities live under src/Modules/ContentFields/Migration/.
ContentFieldAcfImportAnalyzerreads ACF groups and fields whenacf_get_field_groups()andacf_get_fields()are available.ContentFieldAcfDefinitionMappermaps supported ACF field definitions to native Content Fields definitions and reserves destination keys throughContentFieldDefinitionService.ContentFieldAcfValueMigratorbranches reads by ACF field type. Scalar, choice, relational, and media fields read raw values. Groups and repeaters usehave_rows(),the_row(), and rawget_sub_field( $name, false )where possible, with formatted fallback for structured values.ContentFieldMigrationManifestServicepersists the latest migration manifest inbrm_content_fields_last_import_manifest.
Nested relational and media subfields must normalize to IDs. Never write ACF reference meta for BRM-owned fields.
AI/MCP Abilities
AI Abilities exposes Content Fields through the content ability service when AI Abilities and the relevant access gates are enabled.
bricksmembers/content-fields-catalog-getreturns module availability, the definition option, field groups, type definitions, and the Bricks prefix.bricksmembers/content-fields-values-getreads values for a post and optional field list.bricksmembers/content-fields-values-set-planprepares a guarded value update plan throughAiOperationPlanner.bricksmembers/content-fields-acf-import-planprepares ACF import analysis and points callers to the migration flow for spike-gated value migration.
The admin surface blueprint for Content Fields must list the exact Bricks resources in AiBricksCatalogData: form action brm_update_content_fields, query types, filter source brmContentField, condition pattern brm_content_field__{field_key}, and dynamic tag family content_fields.
Tests and Drift Guards
ContentFieldDefinitionServiceTestcovers duplicate keys, key immutability, reserved/private keys, and Bricks exposure setting normalization.ContentFieldValueServiceTestcovers sanitization, required-field behavior, termmeta writes, and subset saves for Structures quick edit.StructureQuickEditContentFieldsTestcovers the Structure hierarchy Quick Edit AJAX path: only opted-in fields save, non-opted fields are ignored, and required opted-in fields block the save.ContentFieldsTermEditControllerTestcovers term edit rendering and term field saves.ContentFieldRenameMigrationServiceTestcovers dry-run and copy/delete key migration.ContentFieldAcfValueMigratorTestcovers type-branched ACF value reads and structured field handling.ContentFieldBricksExposureTestcovers opt-in Filter element, Query Loop control, and condition exposure, stale disabled-field rejection, and condition evaluation.ContentFieldMetaQueryBuilderTestandContentFieldQueryLoopFilterIntegrationTestcover typed Content Field meta-query clauses, serialized array matching, post query vars, term query vars, and archive query argument preservation.ContentFieldsFormIntegrationTestcovers mapped Post ID targeting and the Instructor Frontend Builder edit-permission fallback for frontend Content Fields saves.DynamicTagsTestcovers Content Fields dynamic tag registration and group subfield rendering.AiAbilitiesFeatureTestandcomposer test:ai-mcp-driftdefend the AI/MCP surface, blueprint/catalog parity, ability count, and docs contracts.
Implementation Invariants
- Definitions have one option owner:
ContentFieldDefinitionService. - Values have one normal write owner:
ContentFieldValueService. - Field keys are globally unique and immutable after first save.
- Rename means migration, never silent key mutation.
- Dynamic tags and structured query loops are always available while the module is active.
- Bricks Filter element exposure, actual Query Loop control exposure, and Bricks condition exposure are separate opt-ins per supported field.
- Public/headless exposure reads only
settings.public. - ACF is migration-only. Do not add an ACF bridge mode.
- Do not add Bricks hook registrations outside the established registrars.