Skip to main content
Skip to main content

Developers Doc

Custom Post Types: Property, Agent & the Data Layer

13 mins read 9 Views 3+

Havenlytics stores its two primary content entities as WordPress custom post types: hvnly_property (listings) and hvnly_agent (real-estate agents). This reference documents how each CPT is registered, the exact meta-key conventions the plugin uses to persist listing and agent data, and the seven custom database tables that back the inquiry, notification, and enterprise subsystems. Everything here is grounded in the Havenlytics 3.3.1 source so you can query, extend, and integrate with confidence.

Note: Both CPTs are registered on the standard WordPress init hook. All Havenlytics post types, meta keys, and hooks use the hvnly_ prefix. If an identifier is not listed here, it does not exist in the plugin — do not rely on it.

The hvnly_property post type

The Property CPT is registered by HvnlyNab\Database\Custom_Posts\HavenlyticsNab (file includes/Database/Custom_Posts/HavenlyticsNab.php), which extends the shared base class HvnlyNab\Database\Base\Custom_Posts. The base constructor hooks registration onto init at priority 0 so the post type exists before any other module runs:

<?php
// includes/Database/Base/Custom_Posts.php (constructor)
add_action( 'init', array( $this, 'register_custom_post_type' ), 0 );

Registration arguments

The Property CPT is fully public and archive-enabled. Several arguments are computed dynamically at registration time rather than hard-coded — most importantly show_in_rest, which is gated on whether the Gutenberg editor is enabled for Havenlytics.

ArgumentValueNotes
publictrueFront-end queryable and visible.
show_in_restdynamicPluginGutenbergSupport::is_enabled(). Off by default (see note below).
has_archivedynamicPermalinkSettings::get_property_archive_slug(), fallback true.
rewritearrayslug = property single slug (fallback 'property'), with_front false, pages true, feeds true.
supportsarraytitleeditorauthorthumbnailexcerptcomments.
publicly_queryabletrue
query_vartrue
show_in_menutrueMenu position 3.
menu_iconSVGassets/admin/img/havenlytics-icon18x18.svg.
capability_type'post'Default post capabilities.
labels['menu_name']"Havenlytics"The top-level admin menu label.

Note: show_in_rest is not enabled by default. It resolves to PluginGutenbergSupport::is_enabled(), which reads the setting key hvnly_EnabledGutenbergEditor in the general group of the hvnly_plugin_settings option. That setting defaults to false, so the Property CPT is excluded from the REST API and the block editor unless an admin turns Gutenberg support on. If you build a headless or block-editor integration, verify this setting first.

Automatic Property ID meta

On insert and publish, the Property CPT automatically assigns a unique, human-readable identifier to the _hvnly_unique_property_id meta key via the plugin helper’s generate_property_id(). A matching cleanup routine runs on before_delete_post. The class also registers custom, sortable admin columns and taxonomy filters for the listing table.

Tip: Treat _hvnly_unique_property_id as read-only. It is generated by the plugin and used as a stable reference in exports, inquiries, and integrations. Do not overwrite it.

The hvnly_agent post type

The Agent CPT is registered by HvnlyNab\Agent\AgentPostType (file includes/Agent/AgentPostType.php), which also extends Custom_Posts. Its slug is defined as the constant AgentConstants::POST_TYPE = 'hvnly_agent'. Unlike the Property CPT, the Agent post type does not add its own top-level menu — that is handled separately by AgentAdminMenu.

Registration arguments

ArgumentValueNotes
publictrue
publicly_queryabletrue
show_uitrue
show_in_menufalseMenu provided by AgentAdminMenu, not the CPT.
show_in_nav_menustrue
show_in_admin_bartrue
show_in_restdynamicPluginGutenbergSupport::is_enabled(), same gate as Property.
has_archivedynamicAgent archive slug.
hierarchicalfalse
capability_type'post'
map_meta_captrueMaps meta capabilities to the primitive post caps.
supportsarraytitleeditorthumbnailexcerptrevisions.
rewrite['slug']dynamicsanitize_title() of the agent single slug; falls back through filter hvnly_agent_rewrite_slug to 'agent'with_front false.
menu_icondashicons-businessman

After registration, the Agent CPT fires an action you can hook to run setup once the post type is guaranteed to exist:

<?php
// Fired by AgentPostType after register_post_type()
add_action( 'hvnly_agent_post_type_registered', function ( $post_type ) {
    // $post_type === 'hvnly_agent'
    // Safe point to register agent-related integrations.
}, 10, 1 );

The Agent listing table adds columns for photo, title, position, company, email, phone, and availability.

Note: The shared Custom_Posts::init() also calls register_post_status( 'expired', … ) once per CPT. This is redundant across post types but harmless — the expired status is registered globally.

Property meta-key conventions

Havenlytics uses a disciplined, prefix-based convention for post meta so keys stay namespaced, hidden from the default custom-fields UI (leading underscore), and protected from accidental deletion. There are two families: fixed scalar keys and dynamic builder-group keys.

Scalar property meta (_hvnly_property_*)

Core listing attributes are stored under the private prefix _hvnly_property_. Real keys in the plugin include:

  • _hvnly_property_price_hvnly_property_bedrooms_hvnly_property_bathrooms_hvnly_property_reception_rooms
  • _hvnly_property_sqft_hvnly_property_garage_sqft_hvnly_property_street_hvnly_property_year_built
  • _hvnly_property_amenities_hvnly_property_floor_plan_hvnly_property_virtual_tour_hvnly_property_brochure
  • _hvnly_property_epc_hvnly_property_map_hvnly_property_inspection_report
  • _hvnly_property_featured and _hvnly_property_action_tool_is_featured (featured flags)
  • _hvnly_property_views (array containing a running total) and the generated _hvnly_unique_property_id

Builder group meta: {group_base_id}_{metaKey}

Fields created through the drag-and-drop DnD Property Builder are not stored under the fixed _hvnly_property_ prefix. Instead each builder group has a stable master base ID, and every field within it is saved as {group_base_id}_{metaKey}. The master base IDs are resolved once by UnifiedFieldGenerator::get_or_create_master_base_ids() (fallback option hvnly_master_base_ids) and cached, so the same field always maps to the same key across saves.

Common group suffixes:

Field typeMeta key patternStored value
gallery{base}_imagesCSV / array of attachment IDs
video{base}_urlVideo URL
map{base}_preview plus {map_*}_address / _latitude / _longitudeMap preview + coordinates
agents{base}_agentsAssigned agent IDs
faq{base}_faqsFAQ entries
repeater{base}_itemsRepeater rows

Gallery is the canonical SSOT

The property gallery is the single source of truth for a listing’s images. It is stored as the {group_base_id}_images value (a CSV/array of attachment IDs) and must always be read through the one canonical loader, hvnly_get_property_gallery_ids(). Never parse the raw meta yourself — the loader normalizes CSV vs. array storage and applies the plugin’s expected ordering.

<?php
// Read a property's gallery attachment IDs the SSOT way.
$property_id = get_the_ID();
$gallery_ids = hvnly_get_property_gallery_ids( $property_id );

foreach ( $gallery_ids as $attachment_id ) {
    echo wp_get_attachment_image( $attachment_id, 'large' );
}

Common mistake: Reading gallery images by calling get_post_meta() on a guessed key. The base ID is dynamic and the value may be CSV or array. Always call hvnly_get_property_gallery_ids() so you get the normalized, correctly ordered list.

Per-image titles and captions are stored on the attachment posts themselves; the metabox save routine reads POST keys of the form hvnly_gallery_title_{id}hvnly_gallery_ids_{id}, and hvnly_gallery_caption_{id}. A JSON field-map bookkeeping value is kept under _hvnly_field_map.

Agent meta-key conventions (_hvnly_agent_*)

Agent contact and profile data is stored under the _hvnly_agent_ prefix. Real keys include _email_phone_mobile_whatsapp_fax_office_position_company_website_address_license, and the social keys _facebook_linkedin_instagram_twitter_pinterest_youtube_tiktok_vimeo. Identity linkage lives in _hvnly_agent_linked_user_id_hvnly_agent_registered_at, and the array _hvnly_agent_extensions. Availability is one of the slugs availablebusyaway, or offline.

Property ↔ agent link meta

A listing references its agents through post meta rather than a taxonomy:

  • _hvnly_property_agents — ordered array of Agent CPT post IDs (the current, canonical link).
  • _hvnly_property_agent — legacy single WordPress user ID (kept for backward compatibility).
  • _hvnly_hide_sidebar_agent_widget — per-listing flag to suppress the sidebar agent widget.

Note: Meta prefixes _hvnly__havenlytics_gallery_video_map_property_docs_, and preset_hvnly_property_field_ are protected against the plugin’s deletion-safety routine. Protected options include hvnly_plugin_settingshvnly_property_builder.sectionshvnly_master_base_idshvnly_db_version, and hvnly_migrations_completed.

Querying properties and reading meta

Because hvnly_property is a standard CPT, you query it with WP_Query and read values with get_post_meta(). This example fetches featured properties under a price ceiling and reads a scalar meta value:

<?php
$query = new WP_Query( array(
    'post_type'      => 'hvnly_property',
    'post_status'    => 'publish',
    'posts_per_page' => 12,
    'meta_query'     => array(
        'relation' => 'AND',
        array(
            'key'   => '_hvnly_property_featured',
            'value' => '1',
        ),
        array(
            'key'     => '_hvnly_property_price',
            'value'   => 750000,
            'type'    => 'NUMERIC',
            'compare' => '<=',
        ),
    ),
) );

if ( $query->have_posts() ) {
    while ( $query->have_posts() ) {
        $query->the_post();

        $price    = get_post_meta( get_the_ID(), '_hvnly_property_price', true );
        $bedrooms = get_post_meta( get_the_ID(), '_hvnly_property_bedrooms', true );
        $ref      = get_post_meta( get_the_ID(), '_hvnly_unique_property_id', true );

        printf(
            '<article><h3>%s (%s)</h3><p>%s bed · %s</p></article>',
            esc_html( get_the_title() ),
            esc_html( $ref ),
            esc_html( $bedrooms ),
            esc_html( number_format_i18n( (float) $price ) )
        );
    }
    wp_reset_postdata();
}

Performance: meta_query comparisons on unindexed post meta do not scale to very large catalogs. For heavy filtering, prefer the plugin’s search engine, and cast numeric comparisons with 'type' => 'NUMERIC' so the database compares values, not strings.

Custom database tables

Beyond post meta, Havenlytics provisions seven custom tables, all prefixed with $wpdb->prefix. The ContactAgent and Notification tables are created with dbDelta(); the enterprise tables are created with raw CREATE TABLE IF NOT EXISTS statements during a migration.

Inquiry & notification tables

TableCreating classMigration
{prefix}hvnly_inquiriesContactAgent\Database\InquirySchema::create_table()Version302InquiriesHandler (3.0.2)
{prefix}hvnly_inquiry_repliesContactAgent\Database\InquiryReplySchema::create_table()Version302InquiryRepliesHandler (3.0.2)
{prefix}hvnly_portal_notificationsWorkspace\Notifications\NotificationSchema::create_table()Version320PortalNotificationsHandler (3.2.0)

hvnly_inquiries stores Contact-Agent submissions. It is created lazily on the first submit, during ContactAgentBootstrap, and via its 3.0.2 migration. Creation fires the action hvnly_contact_agent_inquiries_table_created. Columns:

ColumnTypeNotes
idbigint UNSIGNED AIPrimary key
property_idbigintIndexed (idx_property_id)
property_titlevarchar(255)
agent_idbigintIndexed (idx_agent_id)
agent_namevarchar(255)
sender_namevarchar(100)
sender_emailvarchar(100)
sender_phonevarchar(30)
messagelongtext
sourcevarchar(50)Default 'contact_agent_modal'
statusvarchar(20)Default 'new'; indexed (idx_status)
ip_addressvarchar(45)
user_agenttext
created_atdatetimeDefault CURRENT_TIMESTAMP; indexed (idx_created_at)

hvnly_inquiry_replies threads replies against an inquiry. Columns: idinquiry_id (indexed), author_user_id (indexed), direction varchar(20) default 'outbound'message longtext, email_sent tinyint default 0, and created_at (indexed).

hvnly_portal_notifications powers Agent Workspace notifications. Columns: iduser_idagent_idtype varchar(50), category varchar(30) default 'account'title varchar(255), body text, icon varchar(50) default 'bell'action_url varchar(255), payload longtext, read_at datetime, created_at. Composite keys include user_read(user_id, read_at) and user_category(user_id, category).

Enterprise tables (migration 2.3.0)

Four additional tables are created by Version230Handler::up() using raw CREATE TABLE IF NOT EXISTS statements (not dbDelta). Their id columns are bigint but not unsigned, unlike the ContactAgent and Notification tables.

TablePurposeKey columns
{prefix}hvnly_section_cacheCached rendered sections (the only enterprise table actively written; key 'all_sections')section_key varchar(100) UNIQUE, cache_data longtext, cache_hash varchar(64), expires_atcreated_at
{prefix}hvnly_field_indexField-value index for fast querying (scaffolding)property_idfield_key varchar(100), field_value_index varchar(255), numeric_value decimal(20,6), date_value
{prefix}hvnly_audit_logChange audit trail (scaffolding)user_idaction varchar(100), entity_type varchar(50), entity_idold_value/new_value longtext, ip_addressuser_agentcreated_at
{prefix}hvnly_webhook_queueOutbound webhook queue (scaffolding)webhook_url varchar(500), payload longtext, attemptsmax_attempts default 3last_errorstatus varchar(20) default 'pending'scheduled_atprocessed_at

Warning: Only hvnly_section_cache currently has writers in the plugin. hvnly_field_indexhvnly_audit_log, and hvnly_webhook_queue are enterprise scaffolding created by the migration but not yet populated by any runtime code. Do not assume they contain data.

Reading a custom table

<?php
global $wpdb;

$table = $wpdb->prefix . 'hvnly_inquiries';

$recent = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT id, sender_name, sender_email, status, created_at
         FROM {$table}
         WHERE agent_id = %d
         ORDER BY created_at DESC
         LIMIT %d",
        $agent_id,
        20
    )
);

Security: Always build custom-table queries with $wpdb->prepare() and never interpolate untrusted input directly into SQL. Interpolate only the table name (which you control) into the query string, and pass every value as a placeholder argument.

Best practices

  • Query listings and agents with WP_Query and post_type => 'hvnly_property' / 'hvnly_agent' — they are ordinary CPTs.
  • Read gallery images only through hvnly_get_property_gallery_ids(); never parse the raw {base}_images meta yourself.
  • Treat _hvnly_unique_property_id and the linked-user identity keys as plugin-managed; do not write to them directly.
  • Check PluginGutenbergSupport::is_enabled() before assuming the CPTs are exposed over REST or the block editor.
  • Respect the protected meta prefixes and options so your integrations survive the deletion-safety routine.
  • Do not depend on the enterprise scaffolding tables for data until a release documents active writers for them.

Common mistakes

  • Assuming show_in_rest is true — it is gated on the Gutenberg setting and defaults to off.
  • Guessing builder-group meta keys — the group base IDs are dynamic and cached; resolve them via the plugin, don’t hard-code.
  • Adding an hvnly_agent menu item manually — the CPT sets show_in_menu = false on purpose; AgentAdminMenu owns the menu.
  • Expecting the enterprise tables to be unsigned or populated — their id columns are signed bigint and most are unwritten scaffolding.

Need more help?

Can't find what you're looking for? Our team and community are here to help you ship faster.