Hooks: Spaces
The action and filter seams for spaces (groups) and their membership: creation, update, deletion, ownership, joins, requests, invitations, bans, role changes, and per-member notification preferences. This page is for developers building moderation tools, notification bridges, gated-access or paywall integrations, and theme extensions for space pages. Every hook below is fired or applied by BuddyNext Free. The two seams that matter most for extension are buddynext_can_join_space (the Free-to-Pro access gate) and buddynext_space_types (registering new space kinds).

Overview / Contract
Section titled “Overview / Contract”- Actions fire after the write commits. Membership and lifecycle actions pass IDs, not hydrated rows. Re-fetch via
buddynext_service( 'spaces' )->get( $space_id )when you need more than the IDs. The container key isspaces, notspace_service. buddynext_can_join_spaceis the access gate. It runs before any database work in both the direct-join and request-membership paths. Returnfalseto block; BuddyNext then short-circuits with aWP_Errorbuilt by the denial path, andbuddynext_space_join_denied_datalets you attach a payload (for example a Pro paywall) to that error.- Removal vs ban are distinct events. A ban also removes the membership, so a ban fires both
buddynext_space_member_removed(so removal listeners such as cache busting always react) andbuddynext_space_user_banned(so ban-specific listeners react). Listen to whichever matches your intent. - Idempotent membership writes. Joins, requests, and invites use
INSERT IGNORE; their actions fire only when the membership state actually changes. Unban fires only when an active ban row was deleted. - Space types are config maps, not classes.
buddynext_space_typesfilters a slug-keyed array. Behaviour (visibility and join flow) is derived from each entry’svisibilityfield; the three built-in types cannot be removed. - Visibility has ONE decision point.
BuddyNext\Spaces\SpaceVisibilityanswers “can this viewer see this space / its roster / its content?” for every surface — the server-rendered template AND the REST route.buddynext_space_can_view_rosteris applied inside it, so a singleadd_filter()changes the members page andGET /spaces/{id}/memberstogether; the page and the app cannot disagree.
Space visibility
Section titled “Space visibility”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_space_can_view_roster |
filter | A surface resolves whether a viewer may see a space’s member roster | bool $can_view, int $space_id, int $viewer_id, string $type |
buddynext_can_view_space_content |
filter | A viewer’s access to a space’s content is resolved, before it is rendered or cached. Return false to withhold the space’s posts while leaving the space itself visible. Fired from SpaceVisibility and again in FeedService when building a space feed, so an add-on that gates content only has to answer once. Default true. |
bool $can_view, int $space_id, int $viewer_id |
buddynext_space_files_tab_for_guests |
filter | The space nav decides whether to show the Files tab to a logged-out visitor. Default false: WPMediaVerse refuses anonymous document reads, so on a public space the tab could only ever render its empty state. Return true if your MediaVerse serves anonymous reads. |
bool $show, int $space_id |
buddynext_space_default_tab |
filter | Which tab a space opens on when the URL names none (/spaces/{slug}/). Runs for the resolved default only - a non-member of a private space gets about, then the space’s own “Space opens on” setting, then the first inline tab in the site’s Navigation order - never for an explicit /spaces/{slug}/{tab}/. Return a tab id; a value the viewer cannot see falls back to the first renderable tab, so a bad return can never blank the space. Example: open course spaces on About - return 'about';. |
string $tab, array $space, int $viewer_id |
Default: true for open spaces; false for private and secret spaces unless the viewer is an active member, a moderator, the space owner, or a site admin. A private space is listed but gated — its name, description, house rules, avatar, cover, category, member COUNT, and its owner + moderator list stay public (a stranger needs them to decide whether to request to join), while the full member roster does not.
Return true to re-open private rosters Facebook-style. The filter is applied at the single decision point, so this one call re-opens both the members page and the REST roster route:
// Anyone may browse a private space's member list (Facebook-style).add_filter( 'buddynext_space_can_view_roster', '__return_true' );
// Or selectively: open private rosters to logged-in members of the community,// but never a secret space's, and never to a logged-out visitor.add_filter( 'buddynext_space_can_view_roster', function ( bool $can_view, int $space_id, int $viewer_id, string $type ): bool { if ( $can_view || 'private' !== $type ) { return $can_view; } return $viewer_id > 0;}, 10, 4 );Space lifecycle
Section titled “Space lifecycle”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_space_created |
action | A new space is created | int $space_id, int $owner_id |
buddynext_reserved_space_slugs |
filter | A space slug is generated or validated. These slugs are refused because they collide with BuddyNext’s own space sub-routes (members, files, about, …); a space claiming one would shadow its own tab. Add your own to reserve them. |
string[] $slugs |
buddynext_space_updated |
action | A space’s fields are edited | int $space_id, int $user_id, array $fields (columns written this update). See the arity warning below - one call site passes only $space_id. |
buddynext_space_archived |
action | A space is archived | int $space_id, int $actor_id |
buddynext_space_unarchived |
action | A space is unarchived | int $space_id, int $actor_id |
buddynext_space_ownership_transferred |
action | A space’s ownership moves to a new owner | int $space_id, int $new_owner_id, int $actor_id, int $previous_owner_id |
buddynext_space_deleted |
action | A space is deleted | int $space_id, int $user_id |
buddynext_space_archived and buddynext_space_unarchived are dispatched from a single call site that selects the hook name by state, so a listener only fires on the transition it registered for.
buddynext_space_updatedfires with the full three arguments from every call site.This was not always true.
SpaceFieldRegistry::save()used to fire$space_idalone, so a typed three-parameter listener - registered exactly as documented - took anArgumentCountErroron that one path. It now passes$space_id, get_current_user_id(), $savedlike the twoSpaceServicecall sites, and the source carries a comment saying the arity is part of the contract and must not vary by call site. Earlier versions of this page told you to default the second and third parameters as a workaround; that is no longer necessary.
Membership: join, request, invite
Section titled “Membership: join, request, invite”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_can_join_space |
filter | Before a direct join or a membership request, gating access | bool $can, array $space, int $user_id, string $action ($action is 'join' or 'request') |
buddynext_space_member_joined |
action | A user becomes an active member (direct join or approved request) | int $space_id, int $user_id, string $role ('member') |
buddynext_space_join_requested |
action | A user requests to join a private space | int $space_id, int $user_id |
buddynext_space_member_invited |
action | A user is invited to a space | int $invited_user_id, int $space_id, int $inviter_id |
buddynext_space_join_approved |
action | A pending join request is approved | int $space_id, int $user_id, int $actor_id |
buddynext_space_join_declined |
action | A pending join request is declined | int $space_id, int $user_id, int $actor_id |
buddynext_space_join_request_cancelled |
action | A member cancels their own pending request | int $space_id, int $user_id |
buddynext_space_join_denied_data |
filter | A gated join/request is denied, to build the error payload | array $data, int $space_id, int $user_id, array $space, string $action |
Note: When a request is approved, both
buddynext_space_join_approvedandbuddynext_space_member_joinedfire (in that order). The first is the moderation event; the second is the “this user is now an active member” event, identical to the one fired on a direct join.
Membership: leave, remove, ban, roles, preferences
Section titled “Membership: leave, remove, ban, roles, preferences”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_space_member_left |
action | A user leaves a space voluntarily | int $space_id, int $user_id |
buddynext_space_member_removed |
action | A member is removed by a moderator (also fires when a member is banned) | int $space_id, int $user_id, int $actor_id |
buddynext_space_role_changed |
action | A member’s role is promoted or demoted | int $space_id, int $target_id, string $new_role, int $actor_id |
buddynext_space_user_banned |
action | A user is banned from a space | int $space_id, int $user_id, int $actor_id |
buddynext_space_user_unbanned |
action | A space ban is lifted | int $space_id, int $user_id |
buddynext_space_notification_pref_updated |
action | A member changes their per-space notification preference | int $space_id, int $user_id, string $pref ('all', 'mentions_only', 'none') |
Warning: A ban removes the membership, so it fires
buddynext_space_member_removedandbuddynext_space_user_bannedtogether. If you maintain a banned-users list, listen tobuddynext_space_user_bannedspecifically; if you only need to react to “this user is no longer in the space” (for example, busting a sidebar cache), listen tobuddynext_space_member_removedand you will cover both removals and bans.
Space types
Section titled “Space types”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_space_types |
filter | The registered space-type map is resolved | array $types (slug-keyed config map) |
buddynext_register_space_fields |
action | The per-space field registry is built. Call $registry->register() to add your own space fields |
SpaceFieldRegistry $registry |
buddynext_space_max_per_member |
filter | The ceiling on how many spaces one member may own is resolved. Defaults to the site-wide setting; 0 means unlimited |
int $max_per_member, int $owner_id |
buddynext_space_posts_changed |
action | A space’s post set changes - a post created in it, or removed from it. Carries the space id, which buddynext_post_created does not |
int $space_id |
Each space-type entry has this shape. Visibility drives the behaviour: public allows direct joins, private requires a request, secret is invite-only.
'open' => [ 'label' => __( 'Open', 'buddynext' ), // UI label 'tone' => 'success', // badge tone slug 'visibility' => 'public', // 'public' | 'private' | 'secret' 'join' => 'direct', // 'direct' | 'request' | 'invite'],The built-in types are open (public/direct), private (private/request), and secret (secret/invite). They cannot be removed by the filter, only added to.
Examples
Section titled “Examples”Gate a space behind a membership plan
Section titled “Gate a space behind a membership plan”buddynext_can_join_space is the seam Pro uses for paywalls and gated plans. Return false to block; pair it with buddynext_space_join_denied_data to surface a reason or paywall payload in the REST error response. The gate runs before any database work, so a denied user never creates a row.
// Block the join/request unless the user holds the required entitlement.add_filter( 'buddynext_can_join_space', function ( bool $can, array $space, int $user_id, string $action ): bool { if ( ! $can ) { return false; // Someone already denied it. } $required_tier = (int) get_post_meta( (int) ( $space['id'] ?? 0 ), '_required_tier', true ); if ( $required_tier > 0 && ! my_membership_user_has_tier( $user_id, $required_tier ) ) { return false; } return $can;}, 10, 4 );
// Attach a paywall payload to the denial so the client can render a CTA.add_filter( 'buddynext_space_join_denied_data', function ( array $data, int $space_id, int $user_id, array $space, string $action ): array { $data['paywall'] = [ 'message' => __( 'This space is for premium members.', 'my-addon' ), 'cta_url' => home_url( '/upgrade/' ), ]; return $data;}, 10, 5 );Note:
buddynext_can_join_spacefires for both the direct-join path ($action === 'join') and the request-membership path ($action === 'request'). Branch on$actionif your rules differ between the two.
Register a custom space type
Section titled “Register a custom space type”add_filter( 'buddynext_space_types', function ( array $types ): array { $types['announce_only'] = [ 'label' => __( 'Announcements', 'my-addon' ), 'tone' => 'info', 'visibility' => 'public', // anyone can join 'join' => 'direct', ]; return $types;} );React to a new member in a space
Section titled “React to a new member in a space”add_action( 'buddynext_space_member_joined', function ( int $space_id, int $user_id, string $role ): void { my_addon_send_welcome_dm( $user_id, $space_id );}, 10, 3 );Featured spaces
Section titled “Featured spaces”Owner-curated spaces shown first in the directory sidebar, the phone strip, and onboarding. Two filters tune them; both are applied by SpaceService::featured_spaces().
// Raise or lower how many spaces an owner may feature (default 6, clamped 1–12).add_filter( 'buddynext_featured_spaces_limit', fn () => 10 );
// Adjust the final featured list PER SURFACE. Runs AFTER visibility filtering and// its result is visibility-checked again, so you can reorder/trim/add but can// never surface a space the viewer must not see. $surface is one of// 'sidebar' | 'directory_mobile' | 'onboarding' | 'suggestions'.add_filter( 'buddynext_featured_spaces', function ( array $spaces, int $viewer_id, string $surface ): array { if ( 'onboarding' === $surface ) { // e.g. cap onboarding to the top 3. return array_slice( $spaces, 0, 3 ); } return $spaces;}, 10, 3 );- The directory sidebar “Featured” card is registered via
buddynext_sidebar_widgetswith idspaces-featured(priority 10) — remove or reorder it there. - Featured spaces the member has not joined are boosted in feed/explore suggestions via the existing
buddynext_space_suggestionsfilter (behind the member’s strongest personal matches). - REST:
GET/POST /spaces/...— see16-rest-spaces.md(/settings/featured-spaces).
Space admin page
Section titled “Space admin page”The space admin page (/spaces/{slug}/admin/) renders an “at a glance” stats row (Members, Pending requests, Open reports) and, right after it, an action for add-ons to append their own stat tiles for the people who manage the space.
// Append a stat tile after the space-admin at-a-glance row.// Fires only on a page the viewer can already manage (owner, moderators, admins).add_action( 'buddynext_space_admin_after_stats', function ( int $space_id, int $viewer_id ): void { // Reuse the shared tile markup so the surface stays consistent: echo '<div class="bn-space-admin__stats" role="list">'; echo ' <div class="bn-card bn-space-admin__stat" role="listitem">'; echo ' <span class="bn-space-admin__stat-value">' . esc_html( my_metric( $space_id ) ) . '</span>'; echo ' <span class="bn-space-admin__stat-label">' . esc_html__( 'My metric', 'my-plugin' ) . '</span>'; echo ' </div>'; echo '</div>';}, 10, 2 );BuddyNext Pro uses this seam to render its “Last 30 days” analytics row (new members, left, net growth, posts) for space owners; with Pro inactive the page is unchanged. $viewer_id has already passed the manage-space capability gate, so the hook never fires for a member.
Notes / gotchas
Section titled “Notes / gotchas”- Free vs Pro. Every hook here is fired by Free.
buddynext_can_join_spaceplusbuddynext_space_join_denied_dataare the documented gated-spaces / paywall seam that Pro builds on;buddynext_space_typesis the extension point for new space kinds. - The gate runs first. Because
buddynext_can_join_spaceshort-circuits before any insert, you cannot rely on a*_member_joinedaction to undo a join you wanted to block. Block it at the gate. - Ban fires two actions. Choose
buddynext_space_user_bannedfor ban-specific behaviour andbuddynext_space_member_removedfor “no longer a member” behaviour. They fire together on a ban. - Re-fetch space data. Lifecycle actions pass IDs only. Hydrate via
buddynext_service( 'spaces' )->get( $space_id )rather than reading$spacefrom a stale closure.

