Count Paying Subscribers Correctly
The benecaster_tokens table stores three distinct populations: paying subscribers, free-tier bridge members, and direct followers. A query that counts all active tokens overcounts — it includes both non-paying groups. Getting a correct paying subscriber count requires excluding both, and they are excluded by different conditions.
The Two Exclusions
token_type = 'subscriber' — excludes direct followers
Followers sign up via the [benecaster_follower_signup] shortcode and have no bridge membership. Their rows carry token_type = 'follower'. Free-tier bridge members also carry no paid membership, but they came in through a bridge and still carry token_type = 'subscriber' — so token_type alone does not identify them as non-paying.
tm.is_free_tier = 0 — excludes free-tier bridge members
Bridge members on a $0-mapped tier (for example, a “Free” level in MemberPress mapped to Benecaster) have is_free_tier = 1 in benecaster_tier_map. They carry token_type = 'subscriber', so a token_type check alone does not exclude them.
Applying only one filter silently miscounts:
- Checking only
token_type = 'subscriber'— includes free-tier bridge members - Checking only
is_free_tier = 0— includes followers (who have nobenecaster_tier_maprow; anINNER JOINwould drop them but a carelessLEFT JOINwith no null-check would not)
Use both conditions together.
The Query
global $wpdb;
$tokens = $wpdb->prefix . 'benecaster_tokens';
$tier_map = $wpdb->prefix . 'benecaster_tier_map';
// Paying subscribers for a single show.
$count = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM {$tokens} t
INNER JOIN {$tier_map} tm
ON tm.show_id = t.show_id AND tm.internal_tier_slug = t.tier_slug
WHERE t.show_id = %d AND t.status = 'active'
AND t.token_type = 'subscriber' AND tm.is_free_tier = 0",
$show_id
) );
// Site-wide paying subscriber count — used by the daily license validation payload.
$site_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$tokens} t
INNER JOIN {$tier_map} tm
ON tm.show_id = t.show_id AND tm.internal_tier_slug = t.tier_slug
WHERE t.status = 'active'
AND t.token_type = 'subscriber' AND tm.is_free_tier = 0"
);
The INNER JOIN on benecaster_tier_map naturally excludes followers — they have no tier map row. Combined with token_type = 'subscriber', both non-paying groups are excluded in a single pass.
Prefer the Repository Methods
Inside the plugin or an add-on that has access to the DI container, use the built-in repository methods rather than hand-rolling SQL:
$repository = $container->make( \Benecaster\Token\TokenRepository::class );
$show_count = $repository->count_paying_by_show( $show_id );
$site_count = $repository->count_paying_site_wide();
Hand-rolled SQL is appropriate for outside-the-container code: WP-CLI commands in sibling plugins, ad-hoc audit queries in a SQL client, or scripts running outside the WordPress request cycle.
Related Counts
TokenRepository::count_active_site_wide() includes followers and free-tier members — it drives the total_token_count field on the license validation payload. Use this for any total-token-cap UI. Tier upgrade thresholds (Launch, Starter, Growth) read count_paying_*; total token caps read count_active_site_wide. The split is intentional.
Related
- How Feed Tokens Work — token lifecycle and the subscriber model
- Free Follower Tier — what followers are and how they differ from subscribers
- For Developers — full hook, filter, and class reference