Gravity Forms does not have a native concept of "multiple-row" submissions that maps onto an ACF Repeater. Three patterns cover the common cases: append a new Repeater row per form submission (best for "add a testimonial" forms), submit a single form with multiple grouped sections (limited but works for fixed row counts), or use a single text field encoded as JSON and parse it server-side (most flexible). Each has a use case; here is each with code.
Jump to:
- The fundamental mismatch
- Pattern 1: append-per-submission
- Pattern 2: grouped sections with fixed row count
- Pattern 3: JSON-encoded text field
- Validation for Repeater-bound submissions
- The third-party "Repeater field" plugins
- When to use a different tool entirely
The fundamental mismatch
Gravity Forms is a flat-form builder. Each form has a fixed set of fields; each submission produces one entry with values for those fields. There is no built-in "add another row" concept where the user dynamically adds N copies of the same group of fields.
ACF Repeater is exactly the opposite: a dynamic array of rows where each row has sub-fields.
The three patterns below bridge the gap, each with different trade-offs around UX, technical complexity, and editor-side behavior.
Pattern 1: append-per-submission
The simplest case. Each form submission appends one row to an ACF Repeater on a target post. Useful for "add a testimonial" or "submit your event" forms where users contribute one item at a time.
add_action( 'gform_after_submission_7', function ( $entry, $form ) {
// Target post that holds the Repeater
$target_post_id = 42; // or computed from the form context
// Build the new row
$new_row = [
'author_name' => rgar( $entry, '1' ),
'author_email' => rgar( $entry, '2' ),
'testimonial' => rgar( $entry, '3' ),
'rating' => (int) rgar( $entry, '4' ),
'submitted_at' => current_time( 'mysql' ),
];
// Append using ACF's add_row helper
add_row( 'testimonials', $new_row, $target_post_id );
}, 10, 2 );add_row is ACF's helper that appends without needing to read the existing array first. Cleaner than the read-then-write pattern when you are only appending.
For moderation workflows, mark new rows as is_approved = false and require an editor to flip the flag before they appear in the front-end render:
$new_row['is_approved'] = false;
add_row( 'testimonials', $new_row, $target_post_id );Pattern 2: grouped sections with fixed row count
When the form has a fixed N-row Repeater (e.g., "add up to 3 references"), Gravity Forms Section breaks can visually group the fields and you map them as N independent rows:
The form has fields 1-12 grouped into 4 sections of 3 fields each: name, email, company. The hook reads them as 4 rows:
add_action( 'gform_after_submission_8', function ( $entry, $form ) {
$post_id = rgar( $entry, 'post_id' );
if ( ! $post_id ) return;
$rows = [];
for ( $i = 0; $i < 4; $i++ ) {
$base = ( $i * 3 ) + 1; // form field IDs 1, 4, 7, 10
$name = rgar( $entry, (string) $base );
$email = rgar( $entry, (string) ( $base + 1 ) );
$company = rgar( $entry, (string) ( $base + 2 ) );
// Only include rows that have at least a name
if ( ! empty( $name ) ) {
$rows[] = [
'name' => $name,
'email' => $email,
'company' => $company,
];
}
}
update_field( 'references', $rows, $post_id );
}, 10, 2 );Limitations:
- The maximum row count is fixed in the form. Users cannot add more than N.
- The form has N times the field count, which is a lot of fields in the editor.
- Empty rows are filtered server-side; the form itself does not enforce contiguity.
This pattern works for "up to 3 references" but does not scale to "up to 50 items."
Pattern 3: JSON-encoded text field
For genuinely dynamic row counts, the cleanest pattern is a single text/hidden field on the form that holds a JSON-encoded array of rows, populated by JavaScript on the front-end:
The form has one hidden field (form field ID 5) holding JSON. The form's front-end JavaScript adds a "+ add row" UI and stringifies the rows into the hidden field on submission.
The server-side hook decodes and writes to ACF:
add_action( 'gform_after_submission_9', function ( $entry, $form ) {
$post_id = rgar( $entry, 'post_id' );
if ( ! $post_id ) return;
$json = rgar( $entry, '5' );
$rows = json_decode( $json, true );
if ( ! is_array( $rows ) ) return;
// Sanitize each row's fields
$clean_rows = [];
foreach ( $rows as $row ) {
$clean_rows[] = [
'item_name' => sanitize_text_field( $row['item_name'] ?? '' ),
'quantity' => (int) ( $row['quantity'] ?? 0 ),
'notes' => sanitize_textarea_field( $row['notes'] ?? '' ),
];
}
update_field( 'items', $clean_rows, $post_id );
}, 10, 2 );The front-end JavaScript (added via a custom theme script):
// Add row button + serialize on submit
document.querySelector('.gform_wrapper').addEventListener('submit', function () {
const rows = Array.from(document.querySelectorAll('.dynamic-row')).map(row => ({
item_name: row.querySelector('[name="item_name"]').value,
quantity: row.querySelector('[name="quantity"]').value,
notes: row.querySelector('[name="notes"]').value,
}));
document.querySelector('input[name="input_5"]').value = JSON.stringify(rows);
});This pattern is the most flexible but requires custom JavaScript and styling. Worth it when row counts genuinely vary and the form is critical UX.
Validation for Repeater-bound submissions
For all three patterns, server-side validation matters because Gravity Forms cannot natively validate Repeater shapes:
add_action( 'gform_after_submission_9', function ( $entry, $form ) {
$rows = json_decode( rgar( $entry, '5' ), true );
// Validate before writing
if ( ! is_array( $rows ) || count( $rows ) < 1 ) {
// Mark the entry for manual review
GFAPI::update_entry_property( $entry['id'], 'status', 'spam' );
return;
}
if ( count( $rows ) > 50 ) {
// Too many rows; possibly malicious
GFAPI::update_entry_property( $entry['id'], 'status', 'spam' );
return;
}
// Validate each row's structure
foreach ( $rows as $row ) {
if ( empty( $row['item_name'] ) || mb_strlen( $row['item_name'] ) > 200 ) {
GFAPI::update_entry_property( $entry['id'], 'status', 'spam' );
return;
}
}
// OK to write
update_field( 'items', $rows, $post_id );
}, 10, 2 );The spam flag in Gravity Forms hides the entry from the default entry list. Manual review can recover legitimate-but-flagged entries.
The third-party "Repeater field" plugins
Several third-party Gravity Forms add-ons add a UI-driven "Repeater" or "Field Group" concept (Gravity Wiz's Gravity Perks suite has one, among others). They simplify the front-end side (no custom JavaScript needed) and integrate with the form editor.
The trade-off:
- Plugin path: UI-driven, easier to maintain for non-developers, additional plugin cost and dependency.
- Custom JSON path: Code-managed, no plugin dependency, requires custom JavaScript.
For long-lived agency client sites, I default to the custom JSON path because it has no third-party dependency to maintain. For sites where the client wants to manage forms themselves, the plugin path can be worth the cost.
When to use a different tool entirely
If you find yourself building "form with dynamic rows that creates a post with a Repeater" frequently, consider that Gravity Forms might not be the right tool. The alternatives:
- Standalone post submission via the WordPress REST API. Full control, native Repeater support, requires building the UI from scratch.
- A custom front-end form built with React/Vue. Same flexibility, requires the framework.
- WPForms or Fluent Forms for cases where their dynamic-field handling is better fit.
- A separate post type for each "row" with Relationship back to the parent, submitted as individual form posts. Often cleaner architecturally than a Repeater.
For the broader pattern of agency-scale Gravity Forms + ACF integration (especially for client onboarding and lead-capture workflows), see How to Populate ACF Fields from Gravity Forms. For the related "when to use a separate post type vs Repeater" decision, see Why Deeply Nested ACF Flexible Content Layouts Become a Maintenance Nightmare.
Sources
Authoritative references this article was fact-checked against.
- Gravity Forms developer hooks (Gravity Forms docs)docs.gravityforms.com
- update_field() function reference (Advanced Custom Fields docs)advancedcustomfields.com





