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.
Restrict posting to a role or member type
Section titled “Restrict posting to a role or member type”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.
Force moderation for some users
Section titled “Force moderation for some users”There are two separate questions here, and conflating them is how a member’s live post silently disappears.
The publish gate: bpmb_can_publish_post
Section titled “The publish gate: bpmb_can_publish_post”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.
Moderating edits: bpmb_moderate_edits
Section titled “Moderating edits: bpmb_moderate_edits”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.
Add a tab to the member dashboard
Section titled “Add a tab to the member dashboard”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.
bp_member_blog_dashboard_tabs(filter) tells the shortcode your?tab=slug is legal. Without it, the query arg is ignored.bp_member_blog_dashboard_tabs_nav(action) renders the nav link.bp_member_blog_dashboard_custom_tab(filter) renders the body and returnstrueto 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.
React to a published post
Section titled “React to a published post”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 ).
Change the writer profile URL
Section titled “Change the writer profile URL”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.
Add a taxonomy to the topic hub
Section titled “Add a taxonomy to the topic hub”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.
Cap claps
Section titled “Cap claps”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.
Change who can feature a post
Section titled “Change who can feature a post”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.
The CSS contract: the tokens are the API
Section titled “The CSS contract: the tokens are the API”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.
The armour, and why it exists
Section titled “The armour, and why it exists”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.
Restyling: set the tokens
Section titled “Restyling: set the tokens”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.
The token list
Section titled “The token list”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
Section titled “Dark mode”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.
Building a Pro-style extension
Section titled “Building a Pro-style extension”If you are building something substantial, follow the shape Pro uses.
-
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 againstMember_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\ServiceProviderandCore\Registryformalise the same idea as an interface (requires()plusregister()), andbpmb_registry()exposes the shared instance. Free does not boot the registry for you today, so if you use it you must callboot()yourself.bpmb_module_requirements_met()is the mechanism that is actually driven, and it is what Pro delegates to. -
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. -
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. -
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:
minimumandmaximumon 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 withmaximum: 50returned 200 for a value of 10000.
Data on uninstall
Section titled “Data on uninstall”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.
Adding a table to the cleanup
Section titled “Adding a table to the cleanup”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.
Ownership across the free/Pro boundary
Section titled “Ownership across the free/Pro boundary”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.

