Skip to main content

Integrate with a membership plugin not in the built-in bridge set

Premium Advanced

Benecaster ships bridges for MemberPress, WooCommerce Subscriptions, Paid Memberships Pro and Restrict Content Pro. A bridge for any other membership plugin is written by implementing BridgeInterface and registering it through the benecaster_bridges filter.

A registered slug is accepted by benecaster_set_active_bridge() like any built-in one.

You register a class name, not an instance, and Benecaster resolves it through its own container. That is why the example below can take TierMapRepository as a constructor dependency and simply expect it to arrive. Do not build the object yourself in the filter.

A bridge is read-only by design. It reports what the membership plugin already believes: who holds which tier, and whether they are active. It never writes back. That boundary is what keeps Benecaster from corrupting another plugin’s data model, so resist the temptation to have your bridge create or modify memberships.

The contract has two halves. The get_* methods answer questions about current state. The on_* methods accept a callback that your bridge invokes when the membership plugin fires its own events — that is how Benecaster learns to issue, change or revoke a token without polling.

Notes

Tier slugs are resolved through TierMapRepository, never invented. Your plugin’s own level id is mapped to a Benecaster tier by the podcaster in the mapping UI; find_by_external( $plugin_slug, $external_id ) is how you turn one into the other. A bridge that returns its own level slug directly will match no tier on any show.

The activation callback takes four arguments, and show_id is the second. $callback( int $user_id, int $show_id, string $tier_slug, string $reason ) — the show comes off the mapped tier, not from anything your plugin knows. $reason is 'new' or 'resubscribe'.

The cancellation callback takes an optional third argument, $reason (added 2026-09-18), defaulting to 'cancelled'. Report 'expired' when your plugin can tell a lapse — a fixed term ran out, renewals gave up — from a deliberate cancellation. A bridge that never reports an expiry leaves a lapsed member’s token active: benecaster_subscription_expired never fires for that member, and they keep counting as a paying subscriber.

get_all_tiers() rows are keyed id, not slugid is your plugin’s external tier id, which is what the mapping UI stores. price is optional and may be null when the plugin cannot resolve one.

get_all_user_tiers() is required and is not the same as get_user_tier(). A subscriber holding two memberships that map to the same show receives the union of both tiers’ episode sets, and FeedController reads this method to work that out. Returning only the first tier silently under-serves those subscribers.

on_tier_saved() and on_subscription_changed() are commonly no-ops — implement them as empty methods when your plugin has no matching event rather than leaving them undefined, or the class will not satisfy the interface.

A class string that does not resolve fails silently, and it fails at the worst moment. benecaster_bridges discards an entry with no usable class string, but an entry naming a class that simply does not exist registers successfully: the slug is accepted, the picker offers it, benecaster_set_active_bridge() returns true — and every subscriber lookup then resolves to NullBridge and answers “not a subscriber”. A typo in the class name looks exactly like a podcaster with no paying members. Use ::class rather than a quoted string, as below, so the mistake is a PHP error instead of a support ticket.

available gates the picker, not the setter. Returning false hides the bridge from Settings → Subscription; it does not stop benecaster_set_active_bridge() accepting the slug. If your own setup flow calls the setter, check that your plugin is really there first — the setter will not do it for you.

Code

<?php
class MyPlugin_Bridge implements \Benecaster\Bridge\BridgeInterface {

    public function __construct(
        private readonly \Benecaster\Show\TierMapRepository $tier_map
    ) {}

    public function get_plugin_slug(): string {
        return 'my-plugin';
    }

    public function get_user_tier( int $user_id, int $show_id ): ?string {
        foreach ( my_plugin_get_active_levels( $user_id ) as $level_id ) {
            $tier = $this->tier_map->find_by_external( 'my-plugin', (string) $level_id );
            if ( $tier && (int) $tier->show_id === $show_id ) {
                return $tier->internal_tier_slug;
            }
        }
        return null;
    }

    // Required, and NOT the same as get_user_tier(): a subscriber with two
    // memberships mapped to one show gets the union of both tiers' episodes.
    public function get_all_user_tiers( int $user_id, int $show_id ): array {
        $slugs = [];
        foreach ( my_plugin_get_active_levels( $user_id ) as $level_id ) {
            $tier = $this->tier_map->find_by_external( 'my-plugin', (string) $level_id );
            if ( $tier && (int) $tier->show_id === $show_id ) {
                $slugs[] = $tier->internal_tier_slug;
            }
        }
        return array_values( array_unique( $slugs ) );
    }

    public function is_user_active( int $user_id, int $show_id ): bool {
        return null !== $this->get_user_tier( $user_id, $show_id );
    }

    // Rows are keyed by YOUR plugin's external tier id - that is what the
    // mapping UI stores. 'price' may be null when it cannot be resolved.
    public function get_all_tiers( int $show_id ): array {
        return array_map( fn( $level ) => [
            'id'    => $level->id,
            'name'  => $level->name,
            'price' => $level->price ?? null,
        ], my_plugin_get_all_levels() );
    }

    public function on_subscription_activated( callable $callback ): void {
        add_action( 'my_plugin_member_activated', function ( $user_id, $level_id ) use ( $callback ): void {
            $tier = $this->tier_map->find_by_external( 'my-plugin', (string) $level_id );
            if ( $tier ) {
                // user_id, show_id, tier_slug, reason - four arguments.
                $callback( (int) $user_id, (int) $tier->show_id, $tier->internal_tier_slug, 'new' );
            }
        }, 10, 2 );
    }

    public function on_subscription_cancelled( callable $callback ): void {
        add_action( 'my_plugin_member_cancelled', function ( $user_id, $level_id ) use ( $callback ): void {
            $tier = $this->tier_map->find_by_external( 'my-plugin', (string) $level_id );
            if ( $tier ) {
                // user_id, show_id, reason - three arguments ($reason added 2026-09-18).
                // 'my-plugin' has no separate lapse event here, so every cancellation
                // reports 'cancelled'. A plugin that CAN tell a lapse (a fixed term ran
                // out, renewals gave up) from a deliberate cancellation must pass
                // 'expired' for the lapse instead, or benecaster_subscription_expired
                // never fires for its members and their tokens stay active.
                $callback( (int) $user_id, (int) $tier->show_id, 'cancelled' );
            }
        }, 10, 2 );
    }

    // No-ops are the correct implementation when your plugin has no
    // matching event - but they must exist to satisfy the interface.
    public function on_subscription_changed( callable $callback ): void {}
    public function on_subscription_renewed( callable $callback ): void {}
    public function on_payment_failed( callable $callback ): void {}
    public function on_tier_saved( callable $callback ): void {}
}

// Register the bridge. A class name, not an instance - Benecaster resolves
// it through the container, so constructor dependencies are injected for you.
add_filter( 'benecaster_bridges', function ( array $bridges ): array {
    $bridges['my-plugin'] = [
        'name'      => __( 'My Membership Plugin', 'my-addon' ),
        'class'     => MyPlugin_Bridge::class,
        'available' => fn (): bool => function_exists( 'my_plugin_get_active_levels' ),
    ];

    return $bridges;
} );

// The podcaster picks the bridge in Settings -> Subscription. An add-on with
// its own setup wizard can connect it directly instead - always check the
// return value, because a refusal is returned rather than thrown.
// benecaster_set_active_bridge( $show_id, 'my-plugin' );

View on GitHub →

Hooks Used

Need this built rather than just documented? See our services →