Repeater, Group, Clone, and Flexible Content Patterns
ACF Pro structural fields become an advantage only when you model clear contracts and enforce safe rendering behavior.
You will learn how to choose between Group, Repeater, Clone, and Flexible Content, implement production-safe render loops, and validate row/layout integrity with WP-CLI. This matters because structural fields are where ACF projects either become scalable or become impossible to maintain.
Concept Overview
Group, Repeater, Clone, and Flexible Content solve different structural problems. Group models one fixed object. Repeater models a list of similar objects. Clone lets you reuse schema fragments. Flexible Content models variable layout composition where row types can differ.
The biggest mistake is to treat these as interchangeable. They are not. Repeater adds loop complexity. Flexible Content adds branching complexity. Clone adds dependency complexity. The right choice depends on whether your data shape is fixed, repeated, or polymorphic.
In mature ACF Pro projects, these field types often appear together: a Flexible Content layout may include a cloned CTA object and one nested repeater. That can be powerful, but it requires strict rendering guards, clear layout naming, and CLI audits to avoid silent breakage.
Pick the least complex structural field that matches the data shape, then enforce robust render guards for empty, unknown, or malformed rows.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
| Group for fixed blocks, Repeater for repeated items | Rendering logic stays predictable | Lower defect rate and easier onboarding |
| Flexible Content used only where layouts truly vary | Layout logic remains intentional | Better maintainability and faster QA cycles |
| Clone shared CTA/metadata fragments | Schema reuse without copy-paste drift | Consistent behavior across templates |
| Guard unknown layouts and empty rows explicitly | Errors are visible instead of silent | Faster incident triage and safer releases |
| Deep uncontrolled nesting (wrong pattern) | Query and render complexity explodes | Slow pages, fragile templates, expensive refactors |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
| Group field | 'type' => 'group', 'sub_fields' => [...] | Model one fixed object with named properties | ACF Pro Required |
| Repeater field | 'type' => 'repeater', 'sub_fields' => [...] | Model ordered rows of same shape | ACF Pro Required |
| Clone field | 'type' => 'clone', 'clone' => ['field_xxx'] | Reuse existing field definitions | ACF Pro Required |
| Flexible Content | 'type' => 'flexible_content', 'layouts' => [...] | Compose pages from variable row layouts | ACF Pro Required |
have_rows() | `have_rows(string $selector, int | string $post_id = false): bool` | Iterate rows in Repeater/Flexible |
the_row() | the_row(): void | Move loop pointer to current row | Must be called inside while (have_rows(...)) |
get_sub_field() | get_sub_field(string $selector, bool $format_value = true): mixed | Read row sub-field | Prefer null/empty guards in templates |
get_row_layout() | `get_row_layout(): string | false` | Resolve active Flexible layout slug |
acf_get_field_groups() | acf_get_field_groups(array $filter = []): array | Runtime model audit | Pair with acf_get_fields() in CLI checks |
All four structural field types covered in this lesson (Group, Repeater, Clone, Flexible Content) require ACF Pro.
Practical Use Cases
Use Case 1 — Build a service FAQ model with Group + Repeater and strict output guards
A clinic service page uses one hero object and a list of FAQs. You need fixed hero structure plus repeated Q/A rows, with safe rendering when some rows are incomplete.
- Register Group field for service hero object.
- Register Repeater for FAQ rows.
- Render Group values only when required keys exist.
- Loop FAQ rows with
have_rows()and skip incomplete entries. - Verify model shape and row payload via CLI.
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
acf_add_local_field_group([
'key' => 'group_service_structural_model',
'title' => 'Service Structural Model',
'fields' => [
[
'key' => 'field_service_hero_group',
'label' => 'Service Hero Group',
'name' => 'service_hero_group',
'type' => 'group',
'layout' => 'block',
'sub_fields' => [
[
'key' => 'field_service_hero_headline',
'label' => 'Hero Headline',
'name' => 'service_hero_headline',
'type' => 'text',
'required' => 1,
],
[
'key' => 'field_service_hero_summary',
'label' => 'Hero Summary',
'name' => 'service_hero_summary',
'type' => 'textarea',
],
],
],
[
'key' => 'field_service_faq_items',
'label' => 'Service FAQ Items',
'name' => 'service_faq_items',
'type' => 'repeater',
'layout' => 'row',
'button_label' => 'Add FAQ',
'sub_fields' => [
[
'key' => 'field_service_faq_question',
'label' => 'Question',
'name' => 'service_faq_question',
'type' => 'text',
'required' => 1,
],
[
'key' => 'field_service_faq_answer',
'label' => 'Answer',
'name' => 'service_faq_answer',
'type' => 'textarea',
'required' => 1,
],
],
],
],
'location' => [[[
'param' => 'page_template',
'operator' => '==',
'value' => 'template-service.php',
]]],
]);
});
add_action('template_redirect', function (): void {
if (!is_page_template('template-service.php')) {
return;
}
$hero = get_field('service_hero_group');
if (is_array($hero) && !empty($hero['service_hero_headline'])) {
echo '<h1>' . esc_html((string) $hero['service_hero_headline']) . '</h1>';
echo '<p>' . esc_html((string) ($hero['service_hero_summary'] ?? '')) . '</p>';
}
if (have_rows('service_faq_items')) {
echo '<section class="service-faq">';
while (have_rows('service_faq_items')) {
the_row();
$question = (string) get_sub_field('service_faq_question');
$answer = (string) get_sub_field('service_faq_answer');
if ($question === '' || $answer === '') {
continue;
}
echo '<details><summary>' . esc_html($question) . '</summary><div>' . wp_kses_post($answer) . '</div></details>';
}
echo '</section>';
}
});
wp post create --post_title="Structural FAQ Drill" --post_status=publish --post_type=page
wp eval '$id=(int)get_page_by_title("Structural FAQ Drill", OBJECT, "page")->ID; update_field("service_hero_group", ["service_hero_headline"=>"Laser Treatment Plans","service_hero_summary"=>"Evidence-based protocols"], $id); update_field("service_faq_items", [["service_faq_question"=>"Is it painful?","service_faq_answer"=>"Usually mild discomfort."],["service_faq_question"=>"Downtime?","service_faq_answer"=>"Minimal."]], $id); echo get_field("service_hero_group",$id)["service_hero_headline"] . PHP_EOL;'
wp eval '$id=(int)get_page_by_title("Structural FAQ Drill", OBJECT, "page")->ID; $rows=get_field("service_faq_items",$id); echo count($rows) . PHP_EOL; echo $rows[0]["service_faq_question"] . PHP_EOL;'
Success: Created post 1345.
Laser Treatment Plans
2
Is it painful?
Group + Repeater is ideal when one section has fixed metadata and one section has repeated rows.
Use Case 2 — Compose landing pages with Flexible Content + Clone CTA fragment
A growth team needs reusable page layouts where each landing page can combine Hero, Logos, and Testimonial sections in different orders. CTA fields must stay consistent across layouts.
- Register a shared CTA fragment field group.
- Register Flexible Content layouts for
hero_section,logos_section, andtestimonial_section. - Clone CTA fragment into relevant layouts.
- Render layouts via
switch (get_row_layout())with strict guards. - Verify layout map and cloned payload through CLI.
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
acf_add_local_field_group([
'key' => 'group_shared_cta_fragment',
'title' => 'Shared CTA Fragment',
'fields' => [
[
'key' => 'field_shared_cta_label',
'label' => 'CTA Label',
'name' => 'shared_cta_label',
'type' => 'text',
],
[
'key' => 'field_shared_cta_url',
'label' => 'CTA URL',
'name' => 'shared_cta_url',
'type' => 'url',
],
],
'location' => [[[
'param' => 'options_page',
'operator' => '==',
'value' => 'acf-options-global-cta',
]]],
]);
acf_add_local_field_group([
'key' => 'group_landing_sections',
'title' => 'Landing Sections',
'fields' => [
[
'key' => 'field_landing_sections',
'label' => 'Landing Sections',
'name' => 'landing_sections',
'type' => 'flexible_content',
'button_label' => 'Add Section',
'layouts' => [
[
'key' => 'layout_hero_section',
'name' => 'hero_section',
'label' => 'Hero Section',
'display' => 'block',
'sub_fields' => [
[
'key' => 'field_hero_title',
'label' => 'Hero Title',
'name' => 'hero_title',
'type' => 'text',
],
[
'key' => 'field_hero_cta_clone',
'label' => 'Hero CTA',
'name' => 'hero_cta_clone',
'type' => 'clone',
'clone' => ['field_shared_cta_label', 'field_shared_cta_url'],
'display' => 'seamless',
],
],
],
[
'key' => 'layout_logos_section',
'name' => 'logos_section',
'label' => 'Logos Section',
'display' => 'block',
'sub_fields' => [
[
'key' => 'field_logos_repeater',
'label' => 'Logos',
'name' => 'logos_repeater',
'type' => 'repeater',
'sub_fields' => [
[
'key' => 'field_logo_image_id',
'label' => 'Image ID',
'name' => 'logo_image_id',
'type' => 'number',
],
],
],
],
],
[
'key' => 'layout_testimonial_section',
'name' => 'testimonial_section',
'label' => 'Testimonial Section',
'display' => 'block',
'sub_fields' => [
[
'key' => 'field_testimonial_quote',
'label' => 'Quote',
'name' => 'testimonial_quote',
'type' => 'textarea',
],
],
],
],
],
],
'location' => [[[
'param' => 'post_type',
'operator' => '==',
'value' => 'page',
]]],
]);
});
add_action('template_redirect', function (): void {
if (!is_page()) {
return;
}
if (!have_rows('landing_sections')) {
return;
}
while (have_rows('landing_sections')) {
the_row();
$layout = get_row_layout();
switch ($layout) {
case 'hero_section':
$title = (string) get_sub_field('hero_title');
$ctaLabel = (string) get_sub_field('shared_cta_label');
$ctaUrl = (string) get_sub_field('shared_cta_url');
if ($title !== '') {
echo '<section class="hero"><h2>' . esc_html($title) . '</h2>';
if ($ctaLabel !== '' && $ctaUrl !== '') {
echo '<a href="' . esc_url($ctaUrl) . '">' . esc_html($ctaLabel) . '</a>';
}
echo '</section>';
}
break;
case 'logos_section':
echo '<section class="logos"></section>';
break;
case 'testimonial_section':
$quote = (string) get_sub_field('testimonial_quote');
if ($quote !== '') {
echo '<blockquote>' . esc_html($quote) . '</blockquote>';
}
break;
default:
error_log('[landing-flex] unknown layout=' . (string) $layout);
}
}
});
wp eval '$rows=[["acf_fc_layout"=>"hero_section","hero_title"=>"Clinic Hero","shared_cta_label"=>"Book Now","shared_cta_url"=>"/book"],["acf_fc_layout"=>"testimonial_section","testimonial_quote"=>"Great outcomes."]]; update_field("landing_sections",$rows,1345); $saved=get_field("landing_sections",1345); echo count((array)$saved) . PHP_EOL; echo $saved[0]["acf_fc_layout"] . PHP_EOL;'
wp eval '$rows=get_field("landing_sections",1345); foreach((array)$rows as $row){echo $row["acf_fc_layout"] . PHP_EOL;}'
2
hero_section
hero_section
testimonial_section
Flexible Content and Clone patterns shown here are ACF Pro features.
Use Case 3 — Edge case: unguarded nested structures crash or degrade rendering
A team introduces nested repeaters and new layouts without updating template guards. Unknown layout slugs and empty rows begin hitting production logs, and pages intermittently break.
❌ Fragile Pattern
<?php
declare(strict_types=1);
add_action('template_redirect', function (): void {
if (!have_rows('landing_sections')) {
return;
}
while (have_rows('landing_sections')) {
the_row();
$layout = get_row_layout();
if ($layout === 'hero_section') {
echo '<h2>' . get_sub_field('hero_title') . '</h2>';
}
if ($layout === 'logos_section') {
$logos = get_sub_field('logos_repeater');
foreach ($logos as $logo) {
echo wp_get_attachment_image((int) $logo['logo_image_id'], 'medium');
}
}
}
});
✅ Robust Pattern
<?php
declare(strict_types=1);
add_action('template_redirect', function (): void {
if (!is_page() || !have_rows('landing_sections')) {
return;
}
$maxRows = 20;
$processed = 0;
while (have_rows('landing_sections')) {
the_row();
$processed++;
if ($processed > $maxRows) {
error_log('[flex-robust] row limit exceeded on page=' . get_the_ID());
break;
}
$layout = (string) get_row_layout();
switch ($layout) {
case 'hero_section':
$title = (string) get_sub_field('hero_title');
if ($title !== '') {
echo '<section class="hero"><h2>' . esc_html($title) . '</h2></section>';
}
break;
case 'logos_section':
$logos = get_sub_field('logos_repeater');
if (!is_array($logos) || count($logos) === 0) {
continue 2;
}
echo '<section class="logos">';
foreach ($logos as $logo) {
$id = (int) ($logo['logo_image_id'] ?? 0);
if ($id > 0) {
echo wp_get_attachment_image($id, 'medium');
}
}
echo '</section>';
break;
case 'testimonial_section':
$quote = (string) get_sub_field('testimonial_quote');
if ($quote !== '') {
echo '<blockquote>' . esc_html($quote) . '</blockquote>';
}
break;
default:
error_log('[flex-robust] unknown layout=' . $layout . ' page=' . get_the_ID());
}
}
});
wp eval '$rows=get_field("landing_sections",1345); echo "rows=" . count((array)$rows) . PHP_EOL; foreach((array)$rows as $i=>$row){echo "#".($i+1).":" . ($row["acf_fc_layout"] ?? "missing") . PHP_EOL;}'
wp eval '$rows=get_field("landing_sections",1345); $known=["hero_section","logos_section","testimonial_section"]; $unknown=[]; foreach((array)$rows as $row){$layout=$row["acf_fc_layout"] ?? ""; if($layout!=="" && !in_array($layout,$known,true)){$unknown[]=$layout;}} print_r($unknown);'
rows=2
#1:hero_section
#2:testimonial_section
Array
(
)
Flexible Content requires explicit unknown-layout handling. Silent failure here can hide schema drift for weeks.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
| Using Flexible Content where Repeater is enough | Overestimating layout variability | Unnecessary branching and maintenance cost | Use Repeater when row shape is fixed |
| Missing default branch in layout switch | Assumes layout list never changes | New layout silently not rendered | Add default with logging and CLI unknown-layout audit |
| Deep nested repeaters without limits | No complexity budget for content model | Slow rendering and high memory usage | Enforce row caps and split sections |
| Cloning shared fragments without naming clarity | Clone source ambiguous to developers | Misconfigured fields and broken retrieval | Prefix clone-related names and document source keys |
| Rendering subfields without empty checks | Assumes all rows are complete | Broken markup and blank wrappers | Guard each critical subfield before output |
| No CLI payload verification before release | Reliance on editor spot checks | Runtime data shape mismatches go unnoticed | Add wp eval row/layout audits in release checklist |
Deep Dive: Why Flexible Content Failures Often Look Random
Flexible Content failures usually surface as intermittent rendering bugs because only specific pages contain problematic layout combinations. One page may work perfectly while another breaks due to an unknown layout slug or incomplete row payload. These defects evade simple smoke tests that check only one sample page. The solution is to audit saved layout arrays directly and compare them to your template switch coverage. Treat layout slugs as contracts and validate them through CLI before deploy.
wp eval '$rows=get_field("landing_sections",1345); $known=["hero_section","logos_section","testimonial_section"]; foreach((array)$rows as $row){$layout=$row["acf_fc_layout"] ?? "missing"; echo $layout . (in_array($layout,$known,true) ? " [ok]" : " [unknown]") . PHP_EOL;}'
Best Practices
- Model with the simplest structural type first: Group/Repeater before Flexible when possible.
- Keep layout slugs stable and semantic:
hero_section,logos_section,testimonial_section. - Always guard empty rows and required subfields: avoid emitting empty wrappers.
- Include default branch logging in every Flexible switch: unknown layouts should be visible.
- Use Clone for repeated schema fragments, not copy-paste duplication.
- Set pragmatic row limits for heavy sections and enforce them in render code.
- Run CLI audits for row counts and layout maps before each release.
Hands-On Practice
Exercise 1: Register Group + Repeater service model
Create wp-content/themes/clinic-pro/inc/acf/service-structural-model.php and run:
wp eval '$g=acf_get_field_groups(["key"=>"group_service_structural_model"]); echo count($g) . PHP_EOL;'
After completing this exercise, output should be:
1
Exercise 2: Seed FAQ rows and verify row count
Run:
wp eval '$id=1345; update_field("service_faq_items", [["service_faq_question"=>"Q1","service_faq_answer"=>"A1"],["service_faq_question"=>"Q2","service_faq_answer"=>"A2"]], $id); $rows=get_field("service_faq_items",$id); echo count($rows) . PHP_EOL;'
After completing this exercise, output should be:
2
Exercise 3: Register Flexible model and store two layouts
Run:
wp eval '$id=1345; $rows=[["acf_fc_layout"=>"hero_section","hero_title"=>"Demo Hero","shared_cta_label"=>"Book","shared_cta_url"=>"/book"],["acf_fc_layout"=>"testimonial_section","testimonial_quote"=>"Excellent service."]]; update_field("landing_sections",$rows,$id); echo count((array)get_field("landing_sections",$id)) . PHP_EOL;'
After completing this exercise, output should be:
2
Exercise 4: Audit unknown layouts
Run:
wp eval '$rows=get_field("landing_sections",1345); $known=["hero_section","logos_section","testimonial_section"]; $unknown=[]; foreach((array)$rows as $row){$layout=$row["acf_fc_layout"] ?? ""; if($layout!=="" && !in_array($layout,$known,true)){$unknown[]=$layout;}} print_r($unknown);'
After completing this exercise, expected output:
Array
(
)
Exercise 5: Validate cloned CTA payload presence
Run:
wp eval '$rows=get_field("landing_sections",1345); foreach((array)$rows as $row){if(($row["acf_fc_layout"] ?? "")==="hero_section"){echo ($row["shared_cta_label"] ?? "") . " | " . ($row["shared_cta_url"] ?? "") . PHP_EOL;}}'
After completing this exercise, output should be:
Book | /book
CLI Reference
| Command | Purpose | Real Example Output |
|---|---|---|
wp eval 'echo count(acf_get_field_groups()) . PHP_EOL;' | Count active group registry | 16 |
wp eval '$g=acf_get_field_groups(["key"=>"group_service_structural_model"]); print_r(array_column(acf_get_fields($g[0]),"name"));' | List service structural field names | service_hero_group service_faq_items |
wp eval '$id=1345; $rows=get_field("service_faq_items",$id); echo count((array)$rows) . PHP_EOL;' | Read Repeater row count | 2 |
wp eval '$rows=get_field("landing_sections",1345); foreach((array)$rows as $row){echo $row["acf_fc_layout"] . PHP_EOL;}' | List Flexible layout sequence | hero_section testimonial_section |
wp eval '$rows=get_field("landing_sections",1345); print_r($rows[0]);' | Inspect first flexible row payload | Array ( [acf_fc_layout] => hero_section ... ) |
wp post meta get 1345 service_faq_items | Inspect raw stored structural data | a:2:{...} |
wp eval '$unknown=[]; ... ; print_r($unknown);' | Unknown-layout audit | Array ( ) |
| `wp plugin list --status=active | grep advanced-custom-fields-pro` | Confirm ACF Pro requirement |
What's Next
- Continue to Relationship, Taxonomy, and User Fields.
- Return to Module 2 Overview to keep data-modeling decisions aligned.
- Related lesson: Reading Field Values in Templates.
- Related lesson: Conditional Rendering, Fallbacks, and Empty States.
Revisit this lesson whenever a page builder request appears; it helps you decide whether to use Repeater, Flexible Content, or a simpler fixed model.