Darstellung
ADR 0004 — Feature gating
Status: accepted Date: 2026-08-21
Context
The plugin needs roughly 93 licensable features across four plans. The naive approach — checking the plan at each call site — fails in several ways at once: moving a feature between plans becomes a code change, monthly and yearly variants drift apart, and every new call site is a chance to forget the check.
The audited codebase had no feature flags at all, so there was no existing pattern to inherit and no legacy to be compatible with.
Decision
Entitlement keys, one authority, asked identically from every layer.
Forbidden everywhere:
php
if ( 'business' === $plan ) {}
if ( $is_pro ) {}
if ( AD_FORM_PRO ) {}The only form:
php
$entitlements->allows( 'payments.stripe' );FeatureRegistrypins 93 features, each withkey,name,category,dependencies,marketing_planand acoreflag.EntitlementManagerresolves once per request: the Free baseline from the registry, plus the signed entitlements expanded through their dependencies, but only whileLicenseStategrants paid entitlements.marketing_plandrives badges and upgrade copy. It never decides access.- Compound requirements use
allows_all(), which returns the missing keys. Stripe needs bothpaymentsandpayments.stripe. - An entitlement the server grants but this build does not know is honoured, so a newer platform can sell a feature before every site updates.
- Field, action and module requirements are declared on the type itself, so licensing logic exists once per feature rather than once per layer.
required_entitlements() lives on AbstractField and AbstractAction, not on FieldInterface and ActionInterface. Adding a method to those interfaces would break any add-on already implementing them; a defaulted method on the abstract cannot, and the registries read it through an instanceof check.
Enforcement happens at every layer that can reach a feature: builder UI, builder config, save validation, REST, module registration, action execution and runtime policy. UI alone is never sufficient.
Consequences
Good:
- The dashboard can move a feature between plans with no plugin release.
- Monthly and yearly are identical by construction; the billing interval is never consulted.
- A new field type declares one method and is gated in the registry, the scanner, the save gate, the palette and the REST catalogue at once.
- Enforcement is testable as a matrix. 525 tests cover it.
Costs:
- 93 keys to maintain, and a new feature must remember to register one. Mitigated by
docs/FEATURE_ENFORCEMENT_AUDIT.md, where a missing gate shows up as a gap. - The registry duplicates plan knowledge that also exists on the platform. Only for presentation; a disagreement affects a badge, not access.
No escape hatch
There is no ad_form_entitlements filter, no writable registry and no license-state filter. FeatureRegistry::register() refuses any key that is already a core key, so an add-on cannot re-register payments.stripe as Free. ModuleGate likewise refuses to let a core module id be redefined.
Every licensing hook is an action and none can change an outcome.
Rejected
Plan comparison at the call site. Forbidden by the product requirement, and it makes plan composition a code change.
A $is_pro boolean. Cannot express three paid tiers or per-integration plans.
A constant such as AD_FORM_PRO. Definable by the site owner in wp-config.php, which is a one-line bypass.
Entitlements on the interfaces. Breaks existing add-ons for no gain.
Filtering the REST type catalogue. The catalogue is marketing information; hiding it would only stop customers discovering what they could buy, while the save gate refuses the type regardless.