An ACF Flexible Content dispatcher needs three things: have_rows(), the_row() to advance the active row, and get_row_layout() to select its renderer. I keep the rendering in template parts and explicitly map known layout names to files.
This is a classic PHP-theme pattern using ACF PRO's Flexible Content and Repeater fields. It is useful for a bounded set of editorial sections; it is not a requirement to build another page builder inside every page.
The directory structure
your-theme/
page.php
template-parts/
flexible-content/
hero.php
stat-row.php
_partials/
stat-card.php
button.phpThe registered layout name can use underscores while the filename uses hyphens. An explicit map makes that relationship visible and avoids treating arbitrary field data as a file path.
The dispatcher loop
Put this where the page's sections should render, after the theme has established the intended post:
$renderers = array(
'hero' => 'template-parts/flexible-content/hero',
'stat_row' => 'template-parts/flexible-content/stat-row',
);
$post_id = get_the_ID();
if ( function_exists( 'have_rows' ) && have_rows( 'page_builder', $post_id ) ) {
while ( have_rows( 'page_builder', $post_id ) ) {
the_row();
$layout = get_row_layout();
if ( isset( $renderers[ $layout ] ) ) {
get_template_part( $renderers[ $layout ], null, array(
'post_id' => $post_id,
) );
} elseif ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( 'Unknown ACF layout: ' . sanitize_key( (string) $layout ) );
}
}
}If this runs in a footer, REST callback or secondary loop, pass the intended post ID instead of assuming the global post represents the page you want. Missing the_row() can produce an infinite loop: have_rows() checks for a next row but does not advance it.
A representative layout partial
template-parts/flexible-content/hero.php reads the active layout's subfields:
<?php
$heading = (string) get_sub_field( 'heading' );
$text = (string) get_sub_field( 'text' );
$url = (string) get_sub_field( 'cta_url' );
$label = (string) get_sub_field( 'cta_label' );
?>
<section class="fc-hero">
<?php if ( '' !== $heading ) : ?>
<h2><?php echo esc_html( $heading ); ?></h2>
<?php endif; ?>
<p><?php echo esc_html( $text ); ?></p>
<?php if ( $url && $label ) : ?>
<a href="<?php echo esc_url( $url ); ?>"><?php echo esc_html( $label ); ?></a>
<?php endif; ?>
</section>The dispatcher and partial share ACF's active loop state. That does not mean ordinary caller-local PHP variables automatically appear inside the template part. Use $args for explicit inputs; WordPress has supported the third get_template_part() argument since 5.5.
Passing extra data into a partial
A shared button can be independent of ACF:
get_template_part( 'template-parts/flexible-content/_partials/button', null, array(
'url' => (string) get_sub_field( 'cta_url' ),
'label' => (string) get_sub_field( 'cta_label' ),
'variant' => 'primary',
) );In _partials/button.php:
<?php
$url = (string) ( $args['url'] ?? '' );
$label = (string) ( $args['label'] ?? '' );
$variant = in_array( $args['variant'] ?? '', array( 'primary', 'secondary' ), true )
? $args['variant'] : 'primary';
if ( $url && $label ) : ?>
<a class="btn btn--<?php echo esc_attr( $variant ); ?>"
href="<?php echo esc_url( $url ); ?>"><?php echo esc_html( $label ); ?></a>
<?php endif;Allowlist component variants instead of letting data select arbitrary markup or templates. Keep the fields as component inputs; the reusable renderer should not need to know how every other page stores its content.
Nested repeater worked example
Here is a complete field definition for a stat_row layout with a heading and a nested stats repeater. Register it in a site plugin on acf/init; use unique keys across the project. This example adds the group to pages:
add_action( 'acf/init', function () {
if ( ! function_exists( 'acf_add_local_field_group' ) ) {
return;
}
acf_add_local_field_group( array(
'key' => 'group_te_page_builder',
'title' => 'Page sections',
'fields' => array(
array(
'key' => 'field_te_page_builder',
'label' => 'Sections',
'name' => 'page_builder',
'type' => 'flexible_content',
'layouts' => array(
'layout_te_stat_row' => array(
'key' => 'layout_te_stat_row',
'name' => 'stat_row',
'label' => 'Statistics',
'display' => 'block',
'sub_fields' => array(
array(
'key' => 'field_te_stat_heading',
'label' => 'Heading', 'name' => 'heading', 'type' => 'text',
),
array(
'key' => 'field_te_stats',
'label' => 'Statistics', 'name' => 'stats', 'type' => 'repeater',
'sub_fields' => array(
array(
'key' => 'field_te_stat_label',
'label' => 'Label', 'name' => 'label', 'type' => 'text',
),
array(
'key' => 'field_te_stat_value',
'label' => 'Value', 'name' => 'value', 'type' => 'text',
),
array(
'key' => 'field_te_stat_unit',
'label' => 'Unit', 'name' => 'unit', 'type' => 'text',
),
),
),
),
),
),
),
),
'location' => array( array( array(
'param' => 'post_type', 'operator' => '==', 'value' => 'page',
) ) ),
) );
} );The earlier hero partial assumes a separately registered hero layout with the named text/URL fields. The complete definition above is enough to exercise the statistics path without inventing additional fields.
In stat-row.php, save the outer heading before entering the inner repeater:
<?php $heading = (string) get_sub_field( 'heading' ); ?>
<section class="fc-stat-row">
<h2><?php echo esc_html( $heading ); ?></h2>
<?php if ( have_rows( 'stats' ) ) : ?>
<dl>
<?php while ( have_rows( 'stats' ) ) : the_row(); ?>
<?php get_template_part(
'template-parts/flexible-content/_partials/stat-card', null, array(
'label' => (string) get_sub_field( 'label' ),
'value' => (string) get_sub_field( 'value' ),
'unit' => (string) get_sub_field( 'unit' ),
)
); ?>
<?php endwhile; ?>
</dl>
<?php endif; ?>
</section>_partials/stat-card.php:
<dt><?php echo esc_html( (string) ( $args['label'] ?? '' ) ); ?></dt>
<dd><?php echo esc_html( (string) ( $args['value'] ?? '' ) ); ?><?php
echo esc_html( (string) ( $args['unit'] ?? '' ) );
?></dd>Inside the inner loop, get_sub_field('label') reads the current statistic. After the inner loop finishes normally, the outer layout is active again. the_row() advances the active loop; passing a field name to it does not select another loop.
Reading and writing the data programmatically
Outside the active-row API, use the returned arrays directly:
$sections = get_field( 'page_builder', $post_id );
foreach ( is_array( $sections ) ? $sections : array() as $section ) {
if ( 'stat_row' !== ( $section['acf_fc_layout'] ?? '' ) ) {
continue;
}
foreach ( $section['stats'] ?? array() as $stat ) {
printf( '<p>%s: %s%s</p>',
esc_html( (string) ( $stat['label'] ?? '' ) ),
esc_html( (string) ( $stat['value'] ?? '' ) ),
esc_html( (string) ( $stat['unit'] ?? '' ) )
);
}
}A foreach over values is valid, but it does not establish the active context for get_sub_field(). Do not mix those two rendering approaches unintentionally.
For an initial programmatic write, use the field keys from the registration and include acf_fc_layout:
$sections = array(
array(
'acf_fc_layout' => 'stat_row',
'field_te_stat_heading' => 'At a glance',
'field_te_stats' => array(
array(
'field_te_stat_label' => 'Projects',
'field_te_stat_value' => '12',
'field_te_stat_unit' => '',
),
),
),
);
update_field( 'field_te_page_builder', $sections, $post_id );That replaces the whole Flexible Content value. Run it only for an intentional, backed-up update; it is not a merge with existing layouts. Read the value back to verify its shape and render it before using the pattern in a bulk migration.
Read a layout name and choose a renderer
For two or three tiny layouts, a switch (get_row_layout()) inside the active loop is enough. For callback rendering, map fixed layout names to your own functions:
$callbacks = array( 'stat_row' => 'te_render_stat_summary' );
if ( have_rows( 'page_builder', $post_id ) ) {
while ( have_rows( 'page_builder', $post_id ) ) {
the_row();
$layout = get_row_layout();
if ( isset( $callbacks[ $layout ] ) && is_callable( $callbacks[ $layout ] ) ) {
call_user_func( $callbacks[ $layout ] );
}
}
}
function te_render_stat_summary() {
printf( '<h2>%s</h2>', esc_html( (string) get_sub_field( 'heading' ) ) );
}For an inventory without rendering, read the field as an array and use array_column($sections, 'acf_fc_layout') after checking it is an array. That reveals stored layout names without advancing the active rendering loop.
Blank output and loop troubleshooting
Check the post ID, field name, saved values and registered layout name first. Then verify every while (have_rows(...)) calls the_row() once per iteration. Confirm the allowlist contains the layout and the template file exists with the expected filename.
Read subfields only in the intended row context. A top-level get_field('label') does not mean the inner repeater's label. After an early break or return, do not assume a nested ACF loop has unwound normally; finish the loop or explicitly restructure the code to render arrays.
Test an empty builder, one statistics row, several rows, an empty nested repeater, two consecutive sections and a deliberately unknown layout. Verify output escaping with text containing < and &. Inspect both markup and PHP logs; a blank page may indicate a fatal error or infinite loop, not missing data.
When to choose a simpler layout or different architecture
If a page needs only a heading and body, ordinary fields are easier to maintain. If repeated items have independent identity, permissions or queries, consider a related post type. For deeply nested structures, measure editor and frontend costs separately before assuming more caching will solve them.
The benefit of template parts is explicit rendering ownership and reusable components. It does not establish that PHP is inherently faster than another framework. Keep that claim tied to measurements of the actual application.
Sources
Authoritative references this article was fact-checked against.
- ACF loop scope and advancementadvancedcustomfields.com
- ACF Flexible Contentadvancedcustomfields.com
- ACF initial field references and updatesadvancedcustomfields.com
- WordPress template part argumentsdeveloper.wordpress.org
- WordPress output escapingdeveloper.wordpress.org
- ACF active-row implementationgithub.com





