ACF Repeater Field PHP — Loop Any List of Sub-Fields

A step-by-step PHP renderer for ACF Repeater fields — loop any list of sub-fields cleanly, with or without Flexible Content.

PHP utility Production-tested ~4 min install ACF Free or Pro Beginner-friendly WP 6.0+ · ACF 6.0+ Updated May 20, 2026
TL;DR

An ACF repeater field PHP loop that renders each row's sub-fields — team members, FAQs, testimonials, pricing tiers, or any list the editor controls.

Use this when your editor needs to add, remove, or reorder list items from the ACF panel without touching code.

01 — Building it step by step

ACF Repeater Field PHP — Build It in 5 Steps

5 steps — field creation first, then PHP. Stop at any step and you'll have working code.

We build this in 5 steps. Step 1 creates the ACF fields in your WordPress admin — so they exist before the PHP tries to read them. Steps 2–5 are PHP, each adding one new concept. Stop at any step and you have working code.

Step 1: Create the ACF fields. Before writing any PHP, open ACF → Field Groups → Add New and create a field group called Team Members. Add one Repeater field with four sub-fields:

  1. Repeater — Label: Team Members, Name: team_members, Layout: Block, Button Label: Add Team Member. Inside this repeater, add four sub-fields:
    • Text — Label: Name, Name: name
    • Text — Label: Role, Name: role
    • Image — Label: Photo, Name: photo, Return Format: Array
    • Textarea — Label: Bio, Name: bio, Rows: 3

Assign the field group to Pages. Save.

acf-fields-reference.php
php
<?php
// The ACF fields you just created:
//
// team_members    — Repeater   — loop with have_rows() and the_row()
//   └ name        — Text       — returns a plain string
//   └ role        — Text       — returns a plain string
//   └ photo       — Image      — returns [ 'url' => '...', 'alt' => '...' ] or null
//   └ bio         — Textarea   — returns a plain string
//
// Always call the_row() inside the while loop before get_sub_field().
// Without it, get_sub_field() returns null on every iteration.

Step 2: The bare loop. Create template-parts/sections/team-members.php in your theme. This is the minimum code to prove the repeater is working — a loop that reads one field and prints it as plain text.

step-2-bare-loop.php
php
<?php
if ( have_rows( 'team_members' ) ) {
    while ( have_rows( 'team_members' ) ) {
        the_row();
        $name = get_sub_field( 'name' );
        echo $name;
    }
}

Step 3: Read all sub-fields and output HTML. Add the remaining three fields, close PHP before the markup, and reopen it for each dynamic value. esc_html() makes every string safe to display — it converts characters like < and & so they can’t break the HTML.

step-3-html-output.php
php
<?php
if ( have_rows( 'team_members' ) ) {
    ?>
    <ul class="team-list">
    <?php
    while ( have_rows( 'team_members' ) ) {
        the_row();
        $name = get_sub_field( 'name' );
        $role = get_sub_field( 'role' );
        $bio  = get_sub_field( 'bio' );
        ?>
        <li class="team-card">
            <h3><?php echo esc_html( $name ); ?></h3>
            <p><?php echo esc_html( $role ); ?></p>
            <p><?php echo esc_html( $bio ); ?></p>
        </li>
        <?php
    }
    ?>
    </ul>
    <?php
}

Step 4: Add the photo. The Image field returns an array when a photo is uploaded, or null when the field is left empty. The if ( $photo ) check prevents a broken <img> tag when an editor saves a row without a photo.

step-4-with-photo.php
php
<?php
if ( have_rows( 'team_members' ) ) {
    ?>
    <ul class="team-list">
    <?php
    while ( have_rows( 'team_members' ) ) {
        the_row();
        $name  = get_sub_field( 'name' );
        $role  = get_sub_field( 'role' );
        $photo = get_sub_field( 'photo' );
        $bio   = get_sub_field( 'bio' );
        ?>
        <li class="team-card">
            <?php if ( $photo ) { ?>
                <img src="<?php echo esc_url( $photo['url'] ); ?>"
                     alt="<?php echo esc_attr( $photo['alt'] ); ?>">
            <?php } ?>
            <h3><?php echo esc_html( $name ); ?></h3>
            <p class="role"><?php echo esc_html( $role ); ?></p>
            <p class="bio"><?php echo esc_html( $bio ); ?></p>
        </li>
        <?php
    }
    ?>
    </ul>
    <?php
}

Step 5: Everything together. Steps 2–4 are teaching code — each one introduces one concept. This is the complete production file: all steps combined. One addition: an early return when the repeater has no rows, so a blank <ul> never appears on the page.

template-parts/sections/team-members.php
php
<?php
/**
 * Full file: template-parts/sections/team-members.php
 * Steps 2–4 combined — copy this into your theme as the production version.
 */

if ( ! have_rows( 'team_members' ) ) {
    return;
}
?>
<ul class="team-list">
<?php
while ( have_rows( 'team_members' ) ) {
    the_row();
    $name  = get_sub_field( 'name' );
    $role  = get_sub_field( 'role' );
    $photo = get_sub_field( 'photo' );
    $bio   = get_sub_field( 'bio' );
    ?>
    <li class="team-card">
        <?php if ( $photo ) { ?>
            <img src="<?php echo esc_url( $photo['url'] ); ?>"
                 alt="<?php echo esc_attr( $photo['alt'] ); ?>">
        <?php } ?>
        <h3><?php echo esc_html( $name ); ?></h3>
        <p class="role"><?php echo esc_html( $role ); ?></p>
        <p class="bio"><?php echo esc_html( $bio ); ?></p>
    </li>
    <?php
}
?>
</ul>

Where to call this file

From any template in your theme:

get_template_part( 'template-parts/sections/team-members' );

Or drop it directly inside a Flexible Content layout file — have_rows() and get_sub_field() work the same in both contexts.

02 — How it works

How It Works — Why Each Layer Exists

Four PHP layers, each solving a specific problem from the step before it.

1

Step 2 — have_rows() + the_row(), the cursor pattern

Problem solved: we need to loop through each row of the repeater without knowing in advance how many rows there are.

have_rows() does two jobs at once: it checks whether the repeater has data and drives the while loop by returning true once per row. the_row() must be called at the start of each iteration — it moves the internal cursor forward and locks in the current row so get_sub_field() knows which entry to read. Think of it as the ACF equivalent of the_post() inside a standard WordPress loop. Without it, every get_sub_field() call returns null and the loop runs silently blank.

2

Step 3 — read all sub-fields and close PHP for clean HTML

Problem solved: we had one field — now we need all four columns from each row, displayed as real HTML.

Each get_sub_field() call reads one column by its Field Name from the row the_row() just activated. Closing PHP (?>) before the HTML and reopening it (<?php) keeps the markup clean and readable — no echo for large HTML blocks. esc_html() wraps every text value going into the page, converting special characters so they can’t break the HTML or introduce security issues.

3

Step 4 — the image null guard

Problem solved: text fields always return a string, but the Image field can return either a PHP array or null.

When a photo is uploaded, ACF returns an associative array with keys like url, alt, width, and height. When the field is left empty, it returns null. The if ( $photo ) check guards against the null case — skip the block and no broken <img src=""> appears. esc_url() sanitises the URL and esc_attr() sanitises the alt text: both are required any time a dynamic value goes into an HTML attribute.

4

Step 5 — early return stops a blank ul

Problem solved: the template always prints the <ul> wrapper, even when the repeater has zero rows.

The inverted guard — if ( ! have_rows() ) { return; } — exits the file immediately when there is nothing to show. An empty <ul> looks harmless but in a CSS grid or flexbox layout it can create an unexpected gap on the page. Returning early is the cleanest fix: no wrapper, no gap, no markup in the source.

03 — Setup

Setup

One file, one function call.

wp-content/themes/your-theme/
│   └── template-parts/
│   │   └── sections/
            └── team-members.php
1

Save the template file

Create template-parts/sections/ inside your theme if it doesn’t exist yet. Save the Step 5 production code as team-members.php inside that folder.

2

Call it from your template

Call the template from any page template, archive, or Flexible Content layout file:

get_template_part( 'template-parts/sections/team-members' );

WordPress looks for the file relative to your theme root, so the path above resolves to wp-content/themes/your-theme/template-parts/sections/team-members.php.

Field names must match the PHP exactly. The code reads team_members, name, role, photo, and bio. These must match the Field Name you set in ACF — not the Label, not the Key. If you rename a field in ACF, update the matching have_rows() or get_sub_field() call to match.

04 — Making it your own

Making It Your Own

Four common extensions, one silent failure to avoid, and a performance note for large datasets.

1

Rename for any use case — FAQs, testimonials, pricing

The field name team_members is just an example. Rename it to anything — faq_items, testimonials, pricing_tiers, service_list. Replace every occurrence of team_members in the PHP with your field name. Replace the sub-field names (name, role, photo, bio) with your own. The have_rows() / the_row() / get_sub_field() pattern does not change — only the strings change.

2

Skip empty rows with continue

If an editor saves a row without filling in required fields, you get an empty <li> card. Add a continue statement after reading the key field to skip the row entirely:

while ( have_rows( 'team_members' ) ) {
    the_row();

    $name = get_sub_field( 'name' ); // ACF: Text

    if ( ! $name ) {
        continue; // skip this row — the Name field is empty
    }

    // safe to render $name here
}

continue jumps back to the top of the while loop and calls have_rows() again — the empty row is skipped and no blank card appears on the page.

3

Number each row with get_row_index()

get_row_index() returns the current row number, starting at 1. Use it to add a number to each card — useful for ordered lists, step-by-step instructions, or ranked testimonials:

while ( have_rows( 'team_members' ) ) {
    the_row();

    $index = get_row_index(); // 1 for first row, 2 for second, etc.
    $name  = get_sub_field( 'name' ); // ACF: Text
    ?>
    <li class="team-card">
        <span class="team-card__number"><?php echo $index; ?></span>
        <h3><?php echo esc_html( $name ); ?></h3>
    </li>
    <?php
}
4

Target a different post with the post ID parameter

By default, have_rows() reads from the current post in the loop. Pass a post ID as a second argument to target a different post — for example, a global “About” page whose team list you want to show on the homepage:

<?php
// Pass the post ID as a second argument to target a different post's repeater.
$target_id = 42; // change to the ID of the page that has this repeater

if ( have_rows( 'team_members', $target_id ) ) {
    while ( have_rows( 'team_members', $target_id ) ) {
        the_row();
        // get_sub_field() still reads from the active row — no post ID needed here
        $name = get_sub_field( 'name' ); // ACF: Text
        echo esc_html( $name );
    }
}

Note: get_sub_field() does not need the post ID — it always reads from the row that the_row() just activated, regardless of which post the repeater belongs to.

The most common ACF Repeater mistake: forgetting the_row().

Inside the while loop, every call to get_sub_field() depends on the_row() having been called first. the_row() moves the internal cursor to the current row — without it, the cursor never advances, and get_sub_field() returns null on every iteration. The loop still runs the correct number of times, but every row renders as blank. There is no PHP error — it just silently outputs nothing.

Fix: make sure the_row(); is the very first line inside your while ( have_rows() ) block.

For large repeaters (50+ rows): use get_field() instead.

have_rows() fetches rows one at a time using an internal cursor — on a repeater with 200 rows, that can mean many small database reads. get_field() fetches all rows in one query and returns them as a plain PHP array. Loop it with foreach:

<?php
// get_field() returns all rows at once as a PHP array of associative arrays.
// Each $row is an array with keys matching your sub-field names.
$rows = get_field( 'team_members' );

if ( $rows ) {
    foreach ( $rows as $row ) {
        echo esc_html( $row['name'] );
        echo esc_html( $row['role'] );
    }
}

Each $row is an associative array — use $row['name'] instead of get_sub_field( 'name' ). The output is identical; only the data retrieval method changes.

0 comments

No comments yet. Be the first.

Leave a Reply

What Is ACF repeater field PHP?

The ACF repeater field PHP pattern is how WordPress developers loop repeating content — team members, FAQs, testimonials, pricing rows — without hardcoding any HTML. This ACF repeater field PHP snippet builds the loop in five steps, starting with field creation in the ACF admin and ending with a production-ready template file you can drop into any page. Step one creates the field group: a Repeater called team_members with four sub-fields — Name (Text), Role (Text), Photo (Image, Return Format Array), and Bio (Textarea). Assign it to Pages and save. The complete Repeater field reference is at advancedcustomfields.com/resources/repeater.

ACF repeater field PHP Loop Structure

The ACF repeater field PHP loop uses three functions. have_rows() checks whether the repeater has data and drives the while loop. the_row() activates the current row so get_sub_field() knows which entry to read. get_sub_field() reads one column by its field name from the active row. The have_rows() function parameters and return values are documented at advancedcustomfields.com/resources/have_rows. Steps two through five write the PHP progressively, one concept at a time, building up to the full production template.

ACF repeater field PHP — The Most Common Mistake

The most common mistake in any ACF repeater field PHP loop: forgetting the_row() inside the while block. Every get_sub_field() call returns null, the loop runs silently, and nothing appears on the page — no PHP error is thrown. Always put the_row() as the first line inside the while loop. This is the single biggest ACF repeater field PHP debugging tip: if your loop renders nothing and no error appears, check for a missing the_row() call first.

ACF repeater field PHP with Image Sub-Field

The Image sub-field in an ACF repeater field PHP loop returns a PHP array when a photo is uploaded or null when the field is empty. The if ( $photo ) check prevents a broken img tag. All dynamic values are escaped with esc_html(), esc_url(), or esc_attr() before going into the HTML. For datasets with 50 or more rows, get_field() returns all rows at once as a PHP array — more efficient than the row-by-row cursor approach used by have_rows().

ACF repeater field PHP Inside Flexible Content

The ACF repeater field PHP pattern works identically inside a Flexible Content layout or as a standalone field group on any post type. Tested on WordPress 6.x, ACF 6.x, PHP 8.3. This ACF repeater field PHP pattern is used across multiple client projects for team member lists, FAQ sections, and pricing tables. Browse more ACF patterns in the full WordPress code snippets collection.

Chat on WhatsApp