Forms manager end-to-end cookbook

What this doc is

Existing 04a / 04a2 docs list every form-manager hook by category. This one walks through one form, end to end — the order things actually fire, the option keys to set, and the spots where most integrations stub their toe. The running example is the aquaman-boat-booking day_settings form (anchor time + four captain dropdowns + a hidden booking-date).

If you’re building a new form for a sibling plugin, work through this doc top to bottom and you’ll have every wiring point covered.

Which path does this cover?

mam-main supports two form sources:

This cookbook walks the GF-backed path because that’s what the example plugin uses. The phases — cache pre-warm, dynamic injection, surfacing to the user, submission handling — are identical for code-based forms; only Stage 1 (declaration) and Stage 2 (slug→field-id mapping) differ. The hook reference in 04a4 covers both registration entry points.


The five stages

1. Declare        — mam_gf_get_form_settings filter
2. Configure      — admin UI maps slugs → GF field IDs (stored as options)
3. Pre-warm cache — setup_forms_for_caching on mam_initial_phone_data
4. Inject         — mam_populate_gravity_form (choices, populate_with_key, defaults)
5. Surface + handle — row publishes form to user; submission lands at
                     mam_for_gravity_forms_form_result_form_<ID>

Each section below covers one stage, with concrete code from the boat-booking plugin.


1. Declare the form

Sibling plugins announce their forms via the mam_gf_get_form_settings filter. The framework collects every plugin’s contributions and uses them for the admin form-mapping UI and the cache pre-warm.

Envelope shape

add_filter( 'mam_gf_get_form_settings', [ $this, 'register_my_forms' ] );

public function register_my_forms( $settings ) {
    $settings[] = [
        'category'    => 'general',
        'title'       => __( 'Day Settings', 'mam-aquaman-boat-booking' ),
        'variable'    => 'none',
        'type'        => 'gravity_form',
        'id'          => 'mam_aquaman_day_settings',     // option-key root, see Stage 2
        'environment' => 'backend',
        'data'        => [
            'type'   => 'form_info',
            'title'  => 'Day Settings',
            'slug'   => 'day_settings',
            'root'   => 'mam_aquaman_',
            'fields' => [
                [ 'title' => 'Anchor Time',  'slug' => 'anchor_time',  'populate_with_key' => 'anchor_time' ],
                [ 'title' => 'Boat 1',       'slug' => 'capt_one',     'populate_with_key' => 'capt_one' ],
                [ 'title' => 'Boat 2',       'slug' => 'capt_two',     'populate_with_key' => 'capt_two' ],
                [ 'title' => 'Boat 3',       'slug' => 'capt_three',   'populate_with_key' => 'capt_three' ],
                [ 'title' => 'Boat 4',       'slug' => 'capt_four',    'populate_with_key' => 'capt_four' ],
                [ 'title' => 'Booking Date', 'slug' => 'booking_date', 'populate_with_key' => 'booking_date' ],
            ],
        ],
    ];
    return $settings;
}

Per-field keys

Key Required Notes
title yes Human-readable label shown in the admin UI
slug yes Stable identifier used everywhere downstream — handler, row prefill, options. Hyphens or underscores are both OK; the codebase uses both
populate_with_key optional When set, the row’s value at this key becomes the field’s default. Almost always equal to slug for sanity; only differ when you need to publish from one row-key but write to another field
id optional A static GF field ID. Most integrations omit this and let the admin UI set it; geodirectory hardcodes it
type optional text, select, textarea, hidden, email, checkbox — used for richer field metadata; the GF form itself is the source of truth at render time

Why both slug AND populate_with_key?

slug identifies the field across hooks (the option key is <form_id_option>_<slug>, the result envelope uses $values[slug]). populate_with_key identifies the data lane the field reads from when rendered for a row. The two coincide in 95% of cases — but the indirection lets you publish a row value as customer_id and feed it to a hidden GF field whose internal slug is postid, for example.


2. Configure: slug → GF field ID

You declared what fields exist. The admin then has to tell the framework which Gravity Form field carries which slug. Two options get set, conventionally via the in-app admin form-settings UI:

Option Holds Example
<form_settings_id> The GF form ID for this declaration mam_aquaman_day_settings = 3
<form_settings_id>_<slug> The GF field ID inside that form mam_aquaman_day_settings_anchor_time = 1<br>mam_aquaman_day_settings_capt_one = 3<br>mam_aquaman_day_settings_booking_date = 7

Most prefill bugs trace back to this stage. If populate_with_key isn’t appearing in the cached form JSON, the cache pre-warm couldn’t resolve a field ID for that slug — usually because the admin hasn’t mapped it yet.

You can bypass the admin UI for development by setting the options directly:

update_option( 'mam_aquaman_day_settings',              3 );
update_option( 'mam_aquaman_day_settings_anchor_time',  1 );
update_option( 'mam_aquaman_day_settings_capt_one',     3 );
// ...etc

But for production, the admin UI is the canonical write path so the mapping travels with site config.


3. Cache pre-warm

When the app pulls phone data, mam-main triggers mam_initial_phone_data. The forms-manager subscribes to it and runs setup_forms_for_caching() (mam-gravity-forms-class.php:18).

What that function does, per declared form:

for each form_def returned by mam_gf_get_form_settings:
    form_id = get_option(form_def.id)
    if form_id > 0:
        for each declared field:
            field_id = get_option(form_def.id + '_' + slug)
            if field_id > 0:
                remember field_id → populate_with_key
        form = get_data_for_app(form_id)          # fetches and shapes the form
        for each cached form field:
            fire mam_form_manager_precached_field filter
            if we remembered a populate_with_key for this field_id:
                stamp it onto the cached field
        $local_app_form_array[] = form

After this runs, the form lives in $local_app_form_array with populate_with_key already stamped on every field that has both a declared slug and a configured field ID.

The cache-miss path (gotcha)

mam_gf_get_form_from_cache( $form_id ) returns the 1-based index into $local_app_form_array. If the form wasn’t pre-warmed, it falls back to calling get_data_for_app() directly and pushing the result onto $local_app_form_arraywithout the populate_with_key stamping.

If your content class runs before mam_initial_phone_data fires, or if your form was declared on a hook that ran too late, the form ends up in the cache without populate keys. Two ways out:

  1. Hook your declaration on plugins_loaded (or earlier) so it’s in the filter pipeline before any phone-data pull
  2. Stamp populate_with_key yourself in mam_populate_gravity_form (see Stage 4) — that filter runs inside get_data_for_app, so it works for both the pre-warm path and the cache-miss path

Stage 4 belt-and-suspenders is what aquaman-boat-booking does.


4. Inject dynamic data

The framework loads the form via GFAPI::get_form (or equivalent). That doesn’t fire any GF rendering hooks — gform_pre_render does not run for app-bound forms. The mam-equivalent is mam_populate_gravity_form:

add_filter( 'mam_populate_gravity_form', [ $this, 'inject_choices' ], 10, 2 );

public function inject_choices( $form, $form_id ) {
    if ( (int) $form_id !== (int) get_option( 'mam_aquaman_day_settings' ) ) {
        return $form;
    }

    // Build slug → field-id from the same options the framework uses
    $opt_prefix = 'mam_aquaman_day_settings_';
    $slug_by_fid = [];
    foreach ( [ 'anchor_time', 'capt_one', 'capt_two', 'capt_three', 'capt_four', 'booking_date' ] as $slug ) {
        $fid = (int) get_option( $opt_prefix . $slug );
        if ( $fid > 0 ) $slug_by_fid[ $fid ] = $slug;
    }

    $captain_choices = [ [ 'value' => '', 'text' => '— Unassigned —' ] ];
    foreach ( $this->all_captains() as $cap ) {
        $captain_choices[] = [ 'value' => (string) $cap->ID, 'text' => $cap->post_title ];
    }

    foreach ( $form['fields'] as $key => $field ) {
        $fid = (int) ( is_object( $field ) ? $field->id : $field['id'] ?? 0 );
        if ( ! $fid ) continue;

        // Choices
        if ( in_array( $fid, [ /* captain field ids */ ], true ) ) {
            $this->set( $form['fields'][ $key ], 'choices', $captain_choices );
        }

        // Belt-and-suspenders populate_with_key stamping
        if ( isset( $slug_by_fid[ $fid ] ) ) {
            $this->set( $form['fields'][ $key ], 'populate_with_key', $slug_by_fid[ $fid ] );
        }
    }

    return $form;
}

// Field may arrive as GF_Field object OR array depending on path; handle both
private function set( &$field, $key, $value ) {
    if ( is_object( $field ) )      $field->$key = $value;
    elseif ( is_array( $field ) )   $field[ $key ] = $value;
}

Other filters that fire here

Filter Fires Useful for
mam_gf_get_form Before mam_populate_gravity_form Replace the raw form lookup entirely
mam_populate_gravity_form Inside get_data_for_app, after raw fetch Inject choices, defaults, populate_with_key (recommended)
mam_form_manager_precached_field During cache pre-warm only, per field Per-field tweak during cache build — does NOT fire on cache miss
mam_form_manager_content_class_<type> Per field, late Adjust based on the field’s content-class type
mam_populate_gravity_form_final Last, after all per-field processing Final cleanup pass on the whole form
mam_update_form_before_sending_<formid> / mam_update_form_before_sending Just before payload exits Universal last-mile transform

Stick with mam_populate_gravity_form unless you have a specific reason to use one of the others.


5a. Surface the form to the user

Three patterns, each with its own iOS resolution path. Pick one per use case; see 04a5-forms-manager-prefill-paths.md for the gap analysis.

Pattern A — Tabbar button → form-array

The tabbar button opens a sheet of one or more forms. Each form gets its own prefill copy. Used by mam-geodirectory’s Edit Staff button and others.

$form_index = apply_filters( 'mam_gf_get_form_from_cache', 0, $form_id );

$form_values = [
    'first_name' => $staff->first_name,
    'last_name'  => $staff->last_name,
    'postid'     => $listing_id,
];

$tabbar_button['action'] = 'open_form_array';
$tabbar_button['source'] = [
    [
        'custom_form'        => $form_index,
        'custom_form_values' => $form_values,
        'custom_form_icon'   => 'white_person',
        'custom_form_name'   => $staff->display_name,
        'no_persist'         => 'yes',
    ],
];

Pattern B — List row → edit button via custom_form_array

Each row publishes one or more form entries on its custom_form_array. The mobile list renders them as inline buttons. Used by mam-qrlife profiles.

$row['custom_form_array'][] = [
    'custom_form'        => $form_index,
    'custom_form_values' => $populate_values,   // keyed by populate_with_key
    'custom_form_icon'   => 'black_edit.png',
    'custom_form_name'   => 'Edit',
    'no_persist'         => 'yes',
];

Pattern C — Tap-the-row (content_type: 'form')

The whole row is a tap-to-open-form cell. iOS reads form_id (1-based form-array index) and selectedItem (the row index). Used by aquaman-boat-booking anchor-times.

$row['content_type'] = 'form';
$row['form_id']      = (string) $form_index;
// + populate values either at top-level (current convention) or
//   tucked somewhere the iOS resolver reads from (see Doc 04a5).

Pattern C prefill (resolved 2026-05-20, see Doc 04a5): publish populate values as top-level keys on the row using the same slugs declared in mam_gf_get_form_settings. iOS reads them via the form-manager’s selectedItem fallback in vc_form_manager.m. No custom_form_array wrapping needed.


5b. Handle submission

Submissions land at a form-id-keyed filter, dynamically wired:

add_filter(
    'mam_for_gravity_forms_form_result_form_' . get_option( 'mam_aquaman_day_settings' ),
    [ $this, 'save_day_settings' ]
);

The handler signature is function( $result = null, $entry = null, $form = null ). Read values out of the entry via the mam_gf_get_form_data filter, keyed by slug:

public function save_day_settings() {
    $fields = $this->day_settings_fields();   // same slug list as in step 1
    $values = apply_filters(
        'mam_gf_get_form_data',
        $fields,
        'mam_aquaman_day_settings_'   // note trailing underscore — framework convention
    );

    $booking_date = sanitize_text_field( $values['booking_date']['value'] ?? '' );
    $anchor       = $values['anchor_time']['value'] ?? '';
    // ...etc
    // do the persistence work

    return [
        'status'             => 'success',
        'process_normal'     => false,   // true = let GF run its own confirmations/notifications
        'send_notifications' => false,
        'type'               => 'refresh_return',
        'message'            => 'Saved.',
    ];
}

Result envelope keys

Key Required Notes
status yes 'success' or 'error'
process_normal yes If true, GF’s own submission pipeline (confirmations, GF notifications) also runs. Usually false for app-bound forms — your handler is fully replacing it
send_notifications yes If true, fires mam_form_manager_send_notifications (the notification dispatcher)
type yes 'refresh_return', 'error', 'redirect', etc. — controls the iOS post-submit behavior
message yes Shown to the user
send_json optional 'yes' to wrap the response as JSON before returning

Wiring checklist

When a form isn’t behaving, walk this list in order:

  1. Declaration present? apply_filters( 'mam_gf_get_form_settings', [] ) should include your form. If not, your filter callback isn’t registered or fires too late
  2. Form-id option set? get_option( '<form_settings_id>' ) should return a positive integer
  3. Each slug mapped? get_option( '<form_settings_id>_<slug>' ) should return a positive integer for every slug you care about. Missing options = missing populate_with_key
  4. Cache populated? var_dump( $local_app_form_array ) after mam_initial_phone_data — your form should be in there with populate_with_key stamped
  5. Submission hook wired? has_filter( 'mam_for_gravity_forms_form_result_form_<ID>' ) should be true. If you used get_option() in add_filter(), the option must be set before the plugin’s init runs
  6. Result envelope correct? Return the full envelope shape — partial returns can cause the app to hang waiting on a status field

See also


Metadata

Field Value
Article type Recipe / Cookbook
Plugin slug mam-main
Applies to plugin version 2.1.11+
Category Forms Manager
Audience PHP developer building a sibling plugin
Last verified 2026-05-20
Related Forms manager overview, Hook reference, Prefill paths
Contents

    Need Support?

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