The settings cascade: how global, per-role, and per-button settings resolve

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:

  1. Per-button — a value scoped to a single button instance. Stored against that button’s id.
  2. Per-role — a value scoped to one role (subscriber, administrator, anonymous, a sibling-plugin custom role, etc.).
  3. Global — a site-wide value that applies when no role-specific value exists.
  4. Default — the $default argument 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 the ORDER BY is the whole cascade for the role dimension. mam-all is the global sentinel — the row that means “this value applies regardless of role.” A role-specific row sorts ahead of the mam-all row, so if both exist the role-specific one wins; if only mam-all exists, that’s the global fallback; if neither exists, the filter returns your $default.
  • Per-button is the button_id column, not a separate fallback step. The data manager parameter named $button_id is 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 which button_id value you stored against.
  • Everything is scoped by acct_code. On multi-app installs the account code namespaces every row, resolved through mam_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 named global that 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_set or a request-scoped static. 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 what environment they 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-all sentinel, acct_code scoping, 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.md
  • mam-main/user_docs/09g-hook-mam-app-settings-get-setting.md
  • environment flag values observed across mam-main content-class app_settings() declarations

Re-verify whenever the get_setting query shape, the mam-all sentinel, the public filter signature, or the environment flag vocabulary changes.

  • 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)
Was this article helpful?
Contents

    Need Support?

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