ACF Relationship Field — Loop Related Posts in PHP
Loop through hand-picked related posts stored in an ACF Relationship field. Returns WP_Post objects — render title, permalink, and thumbnail without extra queries.
Loop through hand-picked posts stored in an ACF Relationship field.
Use this when editors choose posts manually — "Related Articles", "See Also", or staff picks sections.
Building it step by step
Create the ACF field first, then build the PHP renderer in 4 small steps — each one runs on its own.
We’ll build this in 4 small steps. Each one is a working piece of code on its own — stop at any step and you have something that runs in the browser.
Step 1: Create the ACF field. Before writing any PHP, open Custom Fields → Add New and build the field group:
- Set the Title to Related Posts.
- Click Add Field and set:
- Label: Related Posts
- Field Name:
related_posts - Field Type: Relationship
- Return Format: Post Object
- Post Type: Post (leave blank for all types)
- Scroll to Location → Post Type is equal to Page.
- Click Save.
<?php
// ACF field reference — keep this open while writing PHP below.
//
// Field Name Type Returns
// ─────────────────────────────────────────────────────────
// related_posts Relationship Array of WP_Post objects
// (empty array if nothing selected)
Step 2: The bare check. get_field('related_posts') returns an empty array when nothing is selected. Wrapping the loop in a check stops a PHP notice on pages where editors haven’t picked any posts yet.
<?php
$related = get_field( 'related_posts' ); // ACF: Relationship — array of WP_Post objects
if ( $related ) {
foreach ( $related as $post ) {
echo $post->ID;
}
}
Step 3: Add title and permalink. Each item in $related is a standard WP_Post object. Pass it directly to get_the_title() and get_permalink() — no extra database query needed.
<?php
$related = get_field( 'related_posts' ); // ACF: Relationship
if ( $related ) {
foreach ( $related as $post ) {
$title = get_the_title( $post );
$url = get_permalink( $post );
?>
<a href="<?php echo esc_url( $url ); ?>">
<?php echo esc_html( $title ); ?>
</a>
<?php
}
}
Step 4: Add the featured image. Wrap get_the_post_thumbnail() in a has_post_thumbnail() check — the card still renders cleanly when a post has no image set.
<?php
$related = get_field( 'related_posts' ); // ACF: Relationship
if ( $related ) {
foreach ( $related as $post ) {
$title = get_the_title( $post );
$url = get_permalink( $post );
$has_img = has_post_thumbnail( $post );
?>
<article class="related-post">
<?php if ( $has_img ) { ?>
<a href="<?php echo esc_url( $url ); ?>">
<?php echo get_the_post_thumbnail( $post, 'medium' ); ?>
</a>
<?php } ?>
<h3>
<a href="<?php echo esc_url( $url ); ?>"><?php echo esc_html( $title ); ?></a>
</h3>
</article>
<?php
}
}
Step 5: Everything together. Steps 2–4 are teaching code — each one introduces a single concept. This is the complete production file: all steps combined, wrapped in a section container with an early return guard.
<?php
/**
* Full file: template-parts/sections/related-posts.php
* Steps 2–4 combined — copy this into your theme as the production version.
*/
// ── 1. Read ACF field ─────────────────────────────────────────────────────────
$related = get_field( 'related_posts' ); // ACF: Relationship
if ( ! $related ) {
return;
}
?>
<section class="related-posts-section">
<h2>Related Articles</h2>
<?php
// ── 2. Loop and render ────────────────────────────────────────────────────
foreach ( $related as $post ) {
$title = get_the_title( $post );
$url = get_permalink( $post );
$has_img = has_post_thumbnail( $post );
?>
<article class="related-post-card">
<?php if ( $has_img ) { ?>
<a href="<?php echo esc_url( $url ); ?>">
<?php echo get_the_post_thumbnail( $post, 'medium' ); ?>
</a>
<?php } ?>
<h3><a href="<?php echo esc_url( $url ); ?>"><?php echo esc_html( $title ); ?></a></h3>
</article>
<?php } ?>
</section>
Call this file from your page template: get_template_part( 'template-parts/sections/related-posts' );
How it works
One explanation per build step — what problem each layer solved and which WordPress functions were introduced.
Step 2 — The bare check
Problem solved: prevents a PHP notice when editors leave the Relationship field empty. get_field('related_posts') returns an empty array [] when no posts are selected — a plain if ( $related ) check treats an empty array as false, so the loop never runs. No ACF functions were added here; this step is purely defensive PHP before iterating.
Step 3 — Title and permalink
Problem solved: turns a raw WP_Post object into a clickable link. The Relationship field with Return Format: Post Object gives you a full WP_Post — you pass it directly to get_permalink() and get_the_title() without a secondary database query. Output is escaped with esc_url() and esc_html() to prevent XSS.
Step 4 — Featured image
Problem solved: shows a thumbnail without breaking the layout when a post has no image. has_post_thumbnail() returns true only when a featured image is actually set — the conditional wraps get_the_post_thumbnail() so the card renders cleanly with just the title on image-less posts. The second argument 'medium' tells WordPress which registered image size to return.
Setup
Create the field group in ACF admin, place the template file, and make your first test.
Create the ACF field group
- Go to Custom Fields → Add New.
- Set the Title to Related Posts.
- Click Add Field and configure:
- Field Type: Relationship
- Label: Related Posts
- Field Name:
related_posts— must match your PHP exactly - Return Format: Post Object
- Post Type: Post (or leave blank for all)
- Set Location → Post Type is equal to the type where you want the field.
- Click Save.
Create the template file
Create template-parts/sections/related-posts.php inside your theme and paste in the production code from Step 5. Then call it from your page template:
<?php get_template_part( 'template-parts/sections/related-posts' ); ?>
Add posts in the editor and test
Open a page, scroll to the Related Posts field, search for and select 2–3 posts, then publish. Visit the page — your cards should appear. If nothing shows, run var_dump( get_field( 'related_posts' ) ) to confirm the field is returning data. A NULL return means the field name in PHP doesn’t match ACF exactly.
The field names in the PHP must match ACF exactly — that’s the Field Name column in ACF admin, not the Label. If you rename a field in ACF, update the PHP to match.
Making it your own
Return format variants, post type filtering, and display cap.
Return post IDs instead of objects
In the ACF field settings, set Return Format to Post ID. The loop variable changes from a WP_Post object to a plain integer — update your PHP to pass the ID explicitly: get_permalink( $post_id ), get_the_title( $post_id ). Useful when you only need the ID to pass to an external template function.
Restrict to a specific post type
In the ACF field settings, set Post Type to post, project, or any CPT. This restricts the search picker in the editor — editors can only select posts of that type. Useful when you don’t want pages or CPT items mixed into a “Related Articles” picker.
Cap the number of displayed posts
Editors can select many posts in a Relationship field. Slice the array before the loop to enforce a max display count:
$related = array_slice( $related, 0, 3 );
This prevents the section overflowing if an editor selects 10 posts on a narrow layout.
Do not change Return Format on an existing live field. ACF stores the raw post ID internally — switching Return Format changes how it’s delivered on read, not what’s stored. Existing data is safe, but double-check your PHP handles the new type before switching on a live site.
No comments yet. Be the first.