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.
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.
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_idis in the map, return the mapped v3 ID. This automatically fixeshas_tag()(which maps throughget_tag_idat line 2015), Elementor (which maps throughwpf_get_tag_idat 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 )-- Walkallow_tags,allow_tags_all,allow_tags_not,apply_tags,remove_tagsarrays and swap any IDs found in the map. This catches the directarray_intersectin includes/class-access-control.php line 258, which doesn't go throughget_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.
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.
Require and instantiate WPF_Tag_Migration during plugin init. The class handles its own conditional loading via the early return in the constructor.
Store two new options (not transients):
wpf_tag_id_map--array( old_id => new_id )-- the translation tablewpf_available_tags_prev-- snapshot ofavailable_tagsbefore 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.
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.
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.
PR 236 already adds:
get_postmeta_tag_keys()coveringwpf-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.
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_tagswpf_settings_llms_voucher(LifterLMS vouchers) --apply_tagswpf_settings_llms_group(LifterLMS groups) --apply_tags,tag_linkwpf_settings(Tribe Tickets) --apply_tags,allow_tagswpf_block_settings(Tribe Tickets block) --apply_tagssuremembers_plan_rules(SureMembers) --apply_tags,tag_link
New migration step between scan_users and map:
- Scan
termmetafor 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_usersandfinalize
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_%'andLIKE 'wpf_pmp_discount_%'andLIKE '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
- FluentForms:
{prefix}fluentform_form_meta WHERE meta_key = 'fluentform_wpfusion_feed'-- JSON value containingtagsarray - 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.
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.
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;
}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.
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.
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_mapinfrastructure - Remaining custom table handlers (Amelia, RCP, Events Manager)
- NEW includes/class-tag-migration.php --
WPF_Tag_Migrationclass with all runtime translation logic - includes/class-user.php -- Add
wpf_get_tag_idandwpf_get_tag_labelfilters (one-line additions at return points) - includes/functions.php --
wpf_clean_tags()auto-translate on save - wp-fusion.php -- Load and instantiate
WPF_Tag_Migration - includes/integrations/class-base.php --
migrate_tag_ids()default method - includes/integrations/elementor/class-elementor.php -- Registry implementation
- includes/crms/hubspot/admin/class-admin.php -- Extended migration steps (PR 236 base + term meta, options, custom tables, registry calls, persistent map)
- includes/crms/hubspot/class-hubspot.php -- Snapshot available_tags before v3 sync
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).
- 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_mapis 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