The entitlement model: how plugins gate themselves on license and version

The concept, and why it matters

MAM Suite ships as a family of plugins — mam-main plus a dozen siblings like mam-contact-form, mam-geodirectory, mam-special-offers, and the rest. They install as ordinary WordPress plugins, but they are licensed products: a given customer site may have paid for some of them and not others, and a subscription can lapse, enter a grace period, or be revoked by support.

That raises a design question every sibling plugin has to answer at boot: should I run on this site at all?

The answer is not “is the plugin file present” — WordPress already activated it, so the file is present. The answer is “is this customer entitled to this plugin right now, and is the platform underneath me new enough to support me.” MAM Suite handles this with a small, deliberate contract:

  • A single filter, mam_plugin_entitlement, answers the license question for one plugin slug.
  • A separate filter, mam_check_required_version, answers the platform-version question.
  • The license answer is read from a locally cached option that a background sync refreshes — synchronous code never blocks on the network to find out whether it’s allowed to run.

The result is a system where licensing is enforced centrally (from the WPMAM licensing server) but evaluated cheaply and resiliently on each customer site, and where a network outage at the licensing server does not take a paying customer’s app offline.

This article explains how that works and why each piece is shaped the way it is. It is grounded in mam-main/includes/plugin-update-manager/entitlement-manager.php, the mam_remote_sync scheduling in includes/remote-sync/, and the version checker in plugin-update-manager.php.


The entitlement filter

The heart of the model is one filter that mam-main registers on boot:

$entitlement = new mam_entitlement_manager();
add_filter( 'mam_plugin_entitlement', array( $entitlement, 'mam_plugin_entitlement' ), 10, 2 );

A sibling plugin asks the question by applying the filter with its own slug:

$entitlement = apply_filters( 'mam_plugin_entitlement', null, 'mam-contact-form' );

What comes back is not a boolean — it is a small state record. The handler always returns an array shaped like this:

Key Meaning
state One of entitled, blocked, grace, expired
reason A machine-readable reason code (e.g. purchased, not_purchased, cache_miss, source_server, grace, subscription_expired)
plugin_slug The slug that was asked about
expires_at Present for grace and expired states — a Unix timestamp

A sibling plugin keys its decision off state, not off the presence of individual flags. Roughly:

  • entitled — run normally.
  • grace — run normally, but the customer is in a post-expiry grace window; this is a good place to start surfacing a renewal nudge.
  • expired — the subscription has lapsed past grace; stop bootstrapping functional code, show an admin notice.
  • blocked — the plugin was never purchased (or was revoked); stay dormant. mam-main itself renders the “purchase required” admin notice for these, so a sibling can simply return.

The record is keyed off a single state string, not off separate boolean flags — a sibling tests 'blocked' === $entitlement['state'], not ! empty( $entitlement['blocked'] ).

How the handler decides the state

The logic in mam_plugin_entitlement() is worth reading directly, because the ordering of its checks encodes the product’s licensing philosophy:

  1. Source-server bypass. If the current host is wpmobileappmanager.com (or a subdomain), every plugin is entitled with reason source_server. The licensing server is itself a MAM site and must never gate itself off.
  2. Cache lookup. Otherwise the handler reads the per-plugin record out of the cached option (see below).
  3. Cold cache → fail open. If the cache is missing or doesn’t yet contain this slug, the handler returns entitled with reason cache_miss. A site that just installed a plugin, or whose cache hasn’t synced yet, is treated as entitled rather than locked out.
  4. Explicit non-purchase → blocked. If the server explicitly returned false or an empty record for the slug, the state is blocked / not_purchased.
  5. Status flags. A returned record carrying status = grace_active becomes grace (with grace_untilexpires_at); status = expired becomes expired. Anything else with a record is entitled / purchased.

The headline design choice here is fail-open on uncertainty. A cold cache, an unknown slug, or a sync that hasn’t run yet all resolve to “entitled.” The system only withholds a plugin when the licensing server has affirmatively said the customer doesn’t have it. This is the right bias for a paid platform whose customers have shipped real apps to real app stores: an ambiguous licensing read should never be what darkens a published app.


Why the answer comes from a cache, not a live call

mam_plugin_entitlement is read synchronously, potentially many times per request, during the boot of every MAM plugin on the site. If each read made an HTTP call to the licensing server, every page load would stall on remote latency, and a slow or unreachable server would slow or break the whole site.

So the read path never touches the network. It reads one WordPress option, mam_plugin_entitlements (the CACHE_OPTION constant), which holds a map of plugin_slug → server record. A separate background process keeps that option fresh:

Background scheduler (FastCron, wp-cron fallback)
        │  fires mam_sync_entitlements (hourly)
        ▼
mam_entitlement_manager::sync_now()
        │  collects installed mam-* slugs from get_plugins()
        │  reads the site's account code
        ▼
GET wpmobileappmanager.com/.../admin-ajax.php?action=mam_plugin_entitlements
        │  &account_code=…&plugins=[…]   (5s timeout)
        ▼
update_option( 'mam_plugin_entitlements', $serverMap )

A few properties of this loop matter:

  • The sync is the only thing that talks to the server. MAM_Remote_Sync_Manager schedules mam_sync_entitlements (hourly), alongside the plugin-update and codex syncs. It prefers FastCron and falls back to wp-cron, with an interval guard so the two schedulers don’t double-fire.
  • Failures leave the cache intact. If the HTTP call errors or returns invalid JSON, sync_now() logs via mamdebug and returns without touching the option. The previous, valid map keeps serving reads. Combined with the cold-cache fail-open, this means a licensing-server outage produces stale-but-valid entitlement reads, never a wave of lockouts.
  • The cache is keyed to the account code. The sync POSTs the site’s account code (from MAM_Account_Code_Manager::get()) and stores whatever map comes back. The entitlement map is therefore implicitly per-customer-site. Switching account codes on a live site — which should essentially never happen — would require flushing this cache.

This is the same architectural pattern MAM Suite uses elsewhere for anything that depends on a remote authority: do the network work asynchronously on a schedule, persist the result locally, and make the hot path a pure local read. (The class docblock states the rule outright: synchronous handlers never make HTTP calls.)


The version check is a separate question

Entitlement answers “is the customer licensed.” It does not answer “is the platform underneath me new enough.” A sibling that needs a hook or data shape introduced in a particular mam-main release has to check the version separately, via mam_check_required_version:

$installed = apply_filters( 'mam_check_required_version', '1.9.1', 'MAM Contact Form' );

Be careful with this one — its real signature in plugin-update-manager.php is not a clean boolean gate:

  • The arguments are ( $requires, $plugin ) — the minimum version string and a display name for the caller.
  • It returns the installed mam-main version string, not true/false.
  • Its side effect is the actual enforcement: if the installed version is below $requires, it logs an admin_alert (which surfaces in the admin notice area); if the version is fine, it clears any matching prior alert.
  • Outside the admin (! is_admin()) it short-circuits and returns a baseline version string without doing the comparison, since the alerting only makes sense in wp-admin.

So mam_check_required_version is best understood as “record and surface a version-mismatch warning, and tell me the installed version,” rather than a hard stop. A caller that wants a true gate should compare the returned version itself with version_compare() and decide whether to bootstrap.

There is also a related, coarser check, mam_required_plugins / check_required_plugins(), which alerts admins when a plugin a sibling depends on is installed but not active. That’s about presence and activation, not version or license.


How a sibling plugin puts it together

A well-behaved sibling resolves all three questions — present-and-active, new-enough, entitled — once, at boot, and caches the decision in PHP memory for the rest of the request. The shape is:

add_action( 'plugins_loaded', 'mam_my_plugin_bootstrap', 11 );

function mam_my_plugin_bootstrap() {

    // 1) Platform version (compare the returned version yourself).
    $installed = apply_filters( 'mam_check_required_version', '1.9.1', 'MAM My Plugin' );
    if ( version_compare( $installed, '1.9.1', '<' ) ) {
        return; // version-mismatch alert already logged by the filter
    }

    // 2) Entitlement.
    $entitlement = apply_filters( 'mam_plugin_entitlement', null, 'mam-my-plugin' );

    switch ( $entitlement['state'] ) {
        case 'blocked':
            return; // mam-main shows the purchase notice; stay dormant
        case 'expired':
            add_action( 'admin_notices', 'mam_my_plugin_expired_notice' );
            return;
        case 'entitled':
        case 'grace':
        default:
            break; // OK to run
    }

    // 3) Bootstrap functional code.
    new MAM_My_Plugin_Manager();
}

The important conventions:

  • Hook plugins_loaded at priority 11, not 10. mam-main registers its filters during its own boot; at priority 11 those filters are guaranteed answerable.
  • Check once and cache the decision. The entitlement filter is already backed by a local cache, so re-firing it per request is wasteful, not dangerous — but the idiom is a single bootstrap-time gate. Don’t re-evaluate entitlement on every front-end hook.
  • Respect state, don’t auto-recover. A blocked entitlement is a support/billing matter. A plugin should go dormant and let mam-main‘s purchase notice (and the customer’s account team) handle it, not try to work around the block.
  • Runtime changes lag by design. Because the gate is read once and the cache refreshes on a schedule, a revocation made on the licensing server takes effect on the next sync plus the next request that bootstraps — not instantly. For a licensing model that’s the correct tradeoff; if a plugin genuinely needs near-real-time revocation it has to build that on top of the cron + cache-invalidation layer.

How this connects to the rest of the platform

The entitlement model sits on top of two other MAM Suite concepts:

  • Account code and enrollment. Entitlement is meaningless without identity. The account code (MAM_Account_Code_Manager) is what the sync sends to the licensing server to ask “what has this customer bought.” A site that isn’t enrolled has no account code, so sync_now() simply declines to sync — and the fail-open behavior keeps installed plugins running until enrollment provides one.
  • The remote-sync layer. Entitlement is one of several things mam-main keeps in sync with WPMAM on a schedule (alongside plugin updates and the codex feed). They share the same FastCron-preferred, wp-cron-fallback scheduler and the same “persist-then-read-locally” discipline.

And it underpins the commercial shape of the suite: because every sibling gates itself on mam_plugin_entitlement, MAM Suite can ship all the plugins to every site and let the licensing server decide, per customer, which ones come to life. That is what lets the platform behave like “the WooCommerce of mobile apps” — one install surface, many separately-licensed capabilities.


  • Hook: mam_plugin_entitlement — the filter reference, including the entitlement record shape.
  • Recipe: Gate a sibling plugin behind entitlement — the step-by-step bootstrap pattern.
  • Integration: Account code and enrollment server — where the account code comes from.
  • Frozen public contracts reference — which of these names and shapes are stable contracts.
Was this article helpful?
Contents

    Need Support?

    Can't find the answer you're looking for? Don't worry we're here to help!