Admin Pages and Settings
Developer Guide Free & ProThe BuddyNext admin surface and the contracts that shape it: the registered wp-admin pages (a single Hub menu with one registered page per populated section in free, 19 legacy pages in Pro), the AdminHub section + tab-placement system that arranges every screen into a capped information architecture, the bn_admin_hub_sections and bn_admin_hub_tab_placement filters for adding or relocating tabs from a mu-plugin, and the options-wiring model (per-tab settings groups derived from field descriptors). This page is for developers adding an admin screen, moving an existing tab, or wiring a new setting.


Overview / Contract#
All BuddyNext admin pages gate on the native manage_options capability. There is no BuddyNext-specific admin role; site administrators reach the screens, and every other user is denied. AdminHub::render_section() re-checks current_user_can( 'manage_options' ) on render and wp_die()s otherwise, and each tab can additionally declare its own cap (default manage_options).
The admin is built on three layers:
- Sections - the top-level wp-admin sub-menu entries (
?page=slugs). Declared inAdminHub::default_sections(), filterable viabn_admin_hub_sections. - Tabs - the individual screens, contributed by feature classes through
AdminHub::register_tab(). Each tab declares an originsection:slug. - Placement - a canonical map (
AdminHub::TAB_PLACEMENT) that moves each tab to its final section and sidebar position, filterable viabn_admin_hub_tab_placement. This lets a feature keep registering against its own domain while the hub arranges the final layout in one place.
A section appears in the sidebar only when at least one visible tab is registered into it. Empty sections are hidden.
Registered admin pages#
Free#
AdminHub::build_menu() (hooked on admin_menu priority 9) registers a single top-level menu (add_menu_page, slug buddynext) and then one page per populated section - all on manage_options. Individual feature classes do not register their own pages; they contribute tabs (see the section / tab API below) and the Hub builds the menu. The section slugs come from AdminHub::default_sections():
| Page slug | Title | Section key |
|---|---|---|
buddynext |
Get Started (also the top-level menu) | get-started (top) |
buddynext-members |
Members | members |
buddynext-spaces |
Spaces | spaces |
buddynext-engagement |
Engagement | engagement |
buddynext-notifications |
Notifications | notifications |
buddynext-moderation |
Moderation | moderation |
buddynext-monetization |
Monetization | monetization (Pro tabs) |
buddynext-campaigns |
Campaigns | campaigns (Pro tabs) |
buddynext-realtime |
Realtime & Push | realtime (Pro tabs) |
buddynext-platform |
Platform | platform |
buddynext-integration-settings |
Integration Settings | integration-settings |
buddynext-settings |
Settings | settings |
buddynext-upgrade |
Upgrade | upgrade (free-only "Free vs Pro" tab) |
default_sections() declares these 13 sections, and only sections with at least one registered tab are registered. The three that register no tabs in free (Monetization, Campaigns, Realtime & Push) stay hidden until Pro is active.
Only four sections keep a link in the wp-admin sidebar by default: get-started, members, moderation and settings. Every other section is registered as an unlinked page (null parent), so admin.php?page=buddynext-spaces and the other slugs still load and reach the same tabs through the Hub's own rail. Which sections keep a sidebar link is filterable with buddynext_admin_menu_sections (receives the list of section keys). While Pro is active a direct "License" link is also added, and an "Add-ons" quick link points at the Add-ons tab.
Other registered pages: buddynext-setup is the hidden setup wizard, and buddynext-automod is a retired slug that redirects to the Moderation section. Integrations is not its own section - it is a tab (origin settings:integrations, labelled "Add-ons") placed into the Platform section by the placement map.
Pro (19 pages)#
Pro registers 19 admin pages, each a submenu under the buddynext parent, all on manage_options. Their sidebar entries come from the AdminHub placement map; the registered page slugs are kept so legacy/bookmarked URLs still resolve, and they render inside the Hub chrome:
| Page slug | Title |
|---|---|
buddynextpro-analytics |
Analytics |
buddynextpro-broadcasts |
Broadcast Campaigns |
buddynextpro-drip-sequences |
Drip Sequences |
buddynextpro-member-labels |
Member Labels |
bnpro-membership-tiers |
Membership Plans |
bnpro-subscriptions |
Subscriptions |
bnpro-paywall-settings |
Paywall Settings |
bnpro-coupons |
Coupons |
buddynextpro-mod-rules |
Moderation Rules |
buddynextpro-bulk-mod |
Bulk Moderation |
buddynextpro-push |
Push |
buddynextpro-push-prefs |
Push Preferences |
buddynextpro-realtime |
Realtime |
buddynextpro-scheduled-posts |
Scheduled Posts |
buddynextpro-ai-feed |
AI Feed |
buddynextpro-ai-moderation |
AI Moderation |
buddynextpro-payments |
Payments |
buddynextpro-whitelabel |
White-label |
buddynextpro-custom-reactions |
Custom Reactions |
Pro tabs register against their domain origin section (for example monetization:tiers, growth:broadcasts, moderation:rules) and are routed into the matching hidden-until-active sections by the placement map. The White-label tab is placed as a visible Settings tab (origin settings:white-label).
The section / tab API#
Sections#
AdminHub::default_sections() is keyed by a short section key, each entry carrying its ?page= slug, label, and Lucide icon. One section is marked top (get-started) - its slug is shared with the top-level menu, so clicking "BuddyNext" lands on it.
// AdminHub::sections() = default_sections() merged with the bn_admin_hub_sections filter.
'get-started' => array( 'slug' => 'buddynext', 'label' => 'Get Started', 'top' => true ),
'members' => array( 'slug' => 'buddynext-members', 'label' => 'Members' ),
// ...
Tabs#
Feature classes contribute a tab from their register() method (or any code that runs before admin_menu priority 9):
AdminHub::register_tab(
string $section, // origin section key, e.g. 'settings'
string $slug, // tab slug -> ?tab= value, e.g. 'general'
string $label, // visible, already-translated label
callable $render, // body render callback
array $args = [] // cap, position, badge, icon, group, layout, subtitle, action
);
Recognised $args keys include cap (capability, default manage_options), position (lower sorts earlier), badge (a fn(): int that renders a counter pill when > 0), icon (a Lucide slug, auto-mapped from the tab slug when omitted), layout (sidebar default, or wide for list-detail editors), and subtitle / action for the standardized sub-header bar.
Placement#
AdminHub::TAB_PLACEMENT is the single source of truth for where each tab lands. It is keyed by the tab's origin section:slug and sets the final section, the sidebar position, and optional hidden. When register_tab() runs it applies the matching rule: a hidden rule drops the tab entirely, a section rule relocates it, a position rule reorders it.
This is why a feature can register growth:broadcasts while the tab actually renders under the Campaigns section - the placement map performs the move, and AdminHub::tab_url( 'growth', 'broadcasts' ) resolves to the Campaigns page slug.
The options-wiring model#
BuddyNext wires a large set of option keys, the great majority prefixed buddynext_ (a few legacy bn_* avatar keys and the core admin_email also appear). Each is read at its consumption point and written from its admin tab. Examples: buddynext_site_name, buddynext_default_post_privacy, buddynext_space_creation_role, buddynext_banned_words, buddynext_enabled_reactions.
Settings are registered in Admin/Settings.php::register_settings(), which calls SettingsDriver::register_page( $this, 'buddynext' ). SettingsDriver walks the page's field descriptors (Settings::settings_fields()) and issues one register_setting() per field under a per-tab group named buddynext_{tab}, so a save only touches the active tab's options - and SettingsDriver::save_group_of( $key, 'buddynext' ) answers which group saves a given key. This descriptor-driven approach replaces the old hand-maintained SETTINGS_MAP + TAB_OPTIONS lists. The tab groups derived from the descriptors are:
buddynext_general buddynext_registration buddynext_social
buddynext_spaces buddynext_moderation buddynext_notifications
buddynext_email buddynext_privacy buddynext_webhooks
buddynext_features
Three options carry custom array sanitizers and are registered with explicit standalone register_setting() calls (in addition to flowing through their tab group):
| Option key | Type | Group | Sanitizer |
|---|---|---|---|
buddynext_features |
array | buddynext_features |
sanitize_features_option |
buddynext_social_login |
array | buddynext_registration |
sanitize_social_login_option |
buddynext_enabled_reactions |
array | buddynext_social |
sanitize_enabled_reactions |
An option whose key matches no descriptor field falls back to the
buddynextgroup. Add new settings asFielddescriptors in the relevantSettings::fields_*()method so they are registered under the right group and sanitize on save.
Two Settings tabs do not use SettingsDriver at all#
includes/Admin/NavManager.php registers two tabs in the Settings section (Navigation uses the Advanced group and layout => 'wide'; Pages & URLs sits at position 35) that save through admin_post handlers rather than the Settings API. Their options therefore appear in no tab group above, and the per-tab save-scope rule does not apply to them.
| Tab | Save action | What it writes |
|---|---|---|
| Navigation | admin_post_bn_save_nav -> NavManager::handle_save_nav() |
The five nav-override options below. |
| Pages & URLs | admin_post_bn_save_hub_pages -> NavManager::handle_save_hub_pages() |
The buddynext_page_* slug/page assignments. |
The Navigation tab edits five scopes, one option each (NavManager::SCOPE_OPTION_MAP, mirrored by Nav\NavOverrides::SCOPE_OPTION):
| Scope | Option | The surface it controls |
|---|---|---|
main |
buddynext_nav_overrides |
The left rail. |
profile |
buddynext_nav_overrides_profile |
Member-profile tabs. |
space |
buddynext_nav_overrides_space |
Space tabs. |
mobile |
buddynext_nav_overrides_mobile |
The mobile bottom bar. |
account |
buddynext_nav_overrides_account |
The header avatar dropdown. |
Each scope stores the owner's hide / relabel / reorder / capability-gate choices plus any custom links, and Nav\NavOverrides applies them on that surface's own filter at priority 20. See the Navigation API page for the full model and for how a developer-registered item interacts with these overrides.
The page assignments are kept out of the nav-override options on purpose: handle_save_nav() only writes display overrides, so PageRouter and other services can read a hub's slug without knowing anything about the nav system.
Defaults and "Restore defaults"#
Every owner setting declares ONE default, in the Field descriptor, and that single source drives three things: the register_setting() default, the value every get_option() reads back, and what a tab's "Restore defaults" resets to. A gate (bin/check-option-defaults.php, run from bin/check.sh in both repos) fails the build if a driver-registered setting has no declared default and no resettable => false reason - so a new setting cannot drift in.
Declare a default#
new Field( array(
'key' => 'myplugin_widget_limit',
'type' => 'number',
'label' => __( 'Widgets per page', 'my-plugin' ),
'default' => 20,
) );
For a default that is genuinely dynamic (it follows another value), declare a default_callback instead of default - it is resolved once when the option is registered, so get_option() still inherits one value:
'default_callback' => static fn() => get_option( 'posts_per_page', 10 ),
Read the option with NO inline fallback where the Settings API is active (admin), or pass the SAME declared default (a const or helper) everywhere else - never a re-typed literal. The gate flags a get_option() whose fallback drifts from the canonical one.
Mark owner data as never-reset#
"Restore defaults" is for CONFIGURATION. Owner DATA - a site name, banned-word list, sender identity, API keys, page mappings, uploaded images - is never reset and needs no default. Mark it:
new Field( array(
'key' => 'myplugin_api_key',
'type' => 'secret',
'label' => __( 'API key', 'my-plugin' ),
'resettable' => false,
) );
A resettable => false field satisfies the gate without a default, never appears in the Restore dialog, and is left exactly as the owner set it.
What an add-on gets for free#
Implement BuddyNext\Contracts\ProvidesSettings and call SettingsRegistry::register( $this ) in your page's register(). Your fields then flow through SettingsDriver exactly like the first-party ones: register_setting() with your declared defaults, ⌘K search, and - for any tab that has resettable fields - the shared Restore defaults button, its confirm dialog listing exactly what would change, and the reset itself. React to a reset with:
add_action( 'buddynext_settings_tab_reset', function ( $tab, $keys, $user_id ) {
// Clear a derived cache after the owner restores your tab, etc.
}, 10, 3 );
Add your tab with AdminHub::register_tab( '<section>', '<slug>', … ); register a whole dedicated section for your add-on with the bn_admin_hub_sections filter (see "Add a new top-level section" below) if you do not want to sit inside an existing one.
Examples#
Relocate or hide a tab from a mu-plugin#
bn_admin_hub_tab_placement overrides the canonical placement map. Keys are the tab's origin section:slug.
add_filter( 'bn_admin_hub_tab_placement', function ( array $map ) {
// Hide a tab entirely.
$map['settings:webhooks']['hidden'] = true;
// Move the Social tab from Engagement to Notifications.
$map['settings:social']['section'] = 'notifications';
// Reorder a tab within its section (lower sorts earlier).
$map['settings:reactions']['position'] = 5;
return $map;
} );
Add a new top-level section#
bn_admin_hub_sections adds or renames a section. It appears in the sidebar only once a tab registers into it.
add_filter( 'bn_admin_hub_sections', function ( array $sections ) {
$sections['marketplace'] = array(
'slug' => 'buddynext-marketplace',
'label' => __( 'Marketplace', 'my-ext' ),
);
return $sections;
} );
Register a tab into a section#
add_action( 'init', function () {
\BuddyNext\Admin\AdminHub::register_tab(
'marketplace', // origin section key
'listings', // tab slug
__( 'Listings', 'my-ext' ),
'my_ext_render_listings_tab',
array(
'cap' => 'manage_options',
'position' => 10,
'icon' => 'grid',
'subtitle' => __( 'Manage marketplace listings.', 'my-ext' ),
)
);
} );
Build the link to it with AdminHub::tab_url( 'marketplace', 'listings' ) rather than hand-assembling ?page=...&tab=..., so a future placement move never breaks the URL.
Admin CSS primitives#
Admin styles live in one stylesheet, assets/css/bn-admin.css, enqueued by AssetService::enqueue_admin_assets() on BuddyNext admin screens. Addon and Pro screens consume these primitives rather than shipping their own - a second copy is how two screens drift apart.
| Primitive | Class | Purpose |
|---|---|---|
| Button | .bn-btn |
The admin button, on <button> and <a> alike. Sets box-sizing: border-box itself: WP's admin reset gives <button> border-box but leaves <a> at content-box, so without it an <a class="bn-btn"> came out 2px taller than the <button class="bn-btn"> beside it. Any Actions cell mixing a link and a form button showed it. |
| Badge | .bn-badge |
Status pill. |
| Row actions | .bn-row-actions |
The action cluster in a list table's Actions column. |
| Row action form | .bn-row-actions__form |
Wrap a nonce-carrying <form> inside .bn-row-actions. |
.bn-row-actions exists because an Actions cell is a mix of <form>-wrapped buttons (they need the nonce plus hidden inputs) and bare <a>s. Dropped loose into the <td> they are separate inline boxes, so as soon as the column narrows they stack on their own lines flush against one another with no vertical gap. .bn-row-actions is a wrapping flex row that gives them one consistent gap in both axes; .bn-row-actions__form makes the carrier <form> an inline-flex with no margin so the gap applies to the button rather than to the wrapper.
<td>
<div class="bn-row-actions">
<a class="bn-btn" data-variant="secondary" data-size="sm" href="<?php echo esc_url( $edit_url ); ?>">
<?php esc_html_e( 'Edit', 'my-addon' ); ?>
</a>
<form class="bn-row-actions__form" method="post">
<?php wp_nonce_field( 'my_addon_delete' ); ?>
<input type="hidden" name="id" value="<?php echo (int) $row_id; ?>" />
<button class="bn-btn" data-variant="danger" data-size="sm" type="submit">
<?php esc_html_e( 'Delete', 'my-addon' ); ?>
</button>
</form>
</div>
</td>
Core consumers to copy from: includes/Admin/Spaces.php, includes/Admin/Members.php, includes/Admin/Members/MemberTypesManager.php.
Notes / gotchas#
manage_optionseverywhere. Every page registrar and the Hub renderer gate onmanage_options. A tab's owncapcan tighten this further but the section page itself always requiresmanage_options.- Register tabs before
admin_menupriority 9.AdminHub::build_menu()runs at that priority; tabs registered later will not appear in their section's sub-menu. - Empty sections are hidden, not removed. A section with no registered tab is skipped during menu build. Pro-only sections (Campaigns, Realtime & Push, Monetization) stay hidden in free for this reason.
- Origin section vs final section. Always register against your tab's domain origin and let the placement map decide the final location. Resolve URLs and active-state through
AdminHub::tab_url()/is_tab_active(), which apply the same placement, so a relocated tab keeps its assets and links. - Per-tab save scope. Because options are grouped per tab, saving one tab never overwrites another tab's options. Add new settings as
Fielddescriptors in the relevantSettings::fields_*()method;SettingsDriverregisters them underbuddynext_{tab}and runs their sanitizer on save.
Provider secrets are encrypted at rest (Pro)#
Provider credentials - the FCM service-account JSON, the Soketi secret, the Stripe and PayPal secret keys, the Expo and FCM tokens, the AI embedding key - are encrypted before they are written to the options table. A plain database export (or a plugin that dumps options) no longer reveals them.
How it works, and its honest limit:
- Encryption is transparent at the option layer: an
option_{key}filter decrypts on read and apre_update_option_{key}filter encrypts on write, so everyget_option()/update_option()caller keeps working unchanged. The stored value is prefixedbnpsec:v1:and uses AES-256-GCM. - The key is derived from your site's wp-config salts - so it lives outside the database. That defeats the realistic threat, a leaked DB backup, but not a full server compromise where
wp-config.phpis readable too. This is defense-in-depth, not a vault. - Salt rotation. Because the default key comes from the salts, regenerating them makes the stored secrets undecryptable. The plugin fails closed - a decrypt miss returns empty, so the feature reads as "needs setup" rather than breaking with a corrupt credential - and you re-enter the key. To make secrets survive salt rotation, define a stable
BUDDYNEXT_SECRETS_KEYinwp-config.php; it is used as the encryption key instead of the salts. - Admin fields for secrets render blank with a "Saved" badge, never re-emitting the stored value into the page HTML. Leaving a field blank keeps the saved secret; entering a new value replaces it. To clear a secret entirely, use the feature's own remove/disconnect control.
Existing plaintext secrets are migrated to the encrypted form once, automatically, on upgrade.
See also Roles and Capabilities for the manage_options-vs-community-role distinction these screens rely on.