The subscription tier system manages user subscriptions with feature-based access control. Users get assigned to tiers by purchasing products, and each tier grants access to specific features and limits.
Navigate: Products → Subscription Tiers → Create New Tier
Configure the tier:
Tier Name: premium
Display Name: Premium Plan
Tier Level: 20
Description: Full access with up to 10 devices
Features:
- Maximum Devices: 10
- Custom Rules: Enabled
- Advanced Filters: EnabledFeatures are automatically discovered from JSON definition files:
Plugin Features: Each plugin defines its features in /plugins/{plugin}/tier_features.json
Example: /plugins/controld/tier_features.json
{
"max_devices": {
"type": "integer",
"label": "Maximum Devices",
"description": "Maximum number of devices allowed for this tier",
"default": 1,
"min": 0,
"max": 999
}
}When you edit a tier, the admin UI automatically shows all available features from all plugins. Simply set the values for each tier.
Note: Plugin features are automatically prefixed with the plugin name (e.g., max_devices becomes controld_max_devices).
Navigate: Products → Edit Product
Assign the tier:
Automatic Assignment (Purchase):
Navigate: Products → Subscription Tiers → View Members
See:
Six settings control subscription management behaviors (Products → Settings):
immediate or end_of_period)immediate or end_of_period)Users manage their subscriptions from a dedicated subscription management page:
View Current Tier:
subscription_downgrades_enabled = truesubscription_downgrade_timing settingsubscription_cancellation_enabled = truesubscription_reactivation_enabled = trueModels:
/data/subscription_tiers_class.php - SubscriptionTier and MultiSubscriptionTier/adm/admin_subscription_tiers.php - List tiers/adm/admin_subscription_tier_edit.php - Create/edit tier/adm/admin_subscription_tier_members.php - View members/views/change-subscription.php - Subscription management UI/logic/change_subscription_logic.php - Business logic/includes/core_tier_features.json - Core features/plugins/{plugin}/tier_features.json - Plugin featuresMain table: sbt_subscription_tiers
sbt_grp_group_idsbt_featurespro_sbt_subscription_tier_idCheck user's feature value:
// Get max devices for user (default to 1 if no tier)
$max_devices = SubscriptionTier::getUserFeature($user_id, 'controld_max_devices', 1);
// Check boolean feature
$has_premium = SubscriptionTier::getUserFeature($user_id, 'controld_advanced_filters', false);
// Use in logic
if ($device_count >= $max_devices) {
return "You've reached your device limit. Upgrade to add more.";
}Check minimum tier level:
// Require tier level 20 or higher
if (!SubscriptionTier::UserHasMinimumTier($user_id, 20)) {
header('Location: /upgrade-required');
exit;
}
// Or use helper that redirects automatically:
SubscriptionTier::requireMinimumTier($user_id, 20, '/change-subscription');Get user's tier:
$tier = SubscriptionTier::GetUserTier($user_id);
if ($tier) {
echo "Your plan: " . $tier->get('sbt_display_name');
echo "Tier level: " . $tier->get('sbt_tier_level');
}Get available upgrades:
$upgrades = SubscriptionTier::getUpgradeOptions($user_id);
foreach ($upgrades as $option) {
$tier = $option['tier'];
$products = $option['products'];
// Display upgrade cards
}Create /plugins/{yourplugin}/tier_features.json:
{
"your_feature_name": {
"type": "integer",
"label": "Feature Display Name",
"description": "Help text for admins",
"default": 1,
"min": 0,
"max": 100
},
"another_feature": {
"type": "boolean",
"label": "Enable Premium Feature",
"description": "Allow access to premium features",
"default": false
}
}Feature types:
integer - Numeric limits (shows number input with optional min/max)boolean - True/false flags (shows checkbox)string - Text values (shows text input)Adding a tier-gated feature is three steps. Skipping any of them silently breaks the feature for the wrong audience.
tier_features.json (or core settings.json-equivalent).$tier->getFeature('feature_key', $default) or SubscriptionTier::getUserFeature(...).sbt_subscription_tiers.sbt_features via the admin Subscription Tiers UI (/admin/admin_subscription_tier_edit?id=N).default in the schema is not a per-tier default — it's a fallback used when a tier row has no entry for the key. So if you ship a boolean feature with default: false and forget step 3, every paying tier silently gets false until an admin manually toggles it on. The feature looks broken from the user's side, but the only "bug" is missing per-tier values.For brand-new tiers, the same rule applies — when creating a tier, set every relevant feature explicitly rather than relying on schema defaults.
When querying for groups, use the correct option keys:
// ❌ WRONG - Uses column names directly
$groups = new MultiGroup([
'grp_name' => 'Basic Plan',
'grp_category' => 'subscription_tier'
]);
// ✅ CORRECT - Uses MultiGroup option keys
$groups = new MultiGroup([
'group_name' => 'Basic Plan',
'category' => 'subscription_tier'
]);MultiGroup option keys:
group_name → grp_namecategory → grp_categorygroup_id → grp_group_iduser_id → grp_usr_user_iddeleted → grp_delete_time (bool)/data/[table]_class.php to see which option keys each Multi class accepts.The system automatically:
grp_groups with grp_category = 'subscription_tier'change_tracking table"Tier already exists" error when creating new tier:
pro_sbt_subscription_tier_id setgetUserFeature()false) for paying customers:
sbt_subscription_tiers.sbt_features for that tier — the schema default is filling in./admin/admin_subscription_tier_edit?id=N for each affected tier and toggle the feature on. See "Rolling Out a New Feature to Existing Tiers" above.Tier gating restricts access to any entity based on the viewer's subscription tier. When a user lacks the required tier, they see a prompt to subscribe or upgrade.
Each gatable entity has a {prefix}_tier_min_level field. When set to a tier level value (e.g., 10, 20, 30), only users at that tier or higher can see the full content. Admins (permission 5+) always bypass the gate.
$field_specifications:'{prefix}_tier_min_level' => array('type'=>'int4', 'is_nullable'=>true),'{prefix}_tier_public_after_hours' => array('type'=>'int4', 'is_nullable'=>true),authenticate_tier() in the view:$session = SessionControl::get_instance();
$access = $entity->authenticate_tier($session);
if ($access['allowed']) {
// Render full content
} else {
require_once(PathHelper::getIncludePath('includes/tier_gate_prompt.php'));
render_tier_gate_prompt($access);
}authenticate_read() (files, videos), add inside that method:if ($this->get('{prefix}_tier_min_level')) {
$tier_access = $this->authenticate_tier($session);
if (!$tier_access['allowed']) return false;
}require_once(PathHelper::getIncludePath('data/subscription_tiers_class.php'));
$tier_options = ['' => 'No tier required'];
$all_tiers = MultiSubscriptionTier::GetAllActive();
foreach ($all_tiers as $tier) {
$tier_options[$tier->get('sbt_tier_level')] = $tier->get('sbt_display_name') . ' (Level ' . $tier->get('sbt_tier_level') . ')';
}
$formwriter->dropinput('{prefix}_tier_min_level', 'Minimum Tier Required', [
'options' => $tier_options
]);includes/tier_gate_prompt.php provides two functions:
render_tier_gate_prompt($access, $options) — Renders the paywall prompt. Pass ['preview_html' => $html] in options to show a preview before the gate.get_tier_gate_preview_html($body_text) — Returns truncated preview HTML based on the tier_gate_preview_length setting.Returns an array with:
allowed (bool) — Whether access is grantedreason (string|null) — 'not_logged_in' or 'tier_too_low'required_level (int|null) — The tier level neededuser_level (int|null) — The user's current tier levelrequired_tier (SubscriptionTier|null) — The required tier objectupgrade_options (array) — Available upgrade productsUse max_visible_tier_level in Multi class queries to filter by user's tier:
$user_tier_level = 0;
$tier = SubscriptionTier::GetUserTier($session->get_user_id());
if ($tier) $user_tier_level = $tier->get('sbt_tier_level');
$posts = new MultiPost(['published' => true, 'deleted' => false, 'max_visible_tier_level' => $user_tier_level]);Supported on: MultiPost, MultiEvent, MultiProduct.
Tier gating is additive. The precedence is: soft delete > published state > visibility > permission level > group membership > tier requirement. Tier is always the last check.
tier_gate_preview_length — Characters of body text to show before the paywall (0 = no preview)tier_gate_hide_from_listings — Hide gated content from listings for users who lack the tier (RSS feeds always hide gated items)Plugin developers can use tier gating in plugin views by calling authenticate_tier() on any entity that has the {prefix}_tier_min_level field. The gate prompt component renders correctly in any theme context.
/specs/subscription_tiers.md - Full specification and implementation detailsCLAUDE.md - System architecture and patterns