Structure

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/.

Load order

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.

BuddyPress dependency

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.

How a submission is handled

  1. On wp, a POST containing the form’s submitted flag and form ID is picked up and its nonce checked.
  2. The enquirer is resolved. A logged-in visitor is always the enquirer: any posted email is ignored, and a posted first or last name is only saved where the account has none. A logged-out visitor is matched or created from the submitted email, through lh_user_provisioning_ensure_user() when LH User Provisioning is active, otherwise a built-in fallback.
  3. lh_crm_http_post_after_user fires. Plugins whose profile fields save themselves (for example LH User Experience Taxonomy) save here.
  4. Submitted xProfile fields are read from the request in 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.
  5. The form’s configured group action (add to group, email invite or silent invite) is applied to the enquirer.
  6. If the form was on a singular page, an enquiry is created (or the existing one returned when the reference matches), the submitter’s IP address and user agent are recorded against it by the bundled libraries, and lh_crm_enquiry_created fires for any other plugin listening.
  7. If the form has a Notification template, the auto-reply is emailed to the enquirer.
  8. The page content is then replaced with the form’s Response template.

Because the nonce is printed into the page for the frontend script, a page cached for longer than the nonce lifetime will make submissions fail. Pages carrying a form should not be cached for long.

The logged-in form

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.

Design decisions

  • A logged-in user always enquires as themselves. Staff record enquiries on behalf of other people in the admin (CRM, Enquiries, Add New), or log out and use the anonymous form. Before 1.5.17, staff with promote_users got blank name and email fields on the frontend, and a submission there could create or match a different user.
  • The enquiry form fills gaps; it never changes existing profile data. Existing values are changed on the profile edit screen, which the form links to. For logged-out submissions that match an existing account by email, empty fields can still be filled in by someone who only knows the member’s email address. That is an accepted trade-off for easy data capture, not an oversight.
  • User Experience stays editable on the logged-in form, because a member’s experience level changes regularly and the enquiry form is a reasonable place to update it. Its field types are the default for lh_crm_frontend_always_editable_field_types; ideally LH User Experience Taxonomy would add itself through that filter.
  • Posted profile fields are not limited to the form’s field group, because other plugins add their own fields to these forms.
  • The group action follows the form’s setting, not the submitter. A logged-in member submitting an invite-only form gets an invitation, not an immediate join.
  • The bootstrap stays on bp_loaded rather than the standard plugins_loaded pattern, because BuddyPress is a hard dependency on every LocalHero network.
  • No triple store sync. Removed in 1.5.8: LH CRM wrote four triples per enquiry to LH Relationships, nothing read them, and the hourly resync re-inserted the same rows every hour. The triple store remains a longer-term project; LH CRM will only write to it again once something reads it.
  • No activity stream integration. Removed in 1.5.15: LH CRM had long stopped creating activity items, but its activity class still ran on every BuddyPress activity query. Enquiries are private records, so they are not published to the activity stream. Hiding other plugins’ activity types (such as log entries) belongs in those plugins; see task 147779.

Known issues

  • Notification Recipients do nothing. The method that sends BuddyPress notifications to a form’s configured recipients is not called anywhere, so staff are not notified of new enquiries by LH CRM. The last such notification anywhere on the LocalHero network was sent on 10 May 2026. Tracked in task 147724.
  • The recorded IP address can be spoofed. The IP address library prefers the 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.
  • The admin new-enquiry screen is basic. It only offers existing users as the enquirer, and it does not send the form’s auto-reply or apply its group action.
  • Staff notes are saved with 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.
  • Enquiry categories have no wp-admin management screen.

History

  • 1.4.0 split the original single-file plugin into the classes above and added the MCP ability, post meta registration and the LH User Provisioning fallback.
  • 1.5.0 removed the built-in task post type in favour of the shared LH Tasks plugin.
  • 1.5.1 moved Gutenberg support for the BuddyPress bp-email post type to LH Email, which owns that integration.
  • 1.5.2 moved logging to the standard LH debug log format.
  • 1.5.3 removed the [lh_crm_form] shortcode after every use of it on the network had been converted to the Contact Form block.
  • 1.5.4 made the group action always apply to the enquirer, stopped checkbox, multiselect and textarea profile answers being lost on save, and escaped all frontend form output.
  • 1.5.5 removed the 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.
  • 1.5.6 moved component loading to bp_loaded. That broke the REST API network-wide (see Load order), and the plugin was deactivated within minutes.
  • 1.5.7 restored component loading at file-read time.
  • 1.5.8 removed the LH Relationships sync and deleted the relationships class.
  • 1.5.9 fixed the hourly scheduled-enquiry check, which passed a GMT offset where 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.
  • 1.5.10 tightened the 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.
  • 1.5.11 applied Plugin Check fixes: corrected the enquiry 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.
  • 1.5.12 moved the reading of submitted profile fields into save_data(), after the nonce check, so handle_bp_edit() no longer touches the request.
  • 1.5.13 fixed LH CRM’s copies of the bundled IP address, user agent and script libraries: server values are sanitised, the recorded IP must be a valid address, text domains and a concatenated translatable string are fixed, and logging goes through LH CRM’s write_log().
  • 1.5.14 fixed the findings from the first full PHPStan run: argument types passed to WordPress and BuddyPress functions, activation and deactivation hooks called with the right number of arguments, a duplicate 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.
  • 1.5.15 removed the activity class. Nothing in LH CRM created activity items any more, but the class ran on every BuddyPress activity query, adding an extra query for editors and, for everyone else, limiting the activity action list to registered types, which could hide types such as check_in and log_entry from the stream.
  • 1.5.16 restored the vCard and Text links on the enquiry page. LH Vcard 1.06 (9 September 2026) had removed 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.
  • 1.5.17 made a logged-in user always enquire as themselves, with existing details read-only and inputs only for missing ones, a notice linking to their profile, and staff sent to the admin to enquire for someone else. handle_bp_edit() now only fills empty fields for every submission, and the staff Do not send email option was removed.
  • 1.5.18 made profile fields stored outside BuddyPress’s own tables (Gender, Phone, User Experience) show as read-only when filled, using suppress_if_populated, and added lh_crm_frontend_field_display_value.
  • 1.5.19 kept User Experience editable when filled, through the new lh_crm_frontend_always_editable_field_types filter.

Related plugins

  • LH User Provisioning: preferred way of creating users from submissions. An admin notice appears while it is inactive.
  • LH Tasks: the task queue that replaced LH CRM’s own tasks.
  • LH Email: owns bp-email editor support, which LH CRM’s auto-reply templates rely on.
  • LH Agora: bundles the wp-statuses library that LH CRM’s statuses must register ahead of.
  • LH Profile Page: supplies the Gender, Phone and other extended profile field types shown on forms, and the phone number used for the Text link.
  • LH User Experience Taxonomy: supplies the User Experience field, listens on lh_crm_http_post_after_user to save it, and adds notification tokens.
  • LH Vcard: supplies the signed vCard download link on the enquiry page, through LH_Vcard_plugin::return_signed_vcard_download_url().
  • LH Relationships: no longer written to as of 1.5.8. Rows with source 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.