Repeater and Flexible Content Render Loops
Loop-heavy templates remain stable only when row traversal, layout dispatching, and empty-state guards are designed intentionally.
You will implement robust Repeater and Flexible Content loop patterns, isolate layouts into dedicated renderers, and validate row/layout payloads with WP-CLI to prevent runtime regressions.
Concept Overview
Repeater loops are deterministic: each row has the same subfield shape. Flexible Content loops are polymorphic: each row can represent a different layout and payload shape. That difference is the root of most template complexity in ACF Pro projects.
A scalable pattern keeps loops thin and delegates markup to focused functions or partials. The loop does selection and guard logic; the renderer handles output. This separation improves testability, reduces merge conflicts, and keeps unknown layouts visible via logging.
Without these boundaries, teams often accumulate giant template files where every layout branch manipulates data and markup inline. Small schema changes then trigger broad regressions because no one can confidently reason about loop behavior under partial or malformed payloads.
Treat loops as dispatch engines: validate row/layout state, route to dedicated renderers, and always handle unknown or empty data paths explicitly.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
Guard have_rows() and row payloads before rendering | Empty and malformed rows are handled predictably | Cleaner output and fewer warnings |
| Dispatch Flexible layouts to dedicated renderers | Layout logic is isolated and maintainable | Faster updates and safer releases |
| Include unknown-layout default branch with logs | Schema drift is visible immediately | Reduced debugging time during incidents |
| Keep row-processing limits for heavy pages | Prevents excessive rendering work | Better performance under large editorial payloads |
| Inline everything in one template (wrong pattern) | High cognitive load and fragile edits | Frequent regressions and slower delivery |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
have_rows() | `have_rows(string $selector, int | string $post_id = false): bool` | Check row availability and control loop lifecycle |
the_row() | the_row(): void | Advance current row pointer | Call inside while (have_rows(...)) |
get_sub_field() | get_sub_field(string $selector, bool $format_value = true): mixed | Read subfield from current row | Escape and guard by context |
get_row_layout() | `get_row_layout(): string | false` | Resolve current Flexible layout slug |
| Repeater config | 'type' => 'repeater', 'sub_fields' => [...] | Define fixed-shape row collections | Use for FAQ lists, stats rows, link sets |
| Flexible config | 'type' => 'flexible_content', 'layouts' => [...] | Define variable layout rows | Use when row types differ significantly |
wp eval payload audit | wp eval '$rows=get_field("field",<id>); ...' | Verify row count and layout map | Add to release smoke checks |
error_log() in default branch | error_log('[acf-flex] unknown layout=' . $layout) | Detect unexpected layouts in production | Critical for schema drift observability |
Practical Use Cases
Use Case 1 — Render FAQ accordion from Repeater rows with robust guards
A support team updates FAQ content weekly. You need an editor-friendly Repeater structure and a stable frontend accordion component that ignores incomplete rows.
- Register FAQ Repeater field in code.
- Add question/answer subfields with required flags.
- Render rows only when both values are present.
- Use safe output escaping for summary/body contexts.
- Validate row payload and count from CLI.
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
acf_add_local_field_group([
'key' => 'group_faq_loop_model',
'title' => 'FAQ Loop Model',
'fields' => [
[
'key' => 'field_faq_items',
'label' => 'FAQ Items',
'name' => 'faq_items',
'type' => 'repeater',
'layout' => 'row',
'button_label' => 'Add FAQ Row',
'sub_fields' => [
[
'key' => 'field_faq_question',
'label' => 'Question',
'name' => 'faq_question',
'type' => 'text',
'required' => 1,
],
[
'key' => 'field_faq_answer',
'label' => 'Answer',
'name' => 'faq_answer',
'type' => 'textarea',
'required' => 1,
],
],
],
],
'location' => [[[
'param' => 'post_type',
'operator' => '==',
'value' => 'page',
]]],
]);
});
add_action('template_redirect', function (): void {
if (!is_page()) {
return;
}
if (!have_rows('faq_items')) {
return;
}
echo '<section class="faq-list" aria-label="Frequently asked questions">';
while (have_rows('faq_items')) {
the_row();
$question = (string) get_sub_field('faq_question');
$answer = (string) get_sub_field('faq_answer');
if ($question === '' || $answer === '') {
continue;
}
echo '<details class="faq-item">';
echo '<summary>' . esc_html($question) . '</summary>';
echo '<div>' . wp_kses_post($answer) . '</div>';
echo '</details>';
}
echo '</section>';
});
wp eval '$id=1360; update_field("faq_items", [["faq_question"=>"How long is onboarding?","faq_answer"=>"Usually 14 days."],["faq_question"=>"Is support included?","faq_answer"=>"Yes, weekdays."]], $id); $rows=get_field("faq_items",$id); echo count((array)$rows) . PHP_EOL; echo $rows[0]["faq_question"] . PHP_EOL;'
wp post meta get 1360 faq_items
2
How long is onboarding?
a:2:{...}
Row-level guards allow editors to draft partial entries without breaking frontend markup.
Use Case 2 — Dispatch Flexible layouts into dedicated renderer map
A landing-page system supports three approved layouts (hero, logos, cta). Marketing can reorder layouts, but engineering wants strict rendering control and observability for unknown slugs.
- Register Flexible field with approved layout slugs.
- Implement layout renderer functions.
- Build a dispatcher map from slug to callable.
- Use default logging path for unknown layouts.
- Audit layout sequence via CLI before release.
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
acf_add_local_field_group([
'key' => 'group_landing_flexible_loop',
'title' => 'Landing Flexible Loop',
'fields' => [
[
'key' => 'field_page_sections',
'label' => 'Page Sections',
'name' => 'page_sections',
'type' => 'flexible_content',
'button_label' => 'Add Section',
'layouts' => [
[
'key' => 'layout_hero',
'name' => 'hero',
'label' => 'Hero',
'display' => 'block',
'sub_fields' => [
['key' => 'field_hero_title', 'label' => 'Hero Title', 'name' => 'hero_title', 'type' => 'text'],
],
],
[
'key' => 'layout_logos',
'name' => 'logos',
'label' => 'Logos',
'display' => 'block',
'sub_fields' => [
['key' => 'field_logo_ids', 'label' => 'Logo IDs', 'name' => 'logo_ids', 'type' => 'textarea'],
],
],
[
'key' => 'layout_cta',
'name' => 'cta',
'label' => 'CTA',
'display' => 'block',
'sub_fields' => [
['key' => 'field_cta_label', 'label' => 'CTA Label', 'name' => 'cta_label', 'type' => 'text'],
['key' => 'field_cta_url', 'label' => 'CTA URL', 'name' => 'cta_url', 'type' => 'url'],
],
],
],
],
],
'location' => [[[
'param' => 'post_type',
'operator' => '==',
'value' => 'page',
]]],
]);
});
function clinic_render_layout_hero(): void
{
$title = (string) get_sub_field('hero_title');
if ($title !== '') {
echo '<section class="hero"><h2>' . esc_html($title) . '</h2></section>';
}
}
function clinic_render_layout_logos(): void
{
$raw = (string) get_sub_field('logo_ids');
$ids = array_filter(array_map('intval', array_map('trim', explode(',', $raw))));
if (count($ids) === 0) {
return;
}
echo '<section class="logos">';
foreach ($ids as $id) {
echo wp_get_attachment_image($id, 'medium');
}
echo '</section>';
}
function clinic_render_layout_cta(): void
{
$label = (string) get_sub_field('cta_label');
$url = (string) get_sub_field('cta_url');
if ($label !== '' && $url !== '') {
echo '<section class="cta"><a href="' . esc_url($url) . '">' . esc_html($label) . '</a></section>';
}
}
add_action('template_redirect', function (): void {
if (!is_page() || !have_rows('page_sections')) {
return;
}
$dispatch = [
'hero' => 'clinic_render_layout_hero',
'logos' => 'clinic_render_layout_logos',
'cta' => 'clinic_render_layout_cta',
];
while (have_rows('page_sections')) {
the_row();
$layout = (string) get_row_layout();
if (!isset($dispatch[$layout])) {
error_log('[flex-dispatch] unknown layout=' . $layout . ' page=' . get_the_ID());
continue;
}
call_user_func($dispatch[$layout]);
}
});
wp eval '$id=1360; $rows=[["acf_fc_layout"=>"hero","hero_title"=>"ACF Loop Hero"],["acf_fc_layout"=>"cta","cta_label"=>"Book Now","cta_url"=>"/book"]]; update_field("page_sections",$rows,$id); $saved=get_field("page_sections",$id); echo count((array)$saved) . PHP_EOL; foreach((array)$saved as $row){echo $row["acf_fc_layout"] . PHP_EOL;}'
2
hero
cta
Flexible Content render loops and layout dispatching depend on ACF Pro.
Use Case 3 — Edge case: unknown layouts and oversized row payloads
After a content migration, some pages contain an unapproved pricing_grid layout and 60+ section rows. Fragile templates crash or degrade. Robust loops enforce row limits and log unknown layouts.
❌ Fragile Pattern
<?php
declare(strict_types=1);
add_action('template_redirect', function (): void {
while (have_rows('page_sections')) {
the_row();
$layout = get_row_layout();
include locate_template('partials/layout-' . $layout . '.php');
}
});
✅ Robust Pattern
<?php
declare(strict_types=1);
add_action('template_redirect', function (): void {
if (!is_page() || !have_rows('page_sections')) {
return;
}
$allowedLayouts = ['hero', 'logos', 'cta'];
$maxRows = 25;
$rowIndex = 0;
while (have_rows('page_sections')) {
the_row();
$rowIndex++;
if ($rowIndex > $maxRows) {
error_log('[loop-robust] row_limit_exceeded page=' . get_the_ID() . ' rows=' . $rowIndex);
break;
}
$layout = (string) get_row_layout();
if (!in_array($layout, $allowedLayouts, true)) {
error_log('[loop-robust] unknown_layout=' . $layout . ' page=' . get_the_ID());
continue;
}
$templatePath = locate_template('partials/layout-' . $layout . '.php');
if ($templatePath === '') {
error_log('[loop-robust] missing_partial=' . $layout . ' page=' . get_the_ID());
continue;
}
include $templatePath;
}
});
wp eval '$rows=get_field("page_sections",1360); echo "rows=" . count((array)$rows) . PHP_EOL; $allowed=["hero","logos","cta"]; $unknown=[]; foreach((array)$rows as $row){$l=$row["acf_fc_layout"] ?? ""; if($l!=="" && !in_array($l,$allowed,true)){$unknown[]=$l;}} print_r($unknown);'
wp eval '$rows=get_field("page_sections",1360); echo (count((array)$rows) > 25 ? "over_limit" : "within_limit") . PHP_EOL;'
rows=2
Array
(
)
within_limit
Row limits are not arbitrary. They are a practical guardrail against accidental payload explosions during migrations or bulk imports.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
Rendering rows without have_rows() guard | Assumes rows always exist | Empty wrappers, warnings, and noisy logs | Guard early and return fast |
| No default handling for unknown layouts | Layout list treated as static forever | New layouts silently fail to render | Add default branch and log unknown slugs |
| Inline branching and markup in one giant loop | No separation of concerns | Hard reviews and regression-prone changes | Use dispatcher map + dedicated renderers |
| No row-count limits on heavy pages | Unbounded editor payload growth | Performance degradation and timeout risk | Enforce max rows and log overflow |
| Missing empty checks for subfields | Partial rows produce broken UI | Blank summaries, empty CTAs, malformed sections | Validate required subfields per row |
| Skipping CLI loop audits | Loop regressions found too late | Production troubleshooting overhead | Add payload count/layout checks in release workflow |
Deep Dive: Why Flexible Loop Bugs Feel Intermittent
Flexible loop bugs are data-dependent, so only specific pages trigger them. A page with two approved layouts may render perfectly while another with one migrated unknown layout fails. This causes false confidence during spot checks. The fix is to inspect saved layout arrays for all high-traffic pages and compare them to your allowed layout list. Add CLI audits to release criteria so unknown slugs are discovered before users do.
wp eval '$rows=get_field("page_sections",1360); foreach((array)$rows as $i=>$row){echo "row=".($i+1)." layout=".($row["acf_fc_layout"] ?? "missing") . PHP_EOL;}'
Best Practices
- Use guard clauses first (
is_page,have_rows) to keep loops predictable. - Dispatch Flexible layouts by slug-to-renderer map, not giant inline conditionals.
- Define and enforce allowed layout slugs in one shared constant/source.
- Log unknown layouts and missing partials explicitly with post IDs.
- Validate required subfields before outputting row wrappers.
- Set practical row limits for large editorial sections.
- Run CLI audits for row counts and layout slugs before each release.
Hands-On Practice
Exercise 1: Register and populate FAQ Repeater rows
Create wp-content/themes/clinic-pro/inc/acf/faq-loop-model.php and run:
wp eval '$id=1360; update_field("faq_items", [["faq_question"=>"Q1","faq_answer"=>"A1"],["faq_question"=>"Q2","faq_answer"=>"A2"]], $id); echo count((array)get_field("faq_items",$id)) . PHP_EOL;'
After completing this exercise, output should be:
2
Exercise 2: Register Flexible sections and store layout sequence
Run:
wp eval '$id=1360; update_field("page_sections", [["acf_fc_layout"=>"hero","hero_title"=>"Loop Hero"],["acf_fc_layout"=>"cta","cta_label"=>"Book","cta_url"=>"/book"]], $id); $rows=get_field("page_sections",$id); echo count((array)$rows) . PHP_EOL;'
After completing this exercise, output should be:
2
Exercise 3: Audit layout slugs against allowed map
Run:
wp eval '$allowed=["hero","logos","cta"]; $rows=get_field("page_sections",1360); $unknown=[]; foreach((array)$rows as $row){$layout=$row["acf_fc_layout"] ?? ""; if($layout!=="" && !in_array($layout,$allowed,true)){$unknown[]=$layout;}} print_r($unknown);'
After completing this exercise, output should be:
Array
(
)
Exercise 4: Simulate unknown layout and verify detection
Run:
wp eval '$id=1360; update_field("page_sections", [["acf_fc_layout"=>"hero","hero_title"=>"Loop Hero"],["acf_fc_layout"=>"pricing_grid"]], $id);'
wp eval '$allowed=["hero","logos","cta"]; $rows=get_field("page_sections",1360); $unknown=[]; foreach((array)$rows as $row){$layout=$row["acf_fc_layout"] ?? ""; if($layout!=="" && !in_array($layout,$allowed,true)){$unknown[]=$layout;}} print_r($unknown);'
After completing this exercise, output should include:
Array
(
[0] => pricing_grid
)
Exercise 5: Restore approved layouts and run row-limit check
Run:
wp eval '$id=1360; update_field("page_sections", [["acf_fc_layout"=>"hero","hero_title"=>"Loop Hero"],["acf_fc_layout"=>"logos","logo_ids"=>"1,2"],["acf_fc_layout"=>"cta","cta_label"=>"Book","cta_url"=>"/book"]], $id); $rows=get_field("page_sections",$id); echo (count((array)$rows) > 25 ? "over_limit" : "within_limit") . PHP_EOL;'
After completing this exercise, output should be:
within_limit
CLI Reference
| Command | Purpose | Real Example Output |
|---|---|---|
wp eval '$rows=get_field("faq_items",1360); echo count((array)$rows) . PHP_EOL;' | Count FAQ Repeater rows | 2 |
wp eval '$rows=get_field("faq_items",1360); echo $rows[0]["faq_question"] . PHP_EOL;' | Inspect first Repeater row | How long is onboarding? |
wp eval '$rows=get_field("page_sections",1360); foreach((array)$rows as $row){echo $row["acf_fc_layout"] . PHP_EOL;}' | Print Flexible layout sequence | hero cta |
wp eval '$allowed=["hero","logos","cta"]; ... print_r($unknown);' | Detect unknown layout slugs | Array ( ) |
wp post meta get 1360 page_sections | Inspect raw Flexible payload storage | a:3:{...} |
wp eval '$rows=get_field("page_sections",1360); echo count((array)$rows) > 25 ? "over_limit" : "within_limit";' | Enforce row-limit policy | within_limit |
wp eval '$rows=get_field("page_sections",1360); print_r($rows[0]);' | Debug first layout payload shape | Array ( [acf_fc_layout] => hero ... ) |
| `wp plugin list --status=active | grep advanced-custom-fields-pro` | Confirm ACF Pro dependency |
What's Next
- Continue to Conditional Rendering, Fallbacks, and Empty States.
- Return to Module 3 Overview for full rendering strategy context.
- Related lesson: Repeater, Group, Clone, and Flexible Content Patterns.
- Related lesson: ACF Blocks Core Concepts.
Revisit this lesson whenever editors request new layout variants, because loop architecture quality determines whether those changes are easy or dangerous.