Skip to content

Instantly share code, notes, and snippets.

@jack-arturo
Created February 20, 2026 12:18
Show Gist options
  • Select an option

  • Save jack-arturo/d34fe9c1261b5bbab82654133a41bba6 to your computer and use it in GitHub Desktop.

Select an option

Save jack-arturo/d34fe9c1261b5bbab82654133a41bba6 to your computer and use it in GitHub Desktop.

HubSpot v1-to-v3 Tag ID Migration -- Intermediate Patch

Problem Summary

HubSpot v1 lists API shuts down April 30, 2026. WP Fusion's migration tool only scans wpf-settings postmeta and usermeta, but tag IDs are scattered across 30+ storage locations (15+ postmeta keys, 3+ term meta keys, 20+ option patterns, 6 custom tables, page builder JSON blobs). PR #236 extends postmeta coverage significantly but doesn't solve runtime access failures or the deeper storage locations. We need a layered fix.

Phase 1: WPF_Tag_Migration Class (New File)

All runtime translation logic lives in a single dedicated class: includes/class-tag-migration.php. This keeps core classes clean and makes the migration system easy to find, test, and eventually extend for CRM switching.

1a. New file: includes/class-tag-migration.php

class WPF_Tag_Migration {

    private $id_map;
    private $prev_tags;

    public function __construct() {
        $this->id_map    = wpf_get_option( 'wpf_tag_id_map', array() );
        $this->prev_tags = wpf_get_option( 'wpf_available_tags_prev', array() );

        if ( empty( $this->id_map ) ) {
            return; // No migration active, zero hooks, zero overhead.
        }

        // Runtime translation.
        add_filter( 'wpf_get_tag_id', array( $this, 'translate_tag_id' ), 10, 2 );
        add_filter( 'wpf_get_tag_label', array( $this, 'translate_tag_label' ), 10, 2 );

        // Translate stored settings before access control comparisons.
        add_filter( 'wpf_post_access_meta', array( $this, 'translate_access_meta' ), 5 );

        // Admin UI: inject legacy tags into the tag picker.
        if ( is_admin() ) {
            add_filter( 'wpf_render_tag_multiselect_args', array( $this, 'inject_legacy_tags' ) );
        }
    }
}

When wpf_tag_id_map is empty (no migration active, or non-HubSpot users), the constructor returns immediately. Zero filters attached, zero performance impact.

Key methods:

  • translate_tag_id( $tag_id, $tag_name ) -- If $tag_id is in the map, return the mapped v3 ID. This automatically fixes has_tag() (which maps through get_tag_id at line 2015), Elementor (which maps through wpf_get_tag_id at line 320), and every other integration using the standard resolution path.
  • translate_tag_label( $label, $tag_id ) -- If the label is "Unknown" but the ID exists in $this->prev_tags, return the real label with a "(legacy)" suffix.
  • translate_access_meta( $settings ) -- Walk allow_tags, allow_tags_all, allow_tags_not, apply_tags, remove_tags arrays and swap any IDs found in the map. This catches the direct array_intersect in includes/class-access-control.php line 258, which doesn't go through get_tag_id().
  • inject_legacy_tags( $args ) -- For the tag picker UI, if $args['setting'] contains legacy IDs, ensure they appear in the available tags with a "(legacy)" badge so the UI doesn't show blanks.

1b. Add filter hooks to get_tag_id() and get_tag_label() in class-user.php

These are one-line additions that make both functions extensible beyond just migration:

In includes/class-user.php get_tag_id() (~line 2030), wrap the return values:

// Before each return statement:
return apply_filters( 'wpf_get_tag_id', $result, $tag_name );

In get_tag_label() (~line 2082), similarly:

return apply_filters( 'wpf_get_tag_label', $label, $tag_id );

These filters are useful beyond migration (third-party devs, custom tag aliasing, etc.) and keep the core methods clean.

1c. Load the class from wp-fusion.php

Require and instantiate WPF_Tag_Migration during plugin init. The class handles its own conditional loading via the early return in the constructor.

1d. Persistent mapping storage

Store two new options (not transients):

  • wpf_tag_id_map -- array( old_id => new_id ) -- the translation table
  • wpf_available_tags_prev -- snapshot of available_tags before migration, used for label resolution

HubSpot's fetch_legacy_id_mapping() in includes/crms/hubspot/admin/class-admin.php currently stores the map in a 1-hour transient. Change to update_option('wpf_tag_id_map', ...) so it persists as the runtime safety net. Snapshot available_tags into wpf_available_tags_prev before syncing v3 tags.

Phase 2: UI Enhancement and Auto-Upgrade on Save

2a. Legacy optgroup via inject_legacy_tags()

The WPF_Tag_Migration::inject_legacy_tags() method hooks into the existing wpf_render_tag_multiselect_args filter (line 49 of includes/admin/admin-functions.php). No changes needed to admin-functions.php itself for this -- the filter already exists.

The method checks if $args['setting'] contains IDs in the map, and injects them into available_tags with labels from prev_tags and a "(legacy)" suffix. This ensures the tag picker shows the selected value instead of blank/unknown.

The existing "Unknown tag(s)" warning (line 241) already fires for orphaned IDs. We can enhance this to show a migration-specific message, but that's a minor follow-up.

2b. Auto-translate on save

When wpf_clean_tags() in includes/functions.php processes tag arrays, check wpf_tag_id_map and swap any legacy IDs to their v3 equivalents. This means every time a user saves any settings form, stale IDs get silently upgraded. Over time this naturally cleans up the database.

Phase 3: Extended Migration Tool

3a. Merge PR #236

PR 236 already adds:

  • get_postmeta_tag_keys() covering wpf-settings, wpf-settings-learndash, wpf-settings-woo, wpf-settings-memberpress, wpf-settings-edd, _elementor_data, _elementor_popup_display_settings
  • Recursive JSON walker for Elementor data
  • Nested array support (WooCommerce variations)
  • Self-mapping and v3-collision safety checks

Merge this as the base.

3b. Add missing postmeta keys to get_postmeta_tag_keys()

PR 236 is missing several keys discovered in the codebase audit:

  • wpf-settings-llms-plan (LifterLMS plans) -- tag sub-keys: tag_link, apply_tags_enrolled, apply_tags
  • wpf_settings_llms_voucher (LifterLMS vouchers) -- apply_tags
  • wpf_settings_llms_group (LifterLMS groups) -- apply_tags, tag_link
  • wpf_settings (Tribe Tickets) -- apply_tags, allow_tags
  • wpf_block_settings (Tribe Tickets block) -- apply_tags
  • suremembers_plan_rules (SureMembers) -- apply_tags, tag_link

3c. Add term meta scanning step

New migration step between scan_users and map:

  • Scan termmeta for keys: wpf-settings-woo, wpf_settings_llms_track, wpf_settings_event
  • Same logic as postmeta scanning but against $wpdb->termmeta
  • Corresponding update step between update_users and finalize

3d. Add options scanning step

New migration step to scan known option patterns:

  • Dynamic keys: Query SELECT option_name, option_value FROM wp_options WHERE option_name LIKE 'wpf_pmp_%' and LIKE 'wpf_pmp_discount_%' and LIKE 'frm_wpf_settings_%'
  • Static keys: wpf_wpforo_settings, wpf_wpforo_settings_usergroups
  • WPF options keys (inside wpf_options): woo_tags, edd_tags, lifterlms_tags_*, slicewp_apply_tags_*, give_fund_tags_*, and the various *_status_tagging_* keys
  • Deserialize, walk tag arrays, swap IDs, re-serialize and save

3e. Add custom table scanning (stretch -- implement for the two most common)

  • FluentForms: {prefix}fluentform_form_meta WHERE meta_key = 'fluentform_wpfusion_feed' -- JSON value containing tags array
  • BookingPress: {prefix}bookingpress_servicesmeta WHERE bookingpress_servicemeta_name = 'wpf_apply_tags' -- serialized tags

Amelia, RCP, and Events Manager bookings are lower priority (fewer users) and can be deferred to a follow-up.

3f. Store map as persistent option

Change finalize_migration() to NOT delete the id_map. Instead, move it from transient to the permanent wpf_tag_id_map option so the runtime safety net stays active indefinitely.

Phase 4: Begin Registry Pattern on WPF_Integrations_Base

4a. Add default method to base class

In includes/integrations/class-base.php, add:

public function migrate_tag_ids( $id_map ) {
    // Default: no-op. Override in integrations with complex storage.
    return 0;
}

4b. Implement on Elementor first

Move PR 236's Elementor JSON walking logic into the Elementor integration class (includes/integrations/elementor/class-elementor.php) as its migrate_tag_ids() implementation. This is the natural home for Elementor-specific storage knowledge.

4c. Migration tool checks registry

In the migration wizard, after running the generic meta/options scans, iterate loaded integrations:

foreach ( wp_fusion()->integrations as $integration ) {
    if ( method_exists( $integration, 'migrate_tag_ids' ) ) {
        $integration->migrate_tag_ids( $id_map );
    }
}

This means as more integrations implement the method, the migration tool automatically picks them up. Integrations that don't implement it still get covered by the generic SQL scan.

What Ships Now vs. Later

In this patch (before April 30):

  • Phases 1-3: Runtime safety net, UI enhancement, extended migration tool
  • Phase 4a-4b: Registry method on base class + Elementor implementation

Follow-up (post-deadline):

  • Implement migrate_tag_ids() on more complex integrations (Beaver Builder, Bricks, Fluent Forms, BookingPress, Amelia)
  • CRM switch migration UI using the same wpf_tag_id_map infrastructure
  • Remaining custom table handlers (Amelia, RCP, Events Manager)

Key Files Modified

Files NOT modified (compared to old plan): class-access-control.php (uses existing wpf_post_access_meta filter), admin-functions.php (uses existing wpf_render_tag_multiselect_args filter).

Testing Strategy

  • Unit tests for WPF_Tag_Migration::translate_tag_id() -- v1 input returns v3 output via filter
  • Unit test for WPF_Tag_Migration::translate_tag_label() -- v1 ID returns label + "(legacy)" via filter
  • Unit test for WPF_Tag_Migration::translate_access_meta() -- settings with v1 IDs get translated before access check
  • Unit test for WPF_Tag_Migration::inject_legacy_tags() -- tag picker args include legacy tags with labels
  • Unit test that constructor attaches zero hooks when wpf_tag_id_map is empty
  • Integration test: HubSpot migration wizard with test data across postmeta, termmeta, options
  • Manual test: Elementor page with v1 tag IDs still grants/denies access correctly after v3 switch
  • Manual test: Tag picker shows legacy tags, saving auto-upgrades to v3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment