# Champs ACF Relationship en boucle : le N+1 que WP_Query ne voit pas venir

> Un champ Relationship affiché dans une boucle déclenche une requête par article lié. La solution : un unique WP_Query avec post__in plutôt que get_posts() répété.

- Auteur : Clément Hadrot
- Publié le : 2022-04-28
- Mis à jour le : 2022-04-28
- Catégorie : Performance
- URL : https://wpmoderne.dev.wordpress-developpement.fr/performance/acf-relationship-boucle-n-plus-un/

## L’essentiel

- Un champ Relationship stocke des identifiants, pas les articles complets
- Boucler avec get_posts() par article lié multiplie les requêtes
- post__in avec une seule requête regroupée élimine le N+1

Un client gérant un site de location de matériel de chantier signalait une page « projets similaires » particulièrement lente sur les fiches de ses catégories d'engins. Chaque fiche affichait un champ ACF de type Relationship pointant vers trois à cinq autres fiches jugées complémentaires par l'équipe commerciale. Rien de sophistiqué en apparence, et pourtant la page catégorie, qui listait trente fiches, mettait plus de deux secondes à s'afficher côté serveur.

L'analyse avec Query Monitor a montré un motif classique de requête N+1 : une requête supplémentaire déclenchée pour chaque article de la boucle principale, soit trente requêtes quasi identiques exécutées l'une après l'autre.

## Comprendre ce que stocke réellement un champ Relationship

Un champ ACF de type Relationship ne stocke pas les articles liés eux-mêmes, mais un tableau de leurs identifiants, sérialisé dans `wp_postmeta`. Récupérer ce champ avec `get_field()` renvoie par défaut un tableau d'objets `WP_Post` complets, ce qu'ACF obtient en interne en appelant `get_posts()` une fois par article source, pour résoudre chaque tableau d'identifiants en objets complets.

Le code fautif ressemblait à ceci, tout à fait naturel à écrire sans connaître ce détail d'implémentation :

```
foreach ( $query->posts as $post ) {
    $projets_lies = get_field( 'projets_similaires', $post->ID );
    foreach ( $projets_lies as $projet ) {
        echo '<a href="' . get_permalink( $projet ) . '">' . get_the_title( $projet ) . '</a>';
    }
}
```

Chaque appel à `get_field( 'projets_similaires', $post->ID )` déclenche en coulisses la résolution complète des articles liés, avec sa propre requête SQL, répétée pour chacune des trente fiches de la boucle principale.

> L'essentiel à retenir : Un champ Relationship stocke des identifiants, pas les articles complets ; Boucler avec get_posts() par article lié multiplie les requêtes ; post__in avec une seule requête regroupée élimine le N+1

## La solution en une requête regroupée

Plutôt que de laisser ACF résoudre les objets complets pour chaque article séparément, la stratégie retenue a consisté à ne récupérer que les identifiants bruts, sans résolution automatique, en passant le troisième paramètre `$format_value` à `false`, puis à effectuer une seule requête `WP_Query` pour l'ensemble des identifiants collectés sur toute la page :

```
$tous_les_ids = array();
$ids_par_post = array();

foreach ( $query->posts as $post ) {
    $ids = get_field( 'projets_similaires', $post->ID, false );
    $ids_par_post[ $post->ID ] = $ids;
    $tous_les_ids = array_merge( $tous_les_ids, $ids ?: array() );
}

$tous_les_ids = array_unique( $tous_les_ids );

$projets_lies_query = new WP_Query( array(
    'post_type'      => 'projet',
    'post__in'       => $tous_les_ids,
    'posts_per_page' => -1,
    'orderby'        => 'post__in',
) );

$projets_par_id = array();
foreach ( $projets_lies_query->posts as $projet ) {
    $projets_par_id[ $projet->ID ] = $projet;
}
```

La boucle d'affichage n'a ensuite plus qu'à piocher dans le tableau `$projets_par_id` déjà construit en mémoire, sans déclencher la moindre requête supplémentaire :

```
foreach ( $ids_par_post[ $post->ID ] as $id_lie ) {
    if ( isset( $projets_par_id[ $id_lie ] ) ) {
        $projet = $projets_par_id[ $id_lie ];
        echo '<a href="' . get_permalink( $projet ) . '">' . get_the_title( $projet ) . '</a>';
    }
}
```

## Le gain mesuré

Le nombre de requêtes SQL liées à l'affichage des projets similaires est passé de trente à une seule, quel que soit le nombre de fiches affichées sur la page. Le temps de génération de la page catégorie est descendu de 2,1 secondes à environ 350 millisecondes côté serveur, sans changement d'hébergement ni ajout de cache supplémentaire.

- Toujours vérifier si une fonction pratique comme `get_field()` masque une résolution coûteuse en coulisses.
- Passer `$format_value` à `false` permet de garder la main sur la façon dont les identifiants sont résolus.
- `post__in` combiné à `orderby: post__in` permet de conserver un ordre cohérent malgré le regroupement.

## Ce que ce correctif ne couvre pas

Cette technique cible spécifiquement le N+1 provoqué par un champ Relationship affiché en boucle sur une page de liste. Le cache de champs simples, comme le texte ou les nombres, suit une logique différente déjà traitée par ailleurs via `update_meta_cache()`, et ne suffit pas à lui seul à résoudre ce cas précis lié aux relations entre contenus.

## Pour aller plus loin

Ce genre de restructuration demande un peu plus de rigueur au moment de l'écriture, mais elle transforme un coût qui grandit linéairement avec le nombre d'articles affichés en un coût constant, une propriété precieuse dès qu'un site grandit en volume de contenu.
