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-mainitself 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:
- Source-server bypass. If the current host is
wpmobileappmanager.com(or a subdomain), every plugin isentitledwith reasonsource_server. The licensing server is itself a MAM site and must never gate itself off. - Cache lookup. Otherwise the handler reads the per-plugin record out of the cached option (see below).
- Cold cache → fail open. If the cache is missing or doesn’t yet contain this slug, the handler returns
entitledwith reasoncache_miss. A site that just installed a plugin, or whose cache hasn’t synced yet, is treated as entitled rather than locked out. - Explicit non-purchase → blocked. If the server explicitly returned
falseor an empty record for the slug, the state isblocked/not_purchased. - Status flags. A returned record carrying
status = grace_activebecomesgrace(withgrace_until→expires_at);status = expiredbecomesexpired. Anything else with a record isentitled/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_Managerschedulesmam_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 viamamdebugand 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-mainversion string, nottrue/false. - Its side effect is the actual enforcement: if the installed version is below
$requires, it logs anadmin_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 inwp-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_loadedat priority 11, not 10.mam-mainregisters 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. Ablockedentitlement is a support/billing matter. A plugin should go dormant and letmam-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, sosync_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-mainkeeps 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.
Related
- 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.
