Why there’s a cascade at all
A MAM app is one WordPress site rendering different experiences for different people. An anonymous visitor, a logged-in subscriber, and a cloning admin previewing the app may all need a different value for the same conceptual setting — the geofilter radius, whether login is required, a button’s background color. And within a single role, one button may need to override a site-wide default just for itself.
Rather than scatter that branching logic across every read site, MAM Suite funnels every setting read through one filter, mam_app_settings_get_setting, and resolves the right value in one place: the data manager. There are 100+ call sites for this filter across the suite and sibling plugins. The payoff is that a developer reading a setting never writes “if anonymous do X, else if subscriber do Y” — they ask for the value with a role and a key, and the cascade hands back the most specific value that exists, falling back through less-specific layers until it lands on a default.
This article explains why a value resolves the way it does and, just as importantly, where to write so your value lands at the layer you intend.
The read contract
$value = apply_filters(
'mam_app_settings_get_setting',
$default, // returned if nothing is stored
$role, // role slug; '' (empty) means global
$category, // settings category / button context
$key // the setting key
);
This signature is a frozen public contract. ~100 call sites depend on it. It is not to be renamed, and — this is the part developers most often get wrong — the fallback is not implemented by chaining filter subscribers. It lives inside mam_app_settings_data_manager::get_setting(). A subscriber that tries to re-implement the per-role / global fallback itself will fight the built-in logic. (See “Extending the cascade” below for the rare case where adding a subscriber is correct.)
What actually resolves: the three layers
Conceptually the cascade has three scoping layers plus a final default. From most specific to least specific:
- Per-button — a value scoped to a single button instance. Stored against that button’s id.
- Per-role — a value scoped to one role (
subscriber,administrator,anonymous, a sibling-plugin custom role, etc.). - Global — a site-wide value that applies when no role-specific value exists.
- Default — the
$defaultargument you passed in.
The first layer that has a stored value wins. If you ask for a subscriber’s value and only a global value exists, you get the global value. If neither exists, you get your $default. This is why the same read can return three different things on three different sites — the difference is which layer was written, not the call.
How the layers are actually stored (the part the reference docs simplify)
Some of the older reference docs describe per-role storage as “an option key suffixed with the role” and per-button storage as “a key inside the button blob.” That’s a useful mental model, but the current implementation in app-settings-data-manager.php is different and worth knowing when you debug:
Settings live in a dedicated table, {$wpdb->prefix}mam_app_settings_options, with columns for acct_code, button_id, setting_key, role, and value. A read is a single query:
SELECT value
FROM wp_mam_app_settings_options
WHERE button_id = %s
AND setting_key = %s
AND acct_code = %s
AND (role = %s OR role = 'mam-all')
ORDER BY CASE WHEN role = %s THEN 1 ELSE 2 END
LIMIT 1
A few things fall out of this that explain the resolution behavior:
- The role fallback is one query, not a loop. The
OR role = 'mam-all'plus theORDER BYis the whole cascade for the role dimension.mam-allis the global sentinel — the row that means “this value applies regardless of role.” A role-specific row sorts ahead of themam-allrow, so if both exist the role-specific one wins; if onlymam-allexists, that’s the global fallback; if neither exists, the filter returns your$default. - Per-button is the
button_idcolumn, not a separate fallback step. The data manager parameter named$button_idis what the public filter exposes as the third argument ($category). A non-button, “category-level” read simply passes a category string in that slot; a per-button read passes the button’s id. There is no automatic per-button → category → global walk within a single query — the specificity that resolves is the role ordering. The “per-button vs global” distinction is whichbutton_idvalue you stored against. - Everything is scoped by
acct_code. On multi-app installs the account code namespaces every row, resolved throughmam_multi_app_get_account_code; single-app sites use'default'.
The third argument is one slot under two names: the public filter documents it as $category, and the data-manager method receives it as $button_id. Callers fill it one of two ways — a category string for a non-button, feature-level read ('general-settings', 'mam-user-roles', and the like) or a button id for a per-button read (a login button’s id, a tab button’s id). Either value lands in the button_id column and the query matches it verbatim; “per-button vs. category” is purely which kind of value you stored there, not two storage systems.
An empty role means global, not “the global role”
When $role is empty, the data manager normalizes it to the mam-all sentinel ($role ?: 'mam-all'). So:
- Pass
''to read/write the global value. - Pass an actual role slug to read/write that role’s value.
- Do not pass the literal string
'global'. It isn’t a sentinel; it would create a phantom role namedglobalthat nothing else reads.
Where to write — matching the write to the layer you mean
Writes go through the sibling filter, mam_app_settings_set_setting, which always targets one scope. There is no “write everywhere” — you choose the layer by what you pass:
// Global (site-wide) write — empty role:
apply_filters( 'mam_app_settings_set_setting', $value, '', $category, $key );
// Per-role write — explicit role:
apply_filters( 'mam_app_settings_set_setting', $value, 'subscriber', $category, $key );
The mental model that prevents bugs: write at the layer you want the value to take effect, and no broader. If you write a per-role value, you have not changed the global default — a different role still falls through to global. If you write global, you have not overridden any role that already has its own row; that role keeps winning because role-specific sorts ahead of mam-all. A common “my setting won’t change” report is a global write being shadowed by a stale per-role row.
set_setting resets the JSON cursor (JSON_Cursor_Manager::reset_cursor()) on every write so the next phone-data build picks up the change. That’s also why you should write through the filter rather than poking the table with $wpdb directly — a raw write skips cursor invalidation and the value silently won’t ship to the app.
How content classes declare which layer a setting belongs to
A button’s content class declares its settings via app_settings(), and each declaration carries an environment flag that signals the intended scope. In the current source the values you’ll see are:
all— by far the most common; the setting is offered broadly.global— site-wide setting.per-button— the setting is meant to vary per button instance.backend/hidden— admin-side or non-rendered settings.
environment is the authoring hint that drives where the admin UI surfaces the field; the runtime resolution is still the role/mam-all query above. Treat environment as “where this setting is meant to be set,” and the cascade as “how a read finds whatever was set.”
The cloning-admin wrinkle
A cloning admin (an administrator previewing the app as another role, via MAM_Current_Request->is_cloning()) reads settings for the role being cloned — not their real administrator role. This is intentional: the preview should match what the target role sees. But it means that if you need the admin’s actual context inside a read path, you have to check is_cloning() explicitly; the $role flowing through the cascade will be the cloned role.
Defensive reads: always supply a real default
Because any layer can be absent, every caller must pass a meaningful $default, and per-button values (which historically live in loosely-shaped button blobs) should be read with ?? $default discipline. A missing key is normal, not an error. Never assume a row exists just because the admin UI shows the field.
Extending the cascade (rarely, and cheaply)
You almost never need to add a subscriber — the built-in cascade covers per-button, per-role, and global. The legitimate case is a sibling plugin that stores a setting in its own backing store and wants reads of its namespaced key to resolve from there. Then:
add_filter( 'mam_app_settings_get_setting', function ( $value, $role, $category, $key ) {
if ( $key !== 'my_plugin_special_value' ) {
return $value; // short-circuit: not mine
}
return get_option( 'my_plugin_special_value', $value );
}, 10, 4 );
Two non-negotiable rules, because this filter is a hot path that fires hundreds of times per phone-data build:
- Early-return when the key isn’t yours. Iterating or doing work for every key is the single most common cause of slow phone-data responses.
- Cache anything non-trivial with
wp_cache_get/wp_cache_setor a request-scopedstatic. Never make HTTP calls or run unbatched per-call queries inside this filter. A 50 ms subscriber becomes 5+ seconds across a single build, multiplied by every customer’s app launch.
How this connects to the rest of the system
- Buttons carry per-button settings; their definitions and per-button keys are covered in Button array storage and Per-button and per-role settings.
- Discovery tooling (
mam_codex_manager) enumerates which settings exist and whatenvironmentthey declare — useful for an admin settings-search UI — but it is not part of the runtime read path. Don’t call it to answer “does this setting exist?” at request time; read the value with a default instead. - Scoped sibling filters (
mam_app_settings_get_buttons,..._get_layout_settings,..._get_tab_bar_settings,..._get_form_colors, and others) follow the same role/mam-all, account-scoped pattern but return shaped structures instead of a single value. If you understand this cascade, those read the same way.
Verification
This article was last verified against:
mam-main/includes/app-settings/app-settings-data-manager.php(get_setting()/set_setting(),mam-allsentinel,acct_codescoping, cursor reset)mam-main/user_docs/08a-settings-cascade-overview.md,08c-per-button-and-per-role-settings.md,08d-button-array-storage.md,08e-codex-and-settings-discovery.mdmam-main/user_docs/09g-hook-mam-app-settings-get-setting.mdenvironmentflag values observed acrossmam-maincontent-classapp_settings()declarations
Re-verify whenever the get_setting query shape, the mam-all sentinel, the public filter signature, or the environment flag vocabulary changes.
Related articles
- Settings cascade overview (mam-main)
- Per-button and per-role settings (mam-main)
- Button array storage (mam-main)
- Hook: mam_app_settings_get_setting
- Hook: mam_app_settings_set_setting
- Codex and settings discovery (mam-main)
