Skip to content

Extending BuddyNext - a recipe cookbook

Short, runnable recipes for the most common ways to extend BuddyNext from an addon plugin or a theme. Each recipe states the goal, names the seam, and gives a working snippet you can drop into a plugins_loaded-or-later hook. These are the same seams BuddyNext Pro uses - Pro never re-implements Free code, it attaches to these filters and actions, and so should you.

The feed surface most cookbook recipes extend through the documented filter and action seams

BuddyNext exposes three families of extension point:

  • Action hooks - fired after every significant platform event (buddynext_post_created, buddynext_user_followed, …). React, do not block.
  • Filter hooks - let you change a value before BuddyNext uses it (buddynext_reaction_types, buddynext_post_pin_limit, buddynext_safeguard_check, …). Always return the value.
  • The service container - buddynext_service( '<key>' ) resolves any core service (post_service, follows, webhooks, …) so an addon can read and write through the same code paths the plugin uses.

Two rules apply across every recipe:

  1. Register on plugins_loaded priority 20 or later (BuddyNext boots at plugins_loaded:15 and fires buddynext_loaded when ready). Hooking earlier risks the container not being built yet.
  2. A filter must always return a value. Returning null or nothing from a filter breaks the feature.

For the full inventory of hooks behind these recipes, see Feed and Content Hooks, Spaces Hooks, Notifications and Email Hooks, Moderation, Auth and Trust Hooks, and Search, Hashtags, Sidebar and Admin Hooks.


Goal: add a reaction beyond the built-in six (like, love, haha, wow, sad, angry).

Seam: buddynext_reaction_types (which slugs are allowed) plus buddynext_reaction_meta (the label/char/color for a slug). The owner-facing buddynext_enabled_reactions option is a separate, owner-chosen subset of the built-in six - do not write it from an addon; it is the site owner’s on/off control in Settings > Activity Feed.

This is exactly how Pro Custom Reactions works: its CustomReactionsService stores admin-configured slugs and merges them in through buddynext_reaction_types, capping the merged total at 20.

add_filter(
'buddynext_reaction_types',
static function ( array $types ): array {
// Append, never replace - the built-in six must stay present.
$types[] = 'celebrate';
return $types;
}
);
add_filter(
'buddynext_reaction_meta',
static function ( array $meta, string $slug ): array {
if ( 'celebrate' === $slug ) {
$meta['label'] = __( 'Celebrate', 'my-addon' );
}
return $meta;
},
10,
2
);

Important: Every slug you add needs a matching icon at assets/icons/reaction-{slug}.svg (or, for the Pro Fluent-emoji path, a vendored emoji slug) and a --bn-reaction-{slug} color token. Adding a slug with no icon/token renders a broken reaction picker. BuddyNext resolves the list through ReactionService::reaction_types() - never read the REACTION_TYPES constant directly, or you bypass the filter.


Goal: let some users pin more than one post (the Free default is 1 pinned post per profile; pinning is profile-only - space content is featured through Announcements).

Seam: buddynext_post_pin_limit. It is read in PostService as apply_filters( 'buddynext_post_pin_limit', 1, null, $user_id ), so you receive the acting user and can decide per case. This is the seam Pro’s ProPinService uses to lift the cap for premium members.

add_filter(
'buddynext_post_pin_limit',
static function ( int $limit, ?int $space_id, int $user_id ): int {
// Editors get up to 5 pins anywhere; everyone else keeps the default.
if ( user_can( $user_id, 'edit_others_posts' ) ) {
return 5;
}
return $limit;
},
10,
3
);

$space_id is always null (pinning is profile-only); the third parameter is kept for the filter’s stable signature so Pro can still raise a member’s cap.


Goal: fire your addon’s own event to every external endpoint a site owner has registered, signed with the site’s per-endpoint HMAC secret.

Seam: the webhooks service. BuddyNext’s own OutboundWebhookListener does nothing more than call buddynext_service( 'webhooks' )->dispatch( $event_slug, $payload ) from each core action handler - your addon does the same with its own slug. Delivery is queued to Action Scheduler, fanned out only to endpoints subscribed to that slug, signed, logged, and retried with backoff. You write one line.

Runnable, tested snippet: a copy-paste, live-verified version is in buddynext/buddynext-snippets at integrations/dispatch-webhook-event.php. Drop it in wp-content/mu-plugins/ and it works as-is.

add_action( 'my_addon_course_completed', static function ( int $user_id, int $course_id ): void {
buddynext_service( 'webhooks' )->dispatch(
'course.completed', // your event slug (use a dotted namespace)
array(
'user_id' => $user_id,
'course_id' => $course_id,
'completed' => current_time( 'mysql', true ),
)
);
}, 10, 2 );

Endpoints that subscribe to all events (an empty event list) receive your slug automatically; endpoints with an explicit subscription list receive it only if course.completed is on their list. The number of endpoints a site may register is itself filterable - see Recipe 9 - via buddynext_outbound_webhook_limit (Free default 1, Pro lifts it).

Note: Do not query bn_outbound_webhooks or build signatures yourself. Dispatching through the service is the only supported path and is what keeps signing, retry, and logging correct.


Goal: block a post (or comment, or any user-submitted text) at submit time based on your own rule.

Seam: buddynext_safeguard_check. In SafeguardService::check() it runs after the built-in IP, banned-word, blocked-domain, rate-limit, and banned-hashtag gates, and before the duplicate-content and new-member gates - those two return a hold-for-review verdict, and a hold must never outrank a hard block. Return true to allow, or a WP_Error to reject - the WP_Error message is shown to the user. The same filter runs on edits via check_content(), so your rule covers edited content too.

This is the seam the Pro Moderation Rules engine attaches its keyword blocklists and ML scoring to. Banned-word lists are configured through that rules engine, not by adding check_*() methods to SafeguardService.

Runnable, tested snippet: a copy-paste, live-verified version is in buddynext/buddynext-snippets at hooks/block-content-at-submit.php (both a content rule and a create-time cooldown, with the is_wp_error passthrough). Drop it in wp-content/mu-plugins/ and it works as-is.

The filter takes five arguments. The fifth, $context, is 'create' or 'edit', and it is the one you have to think about: if your rule counts an author’s recent activity (a rate limit, flood control, a cooldown), it must skip 'edit'. An edit is not a new post, and re-asking the question there locks an author who has hit your cap out of editing the posts they already published. Content rules - banned words, links, ML scoring - should keep running on edits, or editing becomes a way to smuggle content past you.

add_filter(
'buddynext_safeguard_check',
static function ( $result, int $user_id, string $content, string $link_url, string $context ) {
// Respect an upstream block - never override another guard's WP_Error.
if ( is_wp_error( $result ) ) {
return $result;
}
// A CONTENT rule: it must keep running on edits too, so do not gate it on $context.
if ( false !== stripos( $content, 'buy-now-cheap.example' ) ) {
return new WP_Error(
'my_addon_spam',
__( 'That link is not allowed here.', 'my-addon' )
);
}
// A "how often" rule: create-time only. On an edit, stand down.
if ( 'create' === $context && my_addon_over_hourly_cap( $user_id ) ) {
return new WP_Error(
'my_addon_flood',
__( 'You are posting too quickly. Try again shortly.', 'my-addon' )
);
}
return true;
},
10,
5
);

Warning: Check is_wp_error( $result ) first and pass an existing error through unchanged. If you always return true you silently re-allow content another safeguard already blocked.

Warning: Declare 5 accepted args, not 4. A callback registered with 4 never sees $context, so a rate-limit-shaped rule fires on every edit - which is exactly the bug this argument was added to fix.


Goal: create your own in-app notification and react when any notification is created.

Seam: write with buddynext_service( 'notifications' )->create( $data ); observe with the buddynext_notification_created action; gate with the buddynext_notification_should_send filter.

create() already respects the recipient’s per-type and per-channel preferences, the should_send gate, and the send_at scheduling filter, then fires buddynext_notification_created. Use a group_key to collapse repeated notifications of the same kind into one row within a 24-hour window.

// 1. Create a notification of your own type.
add_action( 'my_addon_mention_detected', static function ( int $recipient_id, int $sender_id, int $object_id ): void {
buddynext_service( 'notifications' )->create(
array(
'recipient_id' => $recipient_id,
'sender_id' => $sender_id,
'type' => 'my_addon_mention',
'object_type' => 'post',
'object_id' => $object_id,
'group_key' => 'my_addon_mention_' . $object_id,
)
);
}, 10, 3 );
// 2. React when ANY notification is created (e.g. mirror to your own channel).
add_action( 'buddynext_notification_created', static function ( int $notification_id, int $recipient_id, array $data ): void {
if ( ( $data['type'] ?? '' ) !== 'my_addon_mention' ) {
return;
}
// your dispatch logic here
}, 10, 3 );
// 3. Optionally suppress a notification before it is ever stored.
add_filter( 'buddynext_notification_should_send', static function ( bool $should, array $payload ): bool {
if ( ( $payload['type'] ?? '' ) === 'my_addon_mention' && is_user_muted_by_addon( (int) $payload['recipient_id'] ) ) {
return false; // create() returns 0, nothing is stored or emailed.
}
return $should;
}, 10, 2 );

Note: The live buddynext_notification_created signature is ( int $notification_id, int $recipient_id, array $data ) - the notification type is read from $data['type'], not a separate parameter. A type whose per-type in-app preference is off causes create() to return 0 and store nothing.


Goal: block certain users from joining or requesting membership in a space (for example, gate a space behind a paid plan).

Seam: buddynext_can_join_space. It runs in SpaceMemberService for both the direct-join and the request-to-join paths, receiving the resolved space row, the user, and the action. Return false to block. This is the seam Pro uses for gated spaces.

Runnable, tested snippet: a copy-paste, live-verified version is in buddynext/buddynext-snippets at roles-caps/gate-space-join.php (a hold-flag gate you can adapt to a capability or plan check). Drop it in wp-content/mu-plugins/ and it works as-is.

add_filter(
'buddynext_can_join_space',
static function ( bool $can, array $space, int $user_id, string $action ): bool {
// $action is 'join' or 'request'.
if ( ! $can ) {
return $can; // already blocked upstream
}
// Example: a space flagged "members-only" needs an active membership.
if ( ! empty( $space['my_addon_members_only'] ) && ! my_addon_has_active_membership( $user_id ) ) {
return false;
}
return $can;
},
10,
4
);

Returning false blocks both the join button and the request flow, so a gated space cannot be entered through either path.


Goal: add your own block to a sidebar, a row to the left navigation rail, or a tab to a space.

Seam: the template-part hooks. Every reusable part under templates/parts/ fires four hooks named after the part: buddynext_part_{name}_args (filter the args before render), buddynext_part_{name}_classes (filter the root class list), buddynext_part_{name}_before and buddynext_part_{name}_after (actions around the markup). Several surfaces also expose dedicated list filters.

Inject a row into the left navigation rail:

add_filter( 'buddynext_rail_items', static function ( array $items ): array {
$items[] = array(
'key' => 'leaderboard',
'label' => __( 'Leaderboard', 'my-addon' ),
'url' => home_url( '/leaderboard/' ),
'icon' => 'list', // a BuddyNext icon slug, NOT raw SVG
'show' => true, // must be truthy or the item is skipped
);
return $items;
} );

Append a tile to the shared stat-strip primitive (parts/stat-strip.php), the part that renders the profile and space stat rows. Each item needs at least a label and a value (optional icon, href, delta, trend, tone):

add_filter( 'buddynext_part_stat_strip_args', static function ( array $args ): array {
$args['stats'][] = array(
'label' => __( 'Points', 'my-addon' ),
'value' => '1,240',
);
return $args;
} );
// Or render arbitrary markup directly after a sidebar card:
add_action( 'buddynext_part_sidebar_card_after', static function ( array $args ): void {
// echo your already-escaped markup here
} );

Add a tab to a space’s nav bar through the unified Nav API. The old buddynext_space_tabs filter is retired - space tabs now flow through the Nav registry (the same system that owns profile tabs), so register on buddynext_register_nav with surface => 'space'. Space tabs are URL-only real links (/spaces/{slug}/{tab}/) and you server-render the panel for that route:

add_action( 'buddynext_register_nav', static function ( \BuddyNext\Nav\NavRegistry $registry ): void {
$registry->register(
array(
'id' => 'leaderboard',
'surface' => 'space',
'layer' => 'primary',
'label' => __( 'Leaderboard', 'my-addon' ),
'icon' => 'list',
'priority' => 45,
'url' => static function ( \BuddyNext\Nav\NavContext $c ): string {
return trailingslashit( \BuddyNext\Core\PageRouter::space_url( $c->subject_id ) ) . 'leaderboard/';
},
'condition' => static fn( \BuddyNext\Nav\NavContext $c ): bool => $c->role_at_least( 'member' ),
)
);
} );

The same action registers profile tabs (surface => 'profile') - see the Navigation API page for the full registration contract, the profile-vs-space tab difference, and how to reorder or remove existing items via the buddynext_nav_items filter.

Warning: Output rendered through *_before / *_after actions and through user-overlay HTML filters is echoed raw at the call site. Escape everything you emit. For the rail_items icon key, pass a BuddyNext icon slug; a raw <svg> string will not render.

For the full Nav registry contract see the Navigation API page. For the per-part hook tables and the user-overlay HTML filters (member-card meta, profile hero badges, avatar overlay, comment author meta, and more), see Template Part Hooks and Search, Hashtags, Sidebar and Admin Hooks.


Recipe 8 - Consume a Free service from an addon

Section titled “Recipe 8 - Consume a Free service from an addon”

Goal: read or write BuddyNext data through the same code paths the plugin uses, instead of querying bn_* tables directly.

Seam: the service container, via buddynext_service( '<key>' ). This is how Pro consumes Free: its AI ranked feed pulls follow relationships through the follows service and posts through post_service; its analytics collector reads posts through post_service. You get caching, counter integrity, and hook firing for free.

Common service keys:

Key Service Use it for
post_service PostService Fetch a post by ID, read author, increment/decrement counters
follows FollowService Follow relationships and follower/following lists
connections ConnectionService Connection (two-way) relationships
notifications NotificationService Create and read notifications (Recipe 5)
webhooks OutboundWebhookService Dispatch outbound events (Recipe 3)
safeguard SafeguardService Run the content-safety pipeline
spaces / space_members Space services Read space rows and membership
moderation ModerationService Read the report queue and moderation log
search SearchService Index content into bn_search_index
add_action( 'plugins_loaded', static function (): void {
if ( ! function_exists( 'buddynext_service' ) ) {
return; // BuddyNext not active - degrade gracefully.
}
add_action( 'my_addon_thing_happened', static function ( int $post_id ): void {
$post = buddynext_service( 'post_service' )->get( $post_id );
if ( ! $post ) {
return;
}
// ... use the hydrated post row instead of querying bn_posts yourself.
} );
}, 20 ); // priority 20: after BuddyNext boots at plugins_loaded:15.

Note: Always guard with function_exists( 'buddynext_service' ) so your addon does not fatal when BuddyNext is inactive. When buddynext_post_created hands you only an ID and a type, re-fetch the full row via buddynext_service( 'post_service' )->get( $post_id ) rather than assuming fields off the action arguments.


Recipe 9 - Register a member profile field from code

Section titled “Recipe 9 - Register a member profile field from code”

Goal: add an extended-profile field to every member from an addon, without an admin creating it in the field builder by hand.

Seam: buddynext_register_member_field( string $key, array $args ) - the member-side companion to buddynext_register_space_field(), with the same ( $key, $args ) shape. Call it on buddynext_loaded (or init).

add_action( 'buddynext_loaded', static function (): void {
buddynext_register_member_field( 'github_url', [
'label' => 'GitHub',
'type' => 'url', // a Free field type (see note below).
'group_key' => 'social_links', // attached to an existing group, or created if absent.
'visibility' => 'public',
] );
} );

The field then:

  • renders in the profile edit UI and on the profile, and
  • is returned by GET /users/{id}/profile through ProfileService.

Because a programmatic field has no bn_profile_fields row, its submitted value is stored to bn_field_{key} usermeta (here bn_field_github_url) on save, not to the bn_profile_values table. Read it back with get_user_meta( $user_id, 'bn_field_github_url', true ).

Note: type must be one of the Free field types (text, textarea, url, email, phone, number, date, boolean, select, radio, multiselect, category_multiselect, color). The “File upload” (file) type is Pro-only - it is registered by Pro on the buddynext_field_types filter and is not available in Free.


Recipe 10 - Register an add-on hub (1.0.4)

Section titled “Recipe 10 - Register an add-on hub (1.0.4)”

Goal: give your add-on its own community page - a real URL like /events/ that renders inside the BuddyNext shell, with a backing WP page, rewrite rules, and template resolution handled for you. (Surfacing an editable URL slug in the admin needs one extra filter today - see the end of the recipe.)

Seam: HubRegistry + the buddynext_register_hubs action.

add_action( 'buddynext_register_hubs', function ( \BuddyNext\Core\HubRegistry $reg ) {
$reg->register(
new \BuddyNext\Core\HubDescriptor(
'events', // hub key (bn_hub query var)
'myaddon_slug_events', // option holding the URL slug
'events', // default slug
'myaddon_page_events', // option holding the backing page id
__( 'Events', 'my-addon' ), // backing page title
'[myaddon_events]', // backing page content shortcode
null, // query_var (defaults to the key)
function () { // register_rules - NO arguments
$slug = trim( (string) get_option( 'myaddon_slug_events', 'events' ) );
if ( '' === $slug ) {
$slug = 'events';
}
add_rewrite_rule(
'^' . preg_quote( $slug, '/' ) . '/?$',
'index.php?bn_hub=events',
'top'
);
},
function ( string $hub ): ?string { // resolve_template - receives the hub key
return 'events' === $hub ? MY_ADDON_DIR . 'templates/events.php' : null;
}
)
);
} );

The two callbacks do not have the same signature. PageRouter::register_rewrites() invokes yours as ( $descriptor->register_rules )() - with no arguments - so read your slug from your own option inside the callback, as above. A callback that declares a required $slug parameter throws ArgumentCountError on every request under PHP 8, taking the whole site down rather than failing quietly. resolve_template is the opposite: it is passed the hub key, and must return null for any hub that is not yours, or you will hijack another hub’s template. includes/Core/CommunityAdminRoutes.php is the reference implementation of both.

Without this your hub’s browser tab reads Events at best, and Circle-studio at worst: PageRouter keeps a title map for its own hubs and falls back to ucfirst( $hub ) for everything else, which turns a slug into a near-miss of a name.

buddynext_document_title is the seam. It receives the title and a context, which for a hub render is the hub key — so match on your own key and leave every other surface alone:

add_filter( 'buddynext_document_title', function ( string $title, string $context ): string {
return 'events' === $context ? __( 'Events', 'my-addon' ) : $title;
}, 10, 2 );

Two things worth knowing before you rely on it:

  • The context is not always a hub key. BuddyNext also fires this filter from HeadMeta with the context head-meta, for surfaces that run no hub render at all (a single-post permalink, for one). Always match on the value you expect rather than assuming a hub.
  • It does not fight an SEO plugin. When Yoast, Rank Math or similar is active, BuddyNext leaves the document title alone entirely and your filter will not be applied. That is deliberate — the owner installed that plugin to own their titles — so set your title there instead on those sites.

One registration gives your hub a live route: PageRouter dispatches your register_rules and resolve_template on every request, a slug change flushes rewrites automatically (BuddyNext hooks update_option_{your_slug_option} for every registered hub), and - if your add-on is active when BuddyNext is activated - the Installer creates a backing WP page for it. includes/Core/CoreHubs.php registers the built-in hubs through the same HubRegistry.

Two limits are worth knowing today. Both are being closed as the hub-registry migration finishes; until then, plan around them:

  • The admin Pages & URLs screen does not list add-on hubs yet. NavManager::page_hub_catalogue() is a fixed list of the built-in hubs, so your hub’s URL slug is not editable there out of the box. Add it with the bn_admin_hub_pages filter, which receives that catalogue as hub key => { label, desc, slug_opt, page_opt, default }:

    add_filter( 'bn_admin_hub_pages', function ( array $hubs ): array {
    $hubs['events'] = array(
    'label' => __( 'Events', 'my-addon' ),
    'desc' => __( 'Your community events hub.', 'my-addon' ),
    'slug_opt' => 'myaddon_slug_events',
    'page_opt' => 'myaddon_page_events',
    'default' => 'events',
    );
    return $hubs;
    } );
  • Most built-in hubs do not yet go through register_rules / resolve_template. Their rewrites and template resolution still live directly in PageRouter; only community_admin rides this add-on seam so far. The callbacks you pass here are the supported path and are dispatched on every request - as the built-ins move onto the same seam, the two converge and a core hub and your hub run identical code.

Recipe 11 - Ship your own templates (1.0.4)

Section titled “Recipe 11 - Ship your own templates (1.0.4)”

Goal: let BuddyNext’s template loader find templates inside your add-on, with the child-theme override chain intact.

Seam: the buddynext_template_locations filter on TemplateLoader.

add_filter( 'buddynext_template_locations', function ( array $dirs ): array {
$dirs[] = MY_ADDON_DIR . 'templates/buddynext';
return $dirs;
} );

Lookup order: child theme, then parent theme, then BuddyNext’s own templates/, then each registered add-on directory. Add-on directories are searched last on purpose - they resolve templates Free does not ship (like Pro membership surfaces), while a site owner can still override any of them from their theme at buddynext/{path} - see Child Theme Template Overrides.

Recipe 12 - Add an owner-only Portfolio panel (Pro, 1.0.7)

Section titled “Recipe 12 - Add an owner-only Portfolio panel (Pro, 1.0.7)”

Goal: give an integration its own panel inside a member’s Portfolio tab that only the profile owner ever sees - a personal “in progress” shelf (Learnomy’s “Continue Learning”) rather than a public credential like a certificate or a listing.

Seam: buddynext_member_suite_panels (Pro). Every suite integration (Career Board, Listora, Learnomy, …) contributes its panels to the one shared Portfolio tab through this filter; SuiteProfile::panels() normalizes each entry and reads an owner_only flag off it. Set that flag and the panel is withheld from every viewer except the profile owner - enforced in three places so it can never leak: the Portfolio sub-nav (SuiteProfile::add_subnav(), via its visible_panels() gate), the section renderer (SuiteProfile::render_section()), and the REST endpoint (PortfolioController::get_portfolio(), which drops the panel outright for a non-owner viewer).

add_filter( 'buddynext_member_suite_panels', function ( array $panels, int $member_id ): array {
$courses = my_addon_get_in_progress_courses( $member_id );
if ( empty( $courses ) ) {
return $panels;
}
$panels[] = array(
'key' => 'continue-learning',
'label' => __( 'Continue Learning', 'my-addon' ),
'icon' => 'play', // a BuddyNext icon slug.
'priority' => 25,
'items' => $courses, // each item: title, url, image, highlight, ...
// Owner-only: rendered on the member's OWN profile alone, never a visitor's.
'owner_only' => true,
'owner_cta' => array(
'label' => __( 'Go to my courses', 'my-addon' ),
'url' => home_url( '/account/courses/' ),
),
);
return $panels;
}, 10, 2 );

Note: BuddyNextPro\Integrations\AbstractSuitePanelProvider is the shared base every bundled Pro integration extends. It wires this filter, the in-process REST fetch, and the panel-array assembly for you - its panel() helper takes the same owner_only opt. Extend it instead of hooking buddynext_member_suite_panels directly if you are shipping more than one panel.


Section titled “Recipe 13 - Keep a plugin (a cookie-consent banner, say) working on community pages (1.2.1)”

Goal: a plugin that must run on every page keeps working on BuddyNext’s community routes.

Seams: usually none - most cases need no code. For the exceptions: buddynext_isolation_plugins and buddynext_allowed_assets.

BuddyNext can isolate its community routes for speed: on those routes, plugin isolation loads only the plugins the owner has NOT ticked to skip, and asset isolation dequeues stylesheets and scripts that are not core, theme, or BuddyNext.

Most of the time you do nothing:

  • Consent, security, membership, backup and translation plugins are kept automatically and can never be stripped. They sit in the never-strip floor (PluginIsolation::essentials()), so an owner is not even offered a switch to strip them - stripping a firewall, a paywall or a cookie banner on the community’s front door is a compliance/access incident, not a performance trade-off. The canonical cookie-consent case (WPConsent, CookieYes, Complianz, Cookiebot, and the like) needs no code at all.
  • Every other active plugin is kept by default too. Isolation strips only the plugins the owner explicitly ticks at Admin > BuddyNext > Platform > Plugin isolation. To keep a plugin loaded, the owner simply does not tick it there.

You only reach for code in two cases, and neither needs a mu-plugin:

// 1) Force a plugin onto the KEEP list from an add-on (e.g. your integration
// depends on it), regardless of the owner's choices, and keep its CSS/JS on
// BuddyNext routes. buddynext_isolation_plugins feeds the keep-list BuddyNext
// computes and stores; the isolation mu-plugin reads that stored option, so a
// normal plugin can hook this - it does NOT have to be a mu-plugin.
add_filter(
'buddynext_isolation_plugins',
static function ( array $kept ): array {
$kept[] = 'my-addon-dependency/my-addon-dependency.php';
return $kept;
}
);
// 2) Keep a kept plugin's CSS/JS ENQUEUED on BuddyNext routes. Asset isolation
// dequeues anything that is not core/theme/BuddyNext even for a loaded plugin,
// so a plugin that renders its own front-end (a consent banner's script, a
// widget's styles) needs its URL prefix allowed through.
add_filter(
'buddynext_allowed_assets',
static function ( array $prefixes ): array {
$prefixes[] = plugins_url( '', 'my-addon-dependency/my-addon-dependency.php' );
return $prefixes;
}
);

Use these sparingly: every asset allowed through gives up part of the memory and payload saving isolation exists to provide.

One more seam, for a security control BuddyNext does not already recognise:

  • buddynext_isolation_security_plugins (string[]) - the list of security/access plugins that are always kept on community routes. Add a firewall, login limiter or access-control plugin BuddyNext has not seen, so it joins the never-strip floor rather than the general keep-list. Prefer this over buddynext_isolation_plugins when the plugin is a security control, so it is floored like the built-in ones.

There is no runtime filter for the master on/off switch. It is the owner setting under Platform > Plugin isolation and nothing else: the plugin-strip enforcer is the isolation mu-plugin, which runs before any plugin loads and so can never see a PHP filter. A former buddynext_isolation_enabled filter reached only the asset side, which desynced the two layers, so it was removed.


Recipe 14 - Ask the gate before offering an action (1.1.5)

Section titled “Recipe 14 - Ask the gate before offering an action (1.1.5)”

If your surface renders a Join button, ask whether the join would actually be allowed. Do not infer it from the space being open.

$spaces = buddynext_service( 'space_members' ); // SpaceMemberService
if ( $spaces->can_join( $space, get_current_user_id() ) ) {
// Safe to render the Join control.
}

can_join( array|object $space, int $user_id ): bool takes either a bn_spaces row array or the object templates carry, and 0 for a logged-out visitor.

This exists because Free cannot know what Pro will decide. Surfaces used to offer Join to anyone looking at an open space, including members a listener was certain to refuse - on a plan-gated space that produced a screen telling the member they needed a paid plan, with two buttons beside it inviting them to join anyway. Asking the gate is the only way to know the offer is real. The same reasoning applies to any control you render on someone else’s behalf.

Runnable, tested snippet: a copy-paste, live-verified version is in buddynext/buddynext-snippets at roles-caps/ask-the-gate.php. Drop it in wp-content/mu-plugins/ and it works as-is.

Recipe 15 - Hydrate a batch of posts in one query (1.1.5)

Section titled “Recipe 15 - Hydrate a batch of posts in one query (1.1.5)”

Looping get() over a list of post ids issues one query per row. At a page of 20 that is 20 round trips for data a single IN() answers.

$posts = buddynext_service( 'post_service' )->get_many( $post_ids ); // PostService

get_many( array $post_ids ): array returns hydrated posts in the order you asked for, not table order, because the caller’s order is usually meaningful - relevance, for one - and the database loses it. Ids with no row are skipped rather than returned as blanks, so do not assume the result is the same length as the input.

It is a fetch, not a gate. Visibility is deliberately not applied. Pass the ids through filter_visible() first, exactly as the feed does, or you will hand a member content they cannot see.

Runnable, tested snippet: a copy-paste, live-verified version (this batch fetch plus the filter_visible() gate that must precede it — Recipe 8’s “consume a service” pattern) is in buddynext/buddynext-snippets at services/consume-a-service.php. Drop it in wp-content/mu-plugins/ and it works as-is.


Recipe 16 - Register a custom profile field type from code

Section titled “Recipe 16 - Register a custom profile field type from code”

Recipe 9 added a field of an existing type. This one adds a brand-new field type - the way BuddyNext Pro adds its Location and advanced Number types.

Runnable, tested snippet: a copy-paste, live-verified version of this recipe is in buddynext/buddynext-snippets at profile-fields/register-custom-field-type.php. Drop it in wp-content/mu-plugins/ and it works as-is. A type is registered in one place (the engine) and given behaviour through a small set of filters; the admin field picker and every render/sanitize path then treat it like a built-in.

There are two registries, and a complete type touches both:

  • buddynext_field_types - the engine registry (Profile\FieldType::types()), the source of truth for a type’s metadata and the render/sanitize pipeline. Register here or your type degrades to a plain text input.
  • buddynext_profile_field_types - the admin field-type dropdown (Admin\Members\ProfileFieldsManager). Register here so an owner can actually pick your type when building a field. Omit it to ship a type that existing fields can use but owners cannot newly select (that is how Pro withholds a type while keeping old fields working).
add_action( 'plugins_loaded', function () {
// 1) Register the type with the engine. The descriptor's four required keys
// are label, value_kind ('scalar' | 'multi' | 'bool'), is_choice, and
// is_searchable_capable. sample_value is what lets the conformance suite
// prove your type renders its own storage - supply a representative one.
add_filter( 'buddynext_field_types', function ( array $types ): array {
$types['twitter_handle'] = array(
'label' => __( 'Twitter handle', 'my-addon' ),
'value_kind' => 'scalar',
'is_choice' => false,
'is_searchable_capable' => true,
'sample_value' => '@example',
);
return $types;
} );
// 2) Offer it in the admin field-type dropdown.
add_filter( 'buddynext_profile_field_types', function ( array $types ): array {
$types[] = 'twitter_handle';
return $types;
} );
add_filter( 'buddynext_profile_field_type_labels', function ( array $labels ): array {
$labels['twitter_handle'] = __( 'Twitter handle', 'my-addon' );
return $labels;
} );
// 3) Sanitize on save. Return the stored value; return '' to reject.
add_filter( 'buddynext_field_sanitize', function ( $handled, array $field, $raw ) {
if ( 'twitter_handle' !== ( $field['type'] ?? '' ) ) {
return $handled; // Not ours - pass through untouched.
}
return '@' . ltrim( sanitize_text_field( (string) $raw ), '@' );
}, 10, 3 );
// 4) Render the input on the profile edit form. Return the field HTML.
add_filter( 'buddynext_field_render_input', function ( $handled, array $field, $value, $name ) {
if ( 'twitter_handle' !== ( $field['type'] ?? '' ) ) {
return $handled;
}
return sprintf(
'<input type="text" name="%s" value="%s" placeholder="@handle" />',
esc_attr( $name ),
esc_attr( (string) $value )
);
}, 10, 4 );
// 5) Render the display value on the profile. Return the display HTML.
add_filter( 'buddynext_field_render_display', function ( $handled, array $field, $value ) {
if ( 'twitter_handle' !== ( $field['type'] ?? '' ) ) {
return $handled;
}
$handle = ltrim( (string) $value, '@' );
return sprintf(
'<a href="https://twitter.com/%s" rel="nofollow">@%1$s</a>',
esc_attr( $handle )
);
}, 10, 3 );
// 6) (Optional) Feed the search index a plain-text form of the value, since
// this type declared is_searchable_capable => true.
add_filter( 'buddynext_field_searchable_text', function ( $text, array $field, $value ) {
if ( 'twitter_handle' !== ( $field['type'] ?? '' ) ) {
return $text;
}
return ltrim( (string) $value, '@' );
}, 10, 3 );
} );

Each render/sanitize filter is passed null (or the running value) as its first argument and must return the untouched argument for types that are not yours - returning your own value unconditionally would hijack every other type. For a type that needs its own options box in the admin (a choice list, a format setting), also hook the buddynext_profile_field_type_options action, which fires inside the field editor for the selected type.

Reference implementation: BuddyNext Pro’s Profile\AdvancedFieldTypes (engine registration for Location, advanced Number/Multi-select, and an extended Date) and Admin\AdvancedFieldsAdmin (the dropdown labels and the per-type options box) are the complete, shipping example of this recipe.


  • Filters return, actions react. A filter that returns nothing erases the value. An action’s return value is ignored.
  • Hook timing. Register service-dependent code at plugins_loaded priority 20 or later, or on the buddynext_loaded action.
  • Never read constants directly. Resolve reaction types via ReactionService::reaction_types() and profile field types via ProfileFieldsManager::field_types() so your filter actually runs.
  • Escaping is yours. Any HTML you emit through a *_before / *_after action or a user-overlay HTML filter is echoed raw. Escape on output.
  • Free degrades cleanly. Several Free defaults (one webhook endpoint, a pin limit of 1) are the seams Pro raises. Your addon raises them the same way and should never assume Pro is present.

For the complete catalog of every hook referenced here, see the hooks reference pages: Feed and Content Hooks, Spaces Hooks, Notifications and Email Hooks, Moderation, Auth and Trust Hooks, Template Part Hooks, and Pro and Integration Hooks.

Count things across many spaces without an N+1

Section titled “Count things across many spaces without an N+1”

Goal: an owner dashboard showing several spaces at once — total members, join requests waiting, content reported — without one query per space, and without getting the member count wrong.

Seam: batched counters on the services that own each table.

$members = new \BuddyNext\Spaces\SpaceMemberService();
$mod = new \BuddyNext\Moderation\ModerationService();
$posts = buddynext_service( 'post_service' );
$space_ids = array( 12, 19, 44 ); // the spaces this owner runs
$people = $members->count_distinct_members( $space_ids );
$waiting = $members->count_pending_requests_for_spaces( $space_ids );
$reported = $mod->count_open_reports_for_spaces( $space_ids );
$drafts = $posts->count_draft_announcements( $space_ids );

Do not sum bn_spaces.member_count across spaces. It is a per-space denormalised column, so anyone who belongs to two of an owner’s spaces is counted twice — and the error grows with exactly the communities that are working, because active members join more spaces. On the development site, summing across ten spaces gave 54 where the real number of people was 14.

The two member counters deliberately answer differently, and the difference is not an inconsistency:

  • count_distinct_members() counts people, once each. Only status = 'active'; invited, pending and banned rows are not members.
  • count_pending_requests_for_spaces() counts rows. One person asking to join three spaces is three decisions an owner has to make, and collapsing them would under-report the work waiting.

count_open_reports_for_spaces() counts distinct reported objects, not report rows — five members reporting one post is one thing to look at, not five.

Each has a singular sibling (count_pending_requests( int $space_id ), count_open_reports_for_space( int $space_id )). The convention across both services: singular takes an int, _for_spaces takes an array. An empty array returns 0 rather than falling through to a site-wide count.