Hooks: Spaces
Free & ProThe 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
- 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
| 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 |
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
| 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
| 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
| 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
| 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
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
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
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 );
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.