Skip to content

Extending the Plugin

Recipes. Every hook name and every parameter list below was taken from the source; drop these into a mu-plugin or your own plugin and they work as written.

The complete list of hooks is in the Hook Reference, which is generated from source. If something you want is not hookable, that is a bug in the hook surface, not a reason to patch a class.

Two layers, and you almost always want the outer one.

The setting-level layer: bpmb_allowed_roles

Section titled “The setting-level layer: bpmb_allowed_roles”

BP_Member_Blog_Access_Control::get_allowed_roles() reads the roles the site owner ticked in Access & Permissions and passes them through this filter. Filtering it changes what the whole plugin considers an allowed role, including the admin screen’s effective behaviour.

/**
* Only editors and a custom "columnist" role may write, whatever the admin screen says.
*
* @param array $roles Role slugs from the Access settings.
* @return array
*/
add_filter( 'bpmb_allowed_roles', function ( $roles ) {
return array( 'editor', 'columnist' );
} );

The member-type equivalent is bpmb_allowed_member_types ( array $types ), used when BuddyPress is active.

Note the OR logic in can_create(): if roles and member types are both configured, either one grants access independently. If only member types are configured, only the member type is checked.

The decision-level layer: bpmb_can_create_post

Section titled “The decision-level layer: bpmb_can_create_post”

This is the gate everything asks - the submission form, the REST autosave route, and RestGate on core’s POST /wp/v2/posts. It is where Pro hangs its post limits and its credit balance. Filter it and every door closes together.

/**
* Only members who have been on the site for 7 days may create posts.
*
* @param bool $can_create Whether the plugin has decided they may.
* @param int $user_id The user being asked about.
* @return bool
*/
add_filter( 'bpmb_can_create_post', function ( $can_create, $user_id ) {
if ( ! $can_create ) {
return false; // never widen a denial you did not issue
}
if ( user_can( $user_id, 'manage_options' ) ) {
return true;
}
$user = get_userdata( $user_id );
if ( ! $user ) {
return false;
}
$registered = strtotime( $user->user_registered );
return ( time() - $registered ) >= WEEK_IN_SECONDS;
}, 10, 2 );

Restricting by BuddyPress member type at this layer:

add_filter( 'bpmb_can_create_post', function ( $can_create, $user_id ) {
if ( ! $can_create || ! function_exists( 'bp_get_member_type' ) ) {
return $can_create;
}
$types = (array) bp_get_member_type( $user_id, false );
return in_array( 'contributor', $types, true );
}, 10, 2 );

Related gates, same shape: bpmb_can_edit_post ( $can_edit, $post_id, $user_id ), bpmb_can_delete_post ( $can_delete, $post_id, $user_id ), bpmb_can_manage_categories ( $can_manage, $user_id ).

Results are memoized per request. If you change a user’s role at runtime, the cache is cleared automatically on set_user_role / add_user_role / remove_user_role.

There are two separate questions here, and conflating them is how a member’s live post silently disappears.

can_publish() answers “may this member put a post straight on the site, or does it go to pending?” With the site’s “Publish immediately” setting off, only a user with edit_others_posts (an editor, not an author) may publish directly. Everyone else lands in pending.

To hold a specific group back even when direct publishing is on:

/**
* New members are moderated for their first 3 published posts.
*
* @param bool $can_publish Whether the plugin has decided they may publish.
* @param int $post_id Post being published (0 for a new post).
* @param int $user_id The author.
* @return bool
*/
add_filter( 'bpmb_can_publish_post', function ( $can_publish, $post_id, $user_id ) {
if ( ! $can_publish || user_can( $user_id, 'edit_others_posts' ) ) {
return $can_publish;
}
$published = count_user_posts( $user_id, 'post', true );
return $published >= 3;
}, 10, 3 );

PostSubmissionService::resolve_status() reads that gate and turns it into a status. The order is frozen: “Save draft” always wins, then publish if they may publish, then pending.

By default, editing an already published post does not send it back for review. This is deliberate. Without it, a moderated member who fixes a typo in their own live post falls into the pending branch and their post vanishes from the site with no warning, staying down until somebody notices it in a queue. Moderation gates publication, not edits, and that is also what core does - an author may freely edit their own published posts.

A site that genuinely wants every edit re-reviewed has to ask:

/**
* Send every edit of a published post back to pending.
*
* @param bool $moderate_edits Default false: the post stays live.
* @param string $current The post's current status ('publish' or 'private').
* @return bool
*/
add_filter( 'bpmb_moderate_edits', '__return_true' );

Or only for members who are not trusted yet:

add_filter( 'bpmb_moderate_edits', function ( $moderate_edits, $current ) {
return ! current_user_can( 'edit_others_posts' );
}, 10, 2 );

Moderated posts appear in the review queue (src/Admin/ModerationAdmin.php) under Posts, where they can be approved or rejected.

Three hooks, and you need all three. The dashboard is rendered by the [member-blog-dashboard] shortcode in public/class-buddypress-member-blog-public.php.

  1. bp_member_blog_dashboard_tabs (filter) tells the shortcode your ?tab= slug is legal. Without it, the query arg is ignored.
  2. bp_member_blog_dashboard_tabs_nav (action) renders the nav link.
  3. bp_member_blog_dashboard_custom_tab (filter) renders the body and returns true to tell the shortcode you handled it.

The built-in slugs are posts, drafts and pending. DashboardService::normalise_tab() falls back to posts for anything it does not recognise, so a custom tab must render its own content - it will not get a post list for free.

/**
* A "Bookmarks" tab on the member dashboard.
*/
// 1. Make the slug legal.
add_filter( 'bp_member_blog_dashboard_tabs', function ( $tabs ) {
$tabs[] = 'bookmarks';
return $tabs;
} );
// 2. Render the nav link.
add_action( 'bp_member_blog_dashboard_tabs_nav', function ( $base_url, $active_tab, $user_id, $is_own_profile ) {
if ( ! $is_own_profile ) {
return; // a reading list is private
}
printf(
'<a href="%1$s" class="member-blog-tab %2$s" role="tab" aria-selected="%3$s">%4$s</a>',
esc_url( add_query_arg( 'tab', 'bookmarks', $base_url ) ),
'bookmarks' === $active_tab ? 'active' : '',
'bookmarks' === $active_tab ? 'true' : 'false',
esc_html__( 'Bookmarks', 'my-plugin' )
);
}, 10, 4 );
// 3. Render the body, and claim the tab.
add_filter( 'bp_member_blog_dashboard_custom_tab', function ( $handled, $tab, $user_id, $is_own_profile ) {
if ( 'bookmarks' !== $tab || ! $is_own_profile ) {
return $handled;
}
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$result = bpmb_bookmarks()->list_for_user( $user_id, $paged, 10 );
$posts = bpmb_post_query()->find_many( $result['ids'] );
if ( ! $posts ) {
bpmb_get_template_part(
'parts/empty-state',
array(
'user_id' => $user_id,
'is_my_own' => true,
)
);
return true;
}
// Draw through the shared renderer, so the cards match every other surface.
echo bpmb_post_cards()->grid( $posts ); // phpcs:ignore WordPress.Security.EscapingOutput.OutputNotEscaped -- renderer escapes.
return true;
}, 10, 4 );

find_many() hydrates the whole page in one query. Never call get_post() per row.

bpmb_post_published fires once, after the post is live and after _thumbnail_id has been written - so a listener can read get_post_thumbnail_id() and get the right answer. It is what Pro uses to deduct a credit.

/**
* Ping a Slack webhook when a member publishes.
*
* @param int $post_id Post ID.
* @param int $user_id The publishing user's ID.
*/
add_action( 'bpmb_post_published', function ( $post_id, $user_id ) {
$user = get_userdata( $user_id );
if ( ! $user ) {
return;
}
wp_remote_post(
'https://hooks.slack.com/services/XXX/YYY/ZZZ',
array(
'blocking' => false,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode(
array(
'text' => sprintf(
'%1$s published "%2$s": %3$s',
$user->display_name,
get_the_title( $post_id ),
get_permalink( $post_id )
),
)
),
)
);
}, 10, 2 );

It fires from two places, both of them “the member published it”: the submission handler in public/class-buddypress-member-blog-public.php (the post form), and the dashboard publish action (the member publishing their own draft).

It does not fire when an editor approves a pending post. That path is src/Admin/ModerationAdmin.php and it fires its own actions:

Hook Signature
bpmb_post_approved ( int $post_id, int $approver_user_id )
bpmb_post_rejected ( int $post_id, int $rejecter_user_id )

If your listener should run however a post reaches the site, hook both bpmb_post_published and bpmb_post_approved. Approve is guarded against a double-fire: only a pending post can be approved, so two reviewers clicking Approve in two tabs cannot re-publish the post and notify its followers twice.

The clap equivalent is bpmb_post_clapped ( $post_id, $user_id, $mine, $total ).

bpmb_writer_url() is the one answer to “link me to this writer”. Every byline, every author card, every REST payload calls it. Before 4.0.0 there were three implementations that disagreed, and on a site without BuddyPress every card in the author directory linked to the same page - a dashboard that showed the viewer’s own posts.

The filter is the seam for a site that hosts its writers somewhere else entirely.

/**
* Send writers to /w/{nickname}/ instead of /author/{nickname}/.
*
* @param string $url The URL the plugin resolved.
* @param int $user_id The writer.
* @return string
*/
add_filter( 'bpmb_writer_url', function ( $url, $user_id ) {
$user = get_userdata( $user_id );
if ( ! $user ) {
return $url;
}
return home_url( '/w/' . $user->user_nicename . '/' );
}, 10, 2 );

If you point the URL somewhere else, remember the plugin no longer renders anything at the destination. Either build that page, or leave the takeover on and restyle templates/writer-profile.php instead - see templates.md.

By default the hub renders on category and post_tag. Add your own taxonomy and its term archive gets the same treatment: the term description as a real header, the writers behind the topic, and the same post cards as every other surface.

/**
* Give the topic hub to a custom "topic" taxonomy.
*
* @param string[] $taxonomies Default: array( 'category', 'post_tag' ).
* @return string[]
*/
add_filter( 'bpmb_topic_hub_taxonomies', function ( $taxonomies ) {
$taxonomies[] = 'topic';
return $taxonomies;
} );

The template handles a custom taxonomy correctly: category and post_tag map onto the query’s dedicated args, and anything else falls through to a tax_query, so it is not silently ignored.

To go the other way and take the hub off tags while keeping it on categories, use bpmb_takeover_term_archive - see templates.md.

A member may clap a single post up to 50 times, as Medium does. The cap is enforced in SQL by ReactionService::clap() (LEAST(claps + %d, %d) inside a single atomic statement), so there is no read-then-write race to lose.

/**
* One clap per member per post: turn claps into a like button.
*
* @param int $max Default 50.
* @return int
*/
add_filter( 'bpmb_max_claps', function ( $max ) {
return 1;
} );

The floor is 1 - max( 1, (int) apply_filters( ... ) ) - so returning 0 or a negative gives you 1, not a disabled button.

The POST /reactions route additionally caps a single request at 50 claps, independent of this filter. That is a request-size bound, not the per-member total; a client batching a burst of taps sends its accumulated count and the service applies the real cap.

Featuring is an editorial act. The default capability is edit_others_posts - an editor, not an author. If an author capability were enough, every member could promote themselves onto the front page.

/**
* Let anyone who can moderate comments feature posts.
*
* @param string $cap Default 'edit_others_posts'.
* @return string
*/
add_filter( 'bpmb_feature_capability', function ( $cap ) {
return 'moderate_comments';
} );

This filter governs all three entry points at once, because they all call FeaturedService::user_can_feature(): the POST /posts/{id}/featured REST route, the Featured row action on the wp-admin posts list, and anything Pro adds.

Pick a capability, not a role. user_can( $user_id, 'columnist' ) will not work the way you expect - WordPress will treat it as a capability name, and unless you registered a capability by that name it is always false.

Read public/css/bpmb-ui.css. It is the single source of truth for every --bpmb-* token; no other stylesheet in Free or Pro may declare one.

Themes ship blanket rules for bare elements, and they out-specify a plain class by a mile. BuddyX’s is real:

button:not([class*="wp-"]):not([class*="customize-"]):not([class*="wc-block-"])
:not([class*="bn-"]):not([class*="mvs-"]):not(.bx-color-mode-toggle__btn)
:not(.menu-toggle):not(.menu-close):not(.datepicker button):not([class*="tribe-events"])

Ten :not()s. Specificity (0,10,1). BuddyPress Nouveau has its own. The result was that the same clap button rendered 40x40 on a shortcode page, 62x48 inside a BuddyPress wrap, and 22px tall on the author archive - three looks for one component, one of them below the WCAG minimum tap target.

So .bpmb-follow-btn, .bpmb-clap-btn and .bpmb-bookmark-btn are armoured:

.bpmb-follow-btn,
.bpmb-clap-btn,
.bpmb-bookmark-btn {
all: unset !important;
box-sizing: border-box !important;
display: inline-flex !important;
align-items: center !important;
gap: var(--bpmb-space-xs) !important;
min-width: 40px !important;
min-height: 40px !important;
font-family: inherit !important; /* the SITE's typeface */
font-size: var(--bpmb-font-size-sm) !important;
border-radius: 999px !important;
}

Repeating the class to win on specificity was tried. It produced (0,4,0), it lost to (0,10,1), and it would lose again the day a theme adds an eleventh :not(). Counting selectors is an arms race against every theme that will ever exist. Short of Shadow DOM, !important is the only deterministic way for a component to keep its own geometry.

The contract that creates:

The tokens are the API. The rules are ours.

You restyle these components by setting CSS custom properties, not by out-specifying our selectors. That seam is documented, it is stable across releases, and it cannot be broken by us renaming a class. Trying to out-specify the armour is not supported and will not work.

The plugin still does not take your palette: --bpmb-primary maps to the theme’s accent, the font is inherited (all: unset is inherit-for-inherited properties), and radius and spacing come from tokens you can set.

Enqueue a small stylesheet after bpmb-ui-helpers, or drop this in the Customizer’s Additional CSS.

:root {
/* Brand */
--bpmb-primary: #7c3aed;
--bpmb-primary-hover: #6d28d9;
/* Squared-off components instead of pills */
--bpmb-radius-sm: 0;
--bpmb-radius-md: 2px;
--bpmb-radius-lg: 2px;
/* A denser layout */
--bpmb-space-sm: 6px;
--bpmb-space-md: 12px;
--bpmb-space-lg: 18px;
}

To restyle a single component without touching the rest, set the token on that component’s own selector (layer 3 in the token model):

.bpmb-follow-btn {
--bpmb-primary-filled: #111827; /* a black follow button, nothing else changes */
}

That works because the armour rules read var(--bpmb-primary-filled). Adding background: red !important to .bpmb-follow-btn does not, reliably, and it is not a supported seam.

Layer 2, the semantic tokens, declared once in :root in public/css/bpmb-ui.css. Colour tokens resolve through a fallback chain (BuddyX, then Reign or the theme’s --global-*, then --wp--preset--*, then the canonical hex) so a themed site keeps its theme’s colours and a standalone site gets the canonical palette.

Brand

Token Default
--bpmb-primary Theme accent, falling back to #2563eb
--bpmb-primary-hover Theme hover, falling back to #1d4ed8
--bpmb-primary-dark #1d4ed8
--bpmb-primary-light, --bpmb-primary-light-hover, --bpmb-primary-light-border, --bpmb-primary-ultra-light The tint ramp
--bpmb-primary-filled --bpmb-primary darkened 22% with color-mix(). This, not --bpmb-primary, is the filled-button background. A theme is free to pick an accent white text cannot sit on (BuddyX’s coral carries white at 3.46:1, which fails AA); darkening keeps the theme’s hue and makes the label readable.
--bpmb-on-primary #fff in light mode, dark in dark mode. Anything sitting on --bpmb-primary uses this, never #fff - in dark mode the accent goes pale and white-on-white measures 1.89:1.
--bpmb-secondary, --bpmb-secondary-hover, --bpmb-secondary-light The neutral action colour
--bpmb-accent, --bpmb-link-color Alias --bpmb-primary

Status. Each has a base, a -bg, a -light and a -text: --bpmb-success (#16a34a), --bpmb-danger (#dc2626), --bpmb-warning (#d97706), --bpmb-info (#2563eb), --bpmb-scheduled (#6f42c1), plus --bpmb-featured (#f59e0b) and --bpmb-unlimited (#059669).

Text

--bpmb-text-primary, --bpmb-text-secondary, --bpmb-text-tertiary, --bpmb-text-muted, --bpmb-text-light, --bpmb-text-dark, --bpmb-text-color.

Surfaces

--bpmb-bg-primary, --bpmb-bg-secondary, --bpmb-bg-tertiary, --bpmb-bg-hover, --bpmb-bg-active, --bpmb-bg-light, --bpmb-bg-lighter, --bpmb-bg-dark, --bpmb-bg-white, --bpmb-white.

Borders

--bpmb-border-color, --bpmb-border-color-dark, --bpmb-border-color-light, --bpmb-border-color-input, --bpmb-border-strong.

Neutral ramp

--bpmb-gray-50 through --bpmb-gray-950.

Spacing

Token Value
--bpmb-space-xs 4px
--bpmb-space-sm 8px
--bpmb-space-md 16px
--bpmb-space-lg 24px
--bpmb-space-xl 32px
--bpmb-space-2xl 48px

Radius

Token Value
--bpmb-radius-sm 4px
--bpmb-radius-md 6px
--bpmb-radius-lg 10px
--bpmb-radius-xl 16px
--bpmb-radius-full 100px

Aliases --bpmb-border-radius-sm, --bpmb-border-radius, --bpmb-border-radius-md, --bpmb-border-radius-lg, --bpmb-border-radius-xl exist for Pro’s older skins. Same scale, older names.

Typography

--bpmb-font-size-xs (12px) through --bpmb-font-size-3xl (22px); --bpmb-font-weight-normal|medium|semibold|bold; --bpmb-line-height-tight|base|relaxed.

There is no font-family token. The components inherit the site’s typeface, deliberately.

Shadow

--bpmb-shadow-sm, --bpmb-shadow-md, --bpmb-shadow-lg, --bpmb-shadow-hover, --bpmb-shadow-modal, --bpmb-shadow-focus.

Motion

--bpmb-transition-fast (150ms), --bpmb-transition-base / -normal (200ms), --bpmb-transition-slow (300ms), --bpmb-transition-slowest (500ms).

Z-index

--bpmb-z-dropdown (100), --bpmb-z-modal (1000), --bpmb-z-tooltip (1100), --bpmb-z-overlay (99999).

Icons (Lucide)

--bpmb-icon-stroke (1.75), --bpmb-icon-xs (12px) through --bpmb-icon-xl (24px).

Inputs

--bpmb-input-bg, --bpmb-input-border-color, --bpmb-input-border-radius, --bpmb-input-focus-border, --bpmb-input-focus-shadow, --bpmb-input-font-size, --bpmb-input-height (40px), --bpmb-input-padding-x, --bpmb-input-padding-y.

Admin surface (wp-admin only)

A parallel --bpmb-admin-* set: accent, surfaces, text, borders, radius, shadow, gaps, and --bpmb-admin-tap (40px).

Dark mode is the same token with a different value, overridden once at :root. Never write a per-component dark rule. The selectors that trigger it:

:root[data-bx-mode="dark"],
:root[data-theme="dark"],
html.dark { /* token overrides */ }
@media (prefers-color-scheme: dark) {
:root[data-bx-mode="auto"] { /* the same overrides */ }
}

If you add tokens of your own for a custom component, override them in the same block or your component will be the only thing on the page that does not follow the site’s theme toggle.

bpmb-ui.css has a generated bpmb-ui-rtl.css sibling, wired up with wp_style_add_data(), so core loads it on RTL locales. Author with logical properties (margin-inline-start, padding-inline-end) and the generator handles the rest.

If you are building something substantial, follow the shape Pro uses.

  1. Gate a platform-specific feature before you register its hooks. The plugin runs on BuddyPress, BuddyBoss, PeepSo and standalone WordPress. A feature that needs the activity stream must be absent on a site with no activity stream, not degraded and not fatal. Ask bpmb_module_requirements_met(), which resolves against Member_Blog_Compat::supports():

    add_action( 'init', function () {
    if ( ! function_exists( 'bpmb_module_requirements_met' ) ) {
    return;
    }
    // Empty array = standalone-safe. Otherwise: members, profile_nav, activity,
    // groups, notifications, friends, messages, xprofile.
    if ( ! bpmb_module_requirements_met( array( 'activity' ) ) ) {
    return; // no activity stream on this site: register nothing at all
    }
    add_action( 'bpmb_post_published', 'my_feature_post_to_activity', 10, 2 );
    } );

    \Wbcom\MemberBlog\Core\ServiceProvider and Core\Registry formalise the same idea as an interface (requires() plus register()), and bpmb_registry() exposes the shared instance. Free does not boot the registry for you today, so if you use it you must call boot() yourself. bpmb_module_requirements_met() is the mechanism that is actually driven, and it is what Pro delegates to.

  2. Render through the shared components. bpmb_components() is the component factory; bpmb_post_cards() draws cards. Free owns the markup, Pro consumes it and invents nothing. That is the mechanism that makes a Pro list and a Free list visually identical rather than merely similar.

  3. Ask the services, do not re-derive the rules. bpmb_access(), bpmb_submission(), bpmb_dashboard(). A second copy of a rule is how a post ends up live over REST while an identical one sits in moderation behind the form.

  4. Extend the REST API by extending RestController. You inherit the namespace, the permission callbacks (can_read(), can_mutate(), including the Application Password refusal), the error shape and the pagination contract.

    namespace My_Plugin\Rest;
    use Wbcom\MemberBlog\Rest\RestController;
    use WP_REST_Server;
    final class MyController extends RestController {
    public function register_routes(): void {
    register_rest_route(
    self::NAMESPACE_V1,
    '/my-thing',
    array(
    array(
    'methods' => WP_REST_Server::CREATABLE,
    'callback' => array( $this, 'create' ),
    'permission_callback' => array( $this, 'can_mutate' ),
    'args' => array(
    'post_id' => array(
    'type' => 'integer',
    'required' => true,
    'sanitize_callback' => 'absint',
    ),
    ),
    ),
    )
    );
    }
    public function create( $request ) {
    $post_id = absint( $request->get_param( 'post_id' ) );
    if ( ! bpmb_access()->can_edit( $post_id ) ) {
    return $this->error( 'cannot_edit', __( 'Not yours.', 'my-plugin' ), 403 );
    }
    return rest_ensure_response( array( 'ok' => true ) );
    }
    }
    add_action( 'rest_api_init', static function () {
    ( new \My_Plugin\Rest\MyController() )->register_routes();
    } );

    One thing to watch: minimum and maximum on an arg do nothing unless the arg also declares 'validate_callback' => 'rest_validate_request_arg'. WordPress only runs schema validation when a validate callback is present. This was measured - a route with maximum: 50 returned 200 for a value of 10000.

Deleting the plugin always removes its own bookkeeping — settings, schema versions, backfill flags, transients and cron events. That is lossless: reinstalling recreates all of it.

Members’ data is different, and is removed only when the site owner has ticked Member Blog → Content → When this plugin is deleted. That covers the seven custom tables (reading lists, follows, claps, view history, writer statistics, series progress) and the plugin’s post and user meta.

use Wbcom\MemberBlog\Core\Uninstaller;
// The stored answer. False unless the owner explicitly opted in.
Uninstaller::removes_data();
// The setting key, if you need to read or set it yourself.
$settings = get_option( Uninstaller::SETTINGS_OPTION );
$settings[ Uninstaller::REMOVE_DATA_SETTING ] = 'yes';

Only the literal string 'yes' counts as consent. A missing key, a malformed option, or a truthy-looking '1' all read as keep the data — an absent answer is not consent.

Pro reads the same setting. One decision covers both plugins, so a site owner cannot end up having agreed to lose reading lists while believing the credit ledger was safe.

Tables are never listed in the uninstaller. Each store owns its own names, and the uninstaller asks:

ViewCountStore::tables(); // and ::options()
EngagementStore::tables(); // and ::options()

Add your table to the relevant tables() method and it is dropped on removal. That is the only edit required. Pro does the same through Buddypress_Member_Blog_Pro_Tables::all().

Pro’s registry deliberately lives in its own file with no side effects at include time, because the uninstaller must resolve table names on a site where nothing is booted. Requiring the module that owns a table would run its ::instance() at file scope and let maybe_create_tables() recreate the table moments after it was dropped.

Each plugin removes only what it created. bpmb_series_progress is created by the free plugin and written by Pro, so Free drops it; Pro must not. The same applies to meta — bpmb_notification_preferences is declared by Free (NotificationPrefs::META) and merely read by Pro, so it is removed with Free. Deleting one plugin must never destroy the other’s data on a site that still has it installed.