lh-crm.php holds the main class, LH_Crm_plugin: identity methods, form meta accessors, shared helpers, the Contact Form block, frontend script and admin notices. Each area of behaviour is a separate class in includes/ that extends LH_Crm_plugin and is its own singleton: contact form post type, enquiry post type, form submission, notifications, bbPress adjustments and MCP abilities. None of the plugin’s own classes is wrapped in a class_exists() guard: on this platform a guard like that can report true before the class is declared and silently skip the class’s setup. libraries/ holds four self-contained drop-in classes (form response post type, IP address taxonomy, user agent meta, script registration); these do keep a guard, because other plugins may ship the same files, and whichever copy loads first is the one the network uses. LH CRM’s copies log through LH CRM’s own write_log(). Consolidating every bundled copy is tracked as task 147743. View templates live in partials/.
The libraries and component classes are loaded by load_components(), called from the main class constructor, which runs as soon as the plugin file is read. Only the main class’s own hooks (block, frontend script, admin notices) are registered later, on bp_loaded.
Do not move component loading to bp_loaded or any later hook. The enquiry post type registers its five statuses on init at priority 1000. The wp-statuses library bundled in LH Agora also runs wp_statuses_register on init at priority 1000, and converts every status registered before it into its own status object. Callbacks at the same priority run in the order they were added, so LH CRM’s callback must be added before wp-statuses adds its own. If it is added later, the enquiry statuses stay plain objects and wp-statuses throws a fatal error (a call to is_builtin()) during REST server setup, which breaks the REST API, and with it MCP, across the whole network. This happened in 1.5.6. The same rule applies to any LH plugin that registers post statuses at init priority 1000.
LH CRM depends on BuddyPress. The main class’s hooks are registered on bp_loaded, so without BuddyPress the block and frontend script never load. The component classes are loaded when the plugin file is read (see Load order) and call BuddyPress functions directly (groups, xProfile, notifications), so the plugin should not be activated on a network without BuddyPress.
wp, a POST containing the form’s submitted flag and form ID is picked up and its nonce checked.lh_user_provisioning_ensure_user() when LH User Provisioning is active, otherwise a built-in fallback.lh_crm_http_post_after_user fires. Plugins whose profile fields save themselves (for example LH User Experience Taxonomy) save here.save_data(), after the nonce check, and passed to handle_bp_edit(). It only fills fields that are empty for the enquirer: a stored value is never changed from the enquiry form, whoever submits it. Values are passed to BuddyPress as submitted, so the field type validates them and BuddyPress (or the plugin that owns the field) sanitises them on save.lh_crm_enquiry_created fires for any other plugin listening.The nonce is printed into the page for the frontend script, as a data attribute on its script tag together with the time the page was rendered. A page cache can keep that nonce past its 12–24 hour lifetime, and a script optimiser that combines or delays scripts can stop it being read at all. So the first time a form field is focused, the script checks the nonce field: if it is empty or the page is more than ten hours old, it fetches a fresh nonce from the lh_crm-frontend_nonce admin-ajax action and fills it in. admin-ajax is used rather than REST because it authenticates by cookie, so a logged-in member gets a nonce for their own account. The fetch happens while the visitor is typing, so submitting is no slower, and if it fails the existing nonce is left in place. Since 1.6.2.
A submission that still fails the nonce check is logged as a frontend_nonce_failed warning, with whether the nonce was empty or invalid, and gets a “Form expired” page with a link back and HTTP status 403.
For a logged-in user the form shows their email, their name and every filled profile field as greyed, disabled inputs, and only offers inputs for missing details. Disabled inputs are never submitted, and the server side enforces the same rule regardless of what is posted.
Some profile fields are xProfile field types from other plugins that keep their data outside BuddyPress’s own profile data table (Gender and Phone in LH Profile Page are user meta; User Experience in LH User Experience Taxonomy is a user taxonomy term), so bp_field_has_data() reports them empty. The form renders each field with the suppress_if_populated property those field types honour, and treats a field as filled if BuddyPress has data for it, if its displayed value is not empty, or if the field type printed nothing. The greyed value comes from bp_get_the_profile_field_value() (which LH Profile Page filters), then lh_crm_frontend_field_display_value, and otherwise reads On your profile. Field types listed in lh_crm_frontend_always_editable_field_types skip all this and stay editable.
promote_users got blank name and email fields on the frontend, and a submission there could create or match a different user.lh_crm_frontend_always_editable_field_types; ideally LH User Experience Taxonomy would add itself through that filter.bp_loaded rather than the standard plugins_loaded pattern, because BuddyPress is a hard dependency on every LocalHero network.Client-IP and X-Forwarded-For headers over the connection address, and a visitor can set both. The recorded value is validated as an IP address but is only as trustworthy as the proxy in front of the site. Tracked in task 147743.sanitize_text_field(), which removes line breaks.lh_crm_message_title is applied but its result is never used, and the response template’s user summary token defaults to a placeholder value.bp-email post type to LH Email, which owns that integration.[lh_crm_form] shortcode after every use of it on the network had been converted to the Contact Form block.class_exists() guards around the plugin’s own classes, escaped the remaining admin and notification output, and made Hide Message and Notification Recipients clearable on a form.bp_loaded. That broke the REST API network-wide (see Load order), and the plugin was deactivated within minutes.current_time() expects a true or false GMT flag and so released scheduled enquiries 10 to 11 hours late on sites ahead of UTC. It now compares post_date_gmt with the current UTC time, and the lh_crm_gmt_offset filter was removed.lh-crm/query-enquiries MCP ability: behaviour annotations, additionalProperties: false, the valid values in its schema, and an error for a site ID that does not exist.read_private_posts capability (it was mapped to a misspelt read_private_postss), replaced the scheduled-enquiry direct query with get_posts(), added translators comments to the status labels, escaped the Group Action select, and sanitised the current-URL helper.save_data(), after the nonce check, so handle_bp_edit() no longer touches the request.write_log().show_in_menu key in the enquiry post type, the dashboard widget receiving a form ID instead of the whole post, and escaping of the enquiry page’s profile and vCard links. No intended change in behaviour.check_in and log_entry from the stream.return_vcard_link(), which the template checked for, so both links had silently disappeared. The vCard link now uses LH Vcard’s signed download URL and only shows to users with LH Vcard’s view capability, since anyone holding the link can redeem it for 24 hours. The Text link no longer depends on LH Vcard.handle_bp_edit() now only fills empty fields for every submission, and the staff Do not send email option was removed.suppress_if_populated, and added lh_crm_frontend_field_display_value.lh_crm_frontend_always_editable_field_types filter.bp-email editor support, which LH CRM’s auto-reply templates rely on.wp-statuses library that LH CRM’s statuses must register ahead of.lh_crm_http_post_after_user to save it, and adds notification tokens.LH_Contacts::return_signed_download_url(), shown only to users with LH_Contacts::return_view_capability(). Until 1.6.6 this came from LH Vcard, which LH Contacts replaced.lh_crm written before then have since been removed from its store.lh-users-insights-crm-extender is a separate Users Insights add-on. The enquiry fields shown in Users Insights are registered by LH CRM itself, not by that plugin.