Building an Addon
BuddyNext overviewFree + ProGet BuddyNext →
How to wrap a third-party plugin (LearnDash, a store, a forum) so its content and events show up inside the community. This page is early access: the API is stable and versioned, and the first official addon, buddynext-learndash, is built only from what is documented here.
Overview / Contract
Section titled “Overview / Contract”| Fact | Detail |
|---|---|
| What an addon is | A small plugin named buddynext-<plugin>. It holds only partner-specific code. |
| What BuddyNext does for it | The Integrations entry, the version floor and its “Update needed” badge, the owner’s per-aspect switches, the wiring of each module, and a boundary that switches off a module that throws. |
| API version | BUDDYNEXT_ADDON_API (an integer, currently 1). Adding is free. Changing or removing a public symbol bumps it. BuddyNext supports the current and the previous value. |
| What is public | Every class and interface in BuddyNext\Addons that is marked @api, plus every hook marked @since. Anything else is internal. |
| Depends on | BuddyNext Free only. |
The shape of an addon
Section titled “The shape of an addon”use BuddyNext\Addons\AbstractAddon;use BuddyNext\Addons\AddonRegistry;
final class My_Addon extends AbstractAddon { public function slug(): string { return 'learndash'; } // integration key, stable forever public function label(): string { return 'LearnDash'; } public function partner_active(): bool { return defined( 'LEARNDASH_VERSION' ); } public function partner_version(): string { return defined( 'LEARNDASH_VERSION' ) ? LEARNDASH_VERSION : ''; } public function min_version(): string { return '4.10.0'; } public function tested_version(): string { return '4.21.0'; }
public function modules(): array { return array( new Cards(), new Notices(), new CourseSearch() ); }}
add_action( 'buddynext_register_addons', static function ( AddonRegistry $addons ): void { $addons->register( new My_Addon() );} );buddynext_register_addons fires at buddynext_load_bridges (priority 20 of plugins_loaded:25), after the first-party bridges, so load order between BuddyNext, the partner and your addon never matters. The slug is a-z, 0-9, _, starts with a letter, and is the key in the owner’s Integrations switches.
If your addon targets a newer API than the installed BuddyNext, or the partner is below min_version(), the owner gets one notice and nothing fatals. Below the floor the addon still appears in Integration Settings with an “Update needed” badge.
Modules
Section titled “Modules”An addon uses only the interfaces it needs. Labels may be translated: modules are wired on init (priority 0), never before.
| Interface | Owner switch | You supply | BuddyNext does |
|---|---|---|---|
FeedCardModule |
Post to the activity feed | card_types(): type, label, icon, optional renderer and Explore preview |
Allows the post type, registers label and icon for the feed and Explore, renders the uniform integration card |
NotificationSource |
- | hook carrying the payload, prefix, icon, types, a visibility answer | Registers the source and adopts the notification contract, so the bell, push and realtime pick it up |
SearchSource |
Include in search | types, result link, next ids to re-index, index one | Runs the re-index when the owner switches search on, removes the index when it is switched off |
SettingsModule |
- | Field objects with option keys that start with buddynext_<slug>_ |
Renders them inside your card on Integration Settings, saves each through its own sanitiser, lists them for search and defaults |
ProfilePanelModule |
Show in navigation | panels( $member_id ): key, label, icon, count, items, cta, owner_cta, owner_only |
Adds a sub-tab under the member’s Portfolio tab and serves the same data to the app at GET /buddynext/v1/members/{id}/portfolio |
SpaceLinkSource |
- | link_types(), link_label(), link_roster(), link_grants_access() |
Lists your links on the space’s Linked to tab, backfills the roster in batches, and answers who may be removed when access ends |
DiscoveryModule |
- | a banner for the space header, paths that must load in full | Shows the banner under the space header and adds your paths to the client-nav deny list |
Feed cards
Section titled “Feed cards”final class Cards implements FeedCardModule { public function card_types(): array { return array( 'course' => array( 'label' => __( 'Course', 'buddynext-learndash' ), 'icon' => 'graduation-cap' ) ); }}
// When a course is published, after checking the owner's switch:if ( $addon->feed_enabled() ) { \BuddyNext\Feed\IntegrationActivity::publish( $member_id, 'published a course', $url, $title, 'course' );}IntegrationActivity also has withdraw, restore, refresh and the *_by_meta forms, so a card follows its source. A type that is a built-in or already rendered by another bridge is refused.
Notifications
Section titled “Notifications”Fire your hook with the contract payload as its last argument. Declare each type’s label, description, default_on, email and audience. Rows are bell only unless a type sets email.
Settings
Section titled “Settings”Do not add an admin tab. Declare fields and they appear in your card. Prefer a filter to a setting where an opinionated default will do.
Profile panels
Section titled “Profile panels”A panel is an array with key, label, icon, count, priority, items, and optional cta and owner_cta (the latter shown only to the profile owner). Each item needs a title; everything else is optional:
array( 'key' => 'jobs', 'label' => __( 'Jobs', 'my-addon' ), 'items' => array( array( 'title' => 'Field Researcher', 'url' => 'https://example.test/jobs/1', 'image' => 'https://example.test/logo.png', 'meta' => 'Acme Research', 'facts' => array( 'Remote', 'Part-time' ), 'highlight' => '$40-50/h', ), ), 'cta' => array( 'label' => __( 'See all jobs', 'my-addon' ), 'url' => 'https://example.test/jobs' ), 'owner_cta' => array( 'label' => __( 'Manage jobs', 'my-addon' ), 'url' => 'https://example.test/dashboard' ),)Panels are cached for “the member viewing their own profile” and “anyone else”, not per viewer. A panel that is not owner_only must return the same data to every viewer; mark anything that depends on who is looking owner_only, and it is dropped for everyone else. When the member’s data changes, call do_action( 'buddynext_suite_panels_changed', $member_id ).
Space links
Section titled “Space links”A space can be linked to an object in your plugin (a course, a group, a store). While it is linked, everyone the object grants access to is added to the space; the space’s Linked to tab lists every link from every addon and lets the space owner remove one.
Declare a SpaceLinkSource module with four methods:
link_types(): string[]- the object types you link (for examplecourse).link_label( string $type, int $object_id ): array{name:string,url:string}- the name and URL shown in the Linked to tab; empty strings when the object is gone.link_roster( string $type, int $object_id, int $after, int $limit ): int[]- one page of user ids that have access, ascending, after the last id seen; used to backfill a new link.link_grants_access( string $type, int $object_id, int $user_id ): bool- whether one member still has access.
Then call BuddyNext\Spaces\SpaceLinkService from your own screens and events:
link( $space_id, $slug, $type, $object_id )links and backfills the current roster. An object feeds at most one space.create_and_link( $slug, $type, $object_id, $owner_id )creates a private space named after the object and links it. Such a space is managed.join_from_source( $space_id, $user_id )adds a member who gained access. It passes the space’s join gate for that one join, honours bans and archives, and stays quiet: no owner notification and no webhook.leave_from_source( $space_id, $user_id, $slug, $type, $object_id )handles lost access. It removes the member only from a managed space, never the owner, and only if none of the space’s other links still grants access. A hand-linked space never loses a member.unlink( ... )andrelease_object( $slug, $type, $object_id )(the object was deleted) keep the space and its members.
Removing an addon or the partner plugin changes nothing: the links stay, shown as “Plugin not active”, and nobody is removed until the addon is back.
| Hook | When |
|---|---|
buddynext_register_addons (action, AddonRegistry $registry) |
Register your addon here. Fires once, at buddynext_load_bridges. |
buddynext_member_suite_panels (filter, array $panels, int $member_id) |
Collects the Portfolio panels for a member. Prefer ProfilePanelModule; use the filter only from a plugin that is not an addon. |
buddynext_suite_panels_changed (action, int $member_id) |
Call it when a member’s panel data changes so the cached panels are rebuilt. |
buddynext_suite_panels_ttl (filter, int $seconds) |
How long panels are cached. Default 5 minutes. |
buddynext_space_link_sources (filter, array<string,SpaceLinkSource> $sources) |
Offers objects a space can be linked to. A SpaceLinkSource module is added for you. |
buddynext_space_linked (action, int $space_id, string $source, string $type, int $object_id) |
A space was linked to an object. |
buddynext_space_unlinked (action, int $space_id, string $source, string $type, int $object_id) |
A link was removed. The space and its members are untouched. |
buddynext_space_link_backfill_chunk_size (filter, int $size, string $source, string $type, int $object_id) |
Members added per backfill run. Default 500, at least 1. |
buddynext_addon_module_failed (action, string $slug, string $module, Throwable $error) |
A module threw and has been switched off for the rest of the request. The error is also written to the PHP error log. |
What the app sees
Section titled “What the app sees”GET /app/config lists your addon under integrations.<slug> with profile_panels: true when it supplies a ProfilePanelModule, and your typed feed cards under card_types. Both keys are additive; an older server omits them.
Keeping it stable
Section titled “Keeping it stable”The public API is exactly these, and tests/Addons/AddonApiContractTest.php fails any change to their signatures:
| Class | What is public |
|---|---|
BuddyNext\Addons\* interfaces and AbstractAddon |
Every public method |
BuddyNext\Integrations\IntegrationRegistry |
Every public method |
BuddyNext\Spaces\SpaceLinkService |
Every public method |
BuddyNext\Feed\IntegrationActivity |
Only the methods tagged @api: publish, remove, withdraw, restore, refresh, rewrite and their _by_meta forms, set_space_by_meta, card_types, render_bridge_card |
SuitePanelHost is not public: add panels through ProfilePanelModule or the buddynext_member_suite_panels filter.
- Use only
@apiclasses and@sincehooks. A symbol that is not marked may change in any release. - A breaking change bumps
BUDDYNEXT_ADDON_API. The old shape keeps working for two minor releases, with_deprecated_function()naming the replacement. - Deactivating the partner or your addon deletes nothing. The owner can switch an addon’s surfaces off from Integration Settings, and data is exactly as it was when it comes back.
- Do not copy BuddyNext code into your addon. If you need something that is not here, it belongs in BuddyNext: ask for it.
Testing
Section titled “Testing”BuddyNext ships a fixture addon (dev/addon-fixture) that implements every module and the tests that drive it. Copy its tests as the pattern for yours: register your addon, call wire(), and assert the filters it should have added. A throwing module must leave the site working.

