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.
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.
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:
- 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
- Text — Label:
Assign the field group to Pages. Save.
<?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.
<?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.
<?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.
<?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.
<?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.
How It Works — Why Each Layer Exists
Four PHP layers, each solving a specific problem from the step before it.
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.
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.
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.
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.
Setup
One file, one function call.
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.
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.
Making It Your Own
Four common extensions, one silent failure to avoid, and a performance note for large datasets.
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.
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.
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
}
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.
No comments yet. Be the first.