Why MAM Suite extends through filters and actions only (the no-import rule)

Metadata

Field Value
Article type Explanation (Concepts)
Category Concepts
Audience PHP developer
Related plugin mam-main (the kernel) and every sibling plugin
Grounded in mam-main/ARCHITECTURE.md (§1, §5, §7, §9), mam-main/user_docs/10a
Last verified 2026-06-03

The rule, in one sentence

Sibling plugins extend mam-main exclusively through filters and actions. They never require_once a mam-main class file, never instantiate a mam-main class, and never read or write mam-main’s owned options and tables directly. They hook into mam-main’s pipelines, and mam-main hooks into theirs — but neither side reaches across the boundary into the other’s code.

This is not a style preference. It is the load-bearing decision that lets one small kernel be extended by a growing portfolio of feature plugins, on roughly 2,000 enrolled customer sites, without the whole thing turning into a single fragile monolith. Everything else in this article is an explanation of why that holds.


What “the boundary” actually is

mam-main is the kernel of MAM Suite — Tiny Screen Labs’ no-code WordPress-to-native-mobile-app platform. Each enrolled customer site runs WordPress with mam-main plus a portfolio of sibling plugins (mam-contact-form, mam-geodirectory, mam-chat-manager, mam-special-offers, mam-reviews-manager, mam-geofilters, mam-gravity-forms-manager, mam-inapp-purchase-manager, and more). The mobile apps talk to the site through a single AJAX endpoint, local_app_get_phone_data, which returns a JSON payload describing every screen, button, and content element in the app.

mam-main owns the things that have to be owned in exactly one place:

  • The mobile-API surface (local_app_get_phone_data and the phone-data pipeline behind it)
  • User authentication and role assignment
  • App settings (themes, layouts, buttons, tabs)
  • Notification dispatch across email, SMS, and push
  • Push credential storage and token registration
  • The cron-driven mail/SMS/PN queue
  • Admin UI scaffolding for all of the above

A sibling plugin does not own any of that. What it owns is one feature — a contact form, a directory, a chat surface, a set of special offers — that needs to appear in the app and participate in the platform’s pipelines. The boundary between “the platform” and “a feature” is the line the no-import rule defends.


How a sibling actually attaches (the contract)

Every well-behaved sibling follows the same shape, and none of the steps involve importing a mam-main class. The pattern is documented in mam-main/user_docs/10a and is visible in the real bootstraps of the shipping siblings:

  1. Standard plugin header. Just WordPress metadata.
  2. Bootstrap on plugins_loaded at the default priority (10). mam-main instantiates MAM_Main_Manager at plugins_loaded:2, so its register_*() chain has already wired up its hooks by the time the sibling boots.
  3. Gate on a version filter. The sibling asks mam-main, through a filter, whether the required minimum version is present — apply_filters( 'mam_check_required_version', $required, 'MAM Special Offers' ), where the default value is the version string it needs and the return is a compatibility boolean. It does not read mam-main’s version constant or include mam-main’s plugin file to find out.
  4. Gate on the entitlement filter. apply_filters( 'mam_plugin_entitlement', null, $plugin_slug ) returns whether this customer is licensed for this feature. If the filter isn’t even present, mam-main isn’t active, and the sibling quietly returns instead of crashing.
  5. Register against the surfaces it cares about and stop. From here on, the sibling only ever talks to mam-main through hooks.

The most common surface is mam_get_phone_data_before_send — the filter mam-main fires once while building the phone-data JSON. A sibling adds its content to the payload by subscribing:

add_filter( 'mam_get_phone_data_before_send', array( $phone, 'manage_phone_data' ) );

That single filter has on the order of 70 subscribers across the suite. Other primary surfaces work the same way: mam_notification_list to register a notification type (68+ registrations), mam_notification_send_message to fire one, mam_tab_manager to enrich a button (~43 subscribers), mam_app_settings_get_setting to read a setting through its fallback chain (100+ call sites), and mam_cron_manager to register a scheduled task (25+ subscribers). Adding a feature almost always means “subscribe to the right filter at the right priority,” not “call into mam-main.”


Why filters-only, and not imports

Imagine the alternative. A sibling require_onces mam-main/includes/phone-data/class-phone-data-pipeline.php and calls a method on the pipeline object directly. It looks tidy, and it works on the developer’s machine. Here is what it costs at scale.

1. It couples every sibling to mam-main’s internal layout. mam-main is a maturing codebase — it went through a multi-phase refactor that moved globals into MAM_Current_Request, raw $wpdb calls into repositories, and tsl_* names into mam_* names. If siblings imported class files, every one of those internal moves would be a breaking change for every sibling that imported them. Because siblings only touch filters, mam-main can rename a class, split a file, or rewrite a pipeline internally and no sibling notices, as long as the filter names and shapes hold.

2. It would multiply the frozen-contract surface by the number of importers. mam-main keeps a deliberately small set of frozen public contracts — the local_app_get_phone_data AJAX action, a handful of local-app-* option keys, the core filter names, and the six mam_* tables — frozen because ~2K live sites and the deployed mobile apps depend on their names and shapes (see Frozen public contracts and the wrap-don’t-rename discipline). If siblings imported internals, every internal method a sibling reached for would effectively become a frozen contract too, because breaking it would break a site in the field. The no-import rule keeps the contract surface tiny and explicit: a sibling can only depend on things mam-main has chosen to expose as a hook.

3. It keeps failure modes graceful. Because a sibling gates on filter presence (has_filter/apply_filters returning a falsy default) rather than on a class existing, “mam-main not active” is a clean no-op, not a fatal require of a missing file. On 2K heterogeneous sites with mismatched plugin versions, that graceful degradation is the difference between a quiet inactive feature and a white-screen-of-death.

4. It makes the dependency direction one-way and inspectable. With hooks, you can enumerate exactly how the system is wired: grep for who subscribes to mam_get_phone_data_before_send, who fires mam_notification_send_message. mam-main’s own boot is built this way — its constructor contains zero direct add_action/add_filter calls; every registration lives in a documented register_*() method. The same discipline extended outward to siblings is what makes a 9-plugin suite legible.


How this scales to 9+ plugins and ~2K sites

The scaling property is subtle but worth stating plainly: adding a sibling adds subscribers, not coupling. A new plugin registers against existing filters and disappears into the crowd of subscribers. mam-main doesn’t learn the new plugin’s name; it just fires its filters as always, and the new subscriber runs.

Two mechanisms make that crowd manageable:

  • Priority conventions order the subscribers without any of them knowing about each other. For mam_get_phone_data_before_send, base settings run first, the default cohort of feature plugins in the middle, and mam-main’s own home_cats injection last — at priority 1000, which must run after everything below it — with use-case total-overrides above that. A sibling declares only its own priority; it never coordinates directly with another sibling. The full priority ladder lives in How sibling plugins extend your app: the filter-driven architecture.
  • The “add a filter” answer to cross-cutting features. mam-main’s own guidance for where to add a feature ends with: if it crosses subsystem boundaries, add a new filter and let subscribers register against it. The platform grows by adding extension points, not by adding direct calls — which is the same rule the siblings live under, applied recursively to mam-main itself.

This is why the suite can keep adding plugins. Each one is, in mam-main’s words, “fully self-contained”: it includes its own files, owns its own prefixed option keys, hook names, and notification slugs, and communicates with everything else only through hooks.


The trade-offs (this is not free)

The no-import rule buys decoupling, and it pays for it in a few currencies. An honest explanation names them.

  • Indirection over directness. You cannot ctrl-click from a sibling’s apply_filters call to the code that answers it. The wiring is discoverable but not statically linked. New developers have to learn the hook map before the system reads clearly.
  • No type-checked interface. A filter is a name and an agreed-upon array shape, not a PHP interface the compiler enforces. If mam-main quietly changes what a hook passes, a sibling can break in ways a type system would have caught. The defense is the frozen-contract discipline plus the phone-data snapshot regression harness (tests/snapshot/snapshot-phone-data.php), which diffs the JSON output for known fixtures so an accidental shape change shows up before it ships.
  • Performance discipline becomes everyone’s job. Because mam_get_phone_data_before_send fires once with ~70 subscribers, a single careless subscriber that spends 50ms adds latency for the whole payload; mam_app_settings_get_setting runs 100+ times per build. The decoupling that lets you add a subscriber freely also means a slow or uncached subscriber degrades a shared hot path. Siblings are expected to cache aggressively and to return null (via the cursor mechanism) when their section hasn’t changed.
  • Discipline is cultural, not mechanical. Nothing in PHP stops a sibling from require_once-ing a mam-main file. The rule holds because the conventions are documented and followed, not because the language forbids it. That puts the weight on code review and on docs like this one.

The trade is deliberate: MAM Suite accepts indirection and an unenforced interface in exchange for the freedom to refactor the kernel, license features independently, fail gracefully on mismatched sites, and grow the plugin count without growing the coupling. At 2K sites, that trade is overwhelmingly worth it.


How this connects to the rest of the codex

  • The full bootstrap skeleton, the surface-to-hook table, and the priority ladder are covered in How sibling plugins extend your app: the filter-driven architecture and the developer guide (mam-main/user_docs/10a).
  • The set of names that may never change — AJAX actions, option keys, core filters, tables — is Frozen public contracts and the wrap-don’t-rename discipline. The same discipline applied to content-class names is Why you must never rename a content class.
  • The two gating filters every sibling uses are documented in Hook: mam_plugin_entitlement and the required-version check.
  • The single most-used extension point has its own article: Hook: mam_get_phone_data_before_send.
  • For a worked example of a sibling that does all of this correctly end-to-end, see Plugin: mam-contact-form and its recipes.

Verification

This article was last verified against:

  • mam-main/ARCHITECTURE.md — §1 (What mam-main Is), §5 (Frozen Public Contracts), §7 (Sibling Plugin Extension Points), §9 (Where to Add a Feature)
  • mam-main/user_docs/10a-extending-mam-main-overview.md
  • mam-suite/docs/ARCHITECTURE.md (suite-level design rules)
  • Real sibling bootstrap: mam-special-offers/mam-special-offers.php (entitlement gate + mam_get_phone_data_before_send subscription)

Re-verify whenever the entitlement/version-check filter contract changes, the frozen-contract list in §5 changes, the priority conventions for mam_get_phone_data_before_send change, or the bootstrap pattern in 10a is revised.


  • How sibling plugins extend your app: the filter-driven architecture
  • Frozen public contracts and the wrap-don’t-rename discipline
  • Why you must never rename a content class
  • Hook: mam_get_phone_data_before_send
  • Hook: mam_plugin_entitlement
  • Plugin: mam-contact-form
Was this article helpful?
Contents

    Need Support?

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