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:
- Code-based (programmatic) forms — defined in PHP, no Gravity Forms installation needed. Registered via
mam_form_manager_get_forms_from_plugins. Default path for any plugin that owns its form contract end-to-end. See 04a2 §Forms providers and extensibility. - Gravity-Forms-backed forms — admin builds them in the WP UI; mam-gravity-forms-manager bridges them in. Registered via
mam_gf_get_form_settings. Used when admins need to edit forms without code or when integrating with existing GF forms.
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_array — without 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:
- Hook your declaration on
plugins_loaded(or earlier) so it’s in the filter pipeline before any phone-data pull - Stamp
populate_with_keyyourself inmam_populate_gravity_form(see Stage 4) — that filter runs insideget_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:
- 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 - Form-id option set?
get_option( '<form_settings_id>' )should return a positive integer - Each slug mapped?
get_option( '<form_settings_id>_<slug>' )should return a positive integer for every slug you care about. Missing options = missingpopulate_with_key - Cache populated?
var_dump( $local_app_form_array )aftermam_initial_phone_data— your form should be in there withpopulate_with_keystamped - Submission hook wired?
has_filter( 'mam_for_gravity_forms_form_result_form_<ID>' )should be true. If you usedget_option()inadd_filter(), the option must be set before the plugin’s init runs - Result envelope correct? Return the full envelope shape — partial returns can cause the app to hang waiting on a status field
See also
- Forms manager overview — hook categories and class roles
- Hook reference — every hook, in firing order
- Prefill paths — row→form data flow and the
content_type: 'form'gap - Form submission lifecycle — what happens after
mam_for_gravity_forms_form_result_form_<ID>returns - Form cache and invalidation — when the cache busts and how to force a refresh
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 |
