Si vos “templates dynamiques” Divi finissent par afficher le mauvais contenu, ou par casser dès que vous changez une règle d’affichage, le problème vient rarement de Divi lui-même. Dans mon expérience, ça vient presque toujours d’une base de contenu mal structurée (CPT/taxos), d’une requête trop “magique”, ou d’un manque de garde-fous (fallbacks, conditions, cache).

Ce qu’on va construire

On va mettre en place une “bibliothèque de ressources” (guides, checklists, templates) pilotée par WordPress 6.9.4 (avril 2026) et affichée via le Divi 5 Theme Builder avec des templates dynamiques avancés.

Résultat final :

  • Un type de contenu Ressource (CPT) avec une taxonomie Thème et des champs (niveau, durée, URL de téléchargement).
  • Un template Divi 5 pour la page single d’une ressource : hero, méta, CTA, ressources liées, et fallback propre si un champ est vide.
  • Un template Divi 5 pour les archives (liste filtrée par taxonomie) avec pagination stable.
  • Des conditions d’affichage propres (par type de contenu, par taxonomie, par auteur) et une stratégie de cache réaliste.

À la fin, vous saurez :

  • Structurer un contenu “fait pour le Theme Builder” (et pas l’inverse).
  • Brancher Divi sur des données WordPress fiables (CPT, taxos, meta) sans hacks fragiles.
  • Déboguer les cas réels : mauvais contexte, champs vides, cache, permaliens, priorités de hooks.

Résumé rapide

  • Créez un CPT + taxonomie + meta déclarés proprement (register_post_type, register_taxonomy, register_post_meta).
  • Ajoutez des shortcodes “propres” pour lister des contenus (et les poser où vous voulez dans Divi).
  • Construisez 2 templates Divi 5 : Single Ressource et Archive Ressource, avec conditions d’affichage strictes.
  • Gérez les fallbacks (champ vide), la pagination, et l’invalidation de cache.
  • Testez sur un site de staging, régénérez les permaliens, videz les caches (Divi + plugin + navigateur).

Quand utiliser cette solution

  • Vous avez un site éditorial ou une base de contenus (blog, média, knowledge base) et vous voulez des pages “type” cohérentes.
  • Vous devez maintenir plusieurs variantes (ex : une ressource “PDF” vs “Vidéo”) sans dupliquer 20 layouts.
  • Vous voulez que vos templates restent stables quand vous changez de page builder, ou quand vous ajoutez un plugin (structure WordPress d’abord).
  • Vous avez besoin de listes dynamiques contrôlées (tri, filtres, pagination) et pas seulement des modules “Posts” par défaut.

Quand ne PAS utiliser cette solution

  • Site vitrine de 5 pages : un Theme Builder complet + CPT est souvent trop lourd.
  • Vous n’avez pas la main sur le thème/environnement (ex : hébergement verrouillé, pas de snippets, pas de staging).
  • Vous cherchez un “catalogue e-commerce” : WooCommerce a déjà ses templates, sa logique et ses hooks. Ne recréez pas ça dans un CPT.
  • Vous refusez tout code : Divi peut faire beaucoup, mais les dynamiques avancées (fallback, requêtes fines, cache) deviennent vite bancales sans un minimum de PHP.

Avant de commencer (prérequis)

Avant de toucher au Theme Builder, sécurisez la base. J’ai souvent vu des sites casser parce qu’un snippet était collé dans le mauvais fichier ou activé en prod sans rollback.

Pré-requis techniques

  • WordPress 6.9.4 (ou plus récent) et PHP 8.1+.
  • Divi 5 (Theme/Builder) à jour.
  • Un accès admin + accès à l’édition de code (idéalement via Git/SSH, sinon via un plugin de snippets).

Sauvegarde et environnement

  • Faites une sauvegarde fichiers + base de données.
  • Travaillez sur un staging (même une copie locale).
  • Activez WP_DEBUG et loggez les erreurs sur staging.
<?php
// wp-config.php (staging uniquement)
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
@ini_set('display_errors', 0);

Où mettre le code

  • Idéal : un mini-plugin dédié (recommandé ci-dessous).
  • Alternative : un plugin de snippets (attention aux snippets activés sans contrôle de version).
  • Évitez : coller ça dans functions.php du thème parent (mise à jour = perte).

Sources officielles utiles


Étape 1 : Préparer le modèle de contenu (Custom Post Type + champs)

Divi 5 Theme Builder est très bon pour “mapper” des champs sur un layout. Mais si vos champs ne sont pas déclarés proprement (types, REST, auth), vous allez vous battre avec des données incohérentes, surtout si vous utilisez l’éditeur de blocs, des API REST, ou des modules dynamiques.

1) Créez un mini-plugin

Créez un dossier /wp-content/plugins/bpcab-divi5-ressources/, puis un fichier bpcab-divi5-ressources.php. Collez ce code, activez le plugin dans Extensions.

<?php
/**
 * Plugin Name: BPCAB - Divi 5 Ressources (CPT + champs)
 * Description: CPT Ressource + taxonomie + meta, prêt pour Theme Builder.
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 */

if (!defined('ABSPATH')) {
	exit;
}

add_action('init', function () {

	// 1) Type de contenu : ressource
	register_post_type('bpcab_ressource', [
		'labels' => [
			'name' => 'Ressources',
			'singular_name' => 'Ressource',
			'add_new_item' => 'Ajouter une ressource',
			'edit_item' => 'Modifier la ressource',
		],
		'public' => true,
		'show_in_rest' => true, // Important pour l’écosystème moderne WP (REST, éditeurs, intégrations)
		'menu_icon' => 'dashicons-media-document',
		'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author', 'revisions'],
		'has_archive' => true,
		'rewrite' => [
			'slug' => 'ressources',
			'with_front' => false,
		],
	]);

	// 2) Taxonomie : thème
	register_taxonomy('bpcab_theme', ['bpcab_ressource'], [
		'labels' => [
			'name' => 'Thèmes',
			'singular_name' => 'Thème',
		],
		'public' => true,
		'show_in_rest' => true,
		'hierarchical' => true,
		'rewrite' => [
			'slug' => 'theme-ressource',
			'with_front' => false,
		],
	]);

	// 3) Champs meta (déclarés) : niveau, durée, url_download
	// Déclarer les meta évite des surprises (types, permissions, REST).
	$meta_args_common = [
		'single' => true,
		'show_in_rest' => true,
		'auth_callback' => function () {
			// Sécurité : seuls les utilisateurs pouvant éditer le post peuvent modifier les meta.
			return current_user_can('edit_posts');
		},
	];

	register_post_meta('bpcab_ressource', 'bpcab_niveau', $meta_args_common + [
		'type' => 'string',
		'sanitize_callback' => function ($value) {
			$allowed = ['debutant', 'intermediaire', 'avance'];
			$value = is_string($value) ? strtolower($value) : '';
			return in_array($value, $allowed, true) ? $value : 'intermediaire';
		},
	]);

	register_post_meta('bpcab_ressource', 'bpcab_duree_minutes', $meta_args_common + [
		'type' => 'integer',
		'sanitize_callback' => function ($value) {
			return max(0, (int) $value);
		},
	]);

	register_post_meta('bpcab_ressource', 'bpcab_url_download', $meta_args_common + [
		'type' => 'string',
		'sanitize_callback' => function ($value) {
			$value = is_string($value) ? trim($value) : '';
			return $value ? esc_url_raw($value) : '';
		},
	]);
});

/**
 * Activation : flush des permaliens.
 * Erreur fréquente : oublier cette étape => 404 sur les archives/singles.
 */
register_activation_hook(__FILE__, function () {
	// On enregistre d’abord les CPT/taxos avant flush.
	do_action('init');
	flush_rewrite_rules();
});

register_deactivation_hook(__FILE__, function () {
	flush_rewrite_rules();
});

2) Vérifiez le résultat

  • Dans l’admin : un menu Ressources apparaît.
  • Créez 2-3 ressources avec une image mise en avant, un extrait, et assignez un Thème.
  • Testez l’URL /ressources/ (archive). Si vous avez une 404, allez dans Réglages → Permaliens et cliquez Enregistrer.

3) Ajouter une UI simple pour éditer les champs (meta box)

Divi peut lire des meta, mais encore faut-il pouvoir les saisir proprement. Je préfère une meta box légère, plutôt qu’un champ ACF “au kilomètre” pour ce cas précis.

Ajoutez ce code à la fin du même plugin :

<?php
add_action('add_meta_boxes', function () {
	add_meta_box(
		'bpcab_ressource_meta',
		'Détails de la ressource',
		'bpcab_render_ressource_metabox',
		'bpcab_ressource',
		'normal',
		'default'
	);
});

function bpcab_render_ressource_metabox(WP_Post $post): void {
	wp_nonce_field('bpcab_save_ressource_meta', 'bpcab_ressource_meta_nonce');

	$niveau = get_post_meta($post->ID, 'bpcab_niveau', true);
	$duree  = (int) get_post_meta($post->ID, 'bpcab_duree_minutes', true);
	$url    = (string) get_post_meta($post->ID, 'bpcab_url_download', true);

	?>
	<div>
		<p>
			<strong>Niveau</strong><br>
			<select name="bpcab_niveau">
				<option value="debutant" <?php selected($niveau, 'debutant'); ?>>Débutant</option>
				<option value="intermediaire" <?php selected($niveau, 'intermediaire'); ?>>Intermédiaire</option>
				<option value="avance" <?php selected($niveau, 'avance'); ?>>Avancé</option>
			</select>
		</p>

		<p>
			<strong>Durée (minutes)</strong><br>
			<input type="number" name="bpcab_duree_minutes" min="0" step="1" value="<?php echo esc_attr((string) $duree); ?>" />
		</p>

		<p>
			<strong>URL de téléchargement (optionnel)</strong><br>
			<input type="url" name="bpcab_url_download" style="width:100%;" value="<?php echo esc_attr($url); ?>" placeholder="https://..." />
		</p>

		<p><em>Astuce : laissez l’URL vide si la ressource n’est pas téléchargeable. On gérera un fallback propre dans le template.</em></p>
	</div>
	<?php
}

add_action('save_post_bpcab_ressource', function (int $post_id) {

	// Autosave / révisions : on ignore
	if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
		return;
	}

	// Nonce
	if (!isset($_POST['bpcab_ressource_meta_nonce']) || !wp_verify_nonce((string) $_POST['bpcab_ressource_meta_nonce'], 'bpcab_save_ressource_meta')) {
		return;
	}

	// Permissions
	if (!current_user_can('edit_post', $post_id)) {
		return;
	}

	// Sauvegarde (sanitization déjà gérée par register_post_meta, mais on reste propre côté input)
	$niveau = isset($_POST['bpcab_niveau']) ? sanitize_text_field((string) $_POST['bpcab_niveau']) : 'intermediaire';
	$duree  = isset($_POST['bpcab_duree_minutes']) ? (int) $_POST['bpcab_duree_minutes'] : 0;
	$url    = isset($_POST['bpcab_url_download']) ? esc_url_raw((string) $_POST['bpcab_url_download']) : '';

	update_post_meta($post_id, 'bpcab_niveau', $niveau);
	update_post_meta($post_id, 'bpcab_duree_minutes', max(0, $duree));
	update_post_meta($post_id, 'bpcab_url_download', $url);

}, 10, 1);

Résultat attendu : dans l’édition d’une ressource, vous avez une boîte “Détails de la ressource”, et les valeurs sont sauvegardées.


Étape 2 : Exposer des listes dynamiques propres (requêtes + shortcodes)

Le Theme Builder Divi gère bien le “contexte” (single, archive), mais dès que vous voulez des listes avancées (liés par taxo, filtrage, tri, pagination, exclusion du post courant), un shortcode bien écrit reste une solution robuste. Et vous pourrez le réutiliser dans Divi, Elementor, Avada, ou même dans un bloc “Shortcode”.

1) Shortcode : ressources liées (même thème)

Ajoutez ceci dans votre plugin. Ce shortcode affiche 3 ressources du même thème que la ressource en cours.

<?php
add_shortcode('bpcab_ressources_liees', function ($atts) {
	if (!is_singular('bpcab_ressource')) {
		return '';
	}

	$post_id = get_the_ID();
	if (!$post_id) {
		return '';
	}

	$terms = wp_get_post_terms($post_id, 'bpcab_theme', ['fields' => 'ids']);
	if (is_wp_error($terms) || empty($terms)) {
		return '<p>Aucune ressource liée (pas de thème assigné).</p>';
	}

	$atts = shortcode_atts([
		'limit' => 3,
	], $atts, 'bpcab_ressources_liees');

	$limit = max(1, min(12, (int) $atts['limit']));

	$q = new WP_Query([
		'post_type' => 'bpcab_ressource',
		'posts_per_page' => $limit,
		'post__not_in' => [$post_id],
		'ignore_sticky_posts' => true,
		'tax_query' => [
			[
				'taxonomy' => 'bpcab_theme',
				'field' => 'term_id',
				'terms' => $terms,
			]
		],
		'no_found_rows' => true, // Perf : pas de pagination ici
	]);

	if (!$q->have_posts()) {
		return '<p>Aucune ressource liée.</p>';
	}

	ob_start();
	echo '<div class="bpcab-related">';
	echo '<ul>';
	while ($q->have_posts()) {
		$q->the_post();
		$title = get_the_title();
		$url   = get_permalink();
		echo '<li><a href="' . esc_url($url) . '">' . esc_html($title) . '</a></li>';
	}
	echo '</ul>';
	echo '</div>';
	wp_reset_postdata();

	return ob_get_clean();
});

2) Shortcode : archive filtrable par thème + pagination

Celui-ci est utile si vous voulez une page “hub” (ex : /bibliotheque/) avec un filtre par thème via query string (?theme=seo) et une pagination stable. C’est typiquement le genre de truc qui part en vrille avec des modules builder si on ne contrôle pas la requête.

<?php
add_shortcode('bpcab_ressources_archive', function ($atts) {

	$atts = shortcode_atts([
		'per_page' => 9,
	], $atts, 'bpcab_ressources_archive');

	$per_page = max(3, min(30, (int) $atts['per_page']));

	// Pagination : sur une page WP, on utilise souvent "paged" via query var.
	$paged = max(1, (int) get_query_var('paged'));

	$tax_query = [];
	$theme_slug = isset($_GET['theme']) ? sanitize_title((string) $_GET['theme']) : '';
	if ($theme_slug) {
		$tax_query[] = [
			'taxonomy' => 'bpcab_theme',
			'field' => 'slug',
			'terms' => [$theme_slug],
		];
	}

	$q = new WP_Query([
		'post_type' => 'bpcab_ressource',
		'posts_per_page' => $per_page,
		'paged' => $paged,
		'ignore_sticky_posts' => true,
		'tax_query' => $tax_query,
	]);

	ob_start();

	// Filtre (liste des thèmes)
	$themes = get_terms([
		'taxonomy' => 'bpcab_theme',
		'hide_empty' => true,
	]);
	if (!is_wp_error($themes) && !empty($themes)) {
		echo '<div class="bpcab-filter"><p><strong>Filtrer par thème :</strong> ';
		echo '<a href="' . esc_url(remove_query_arg('theme')) . '">Tous</a>';
		foreach ($themes as $t) {
			$url = add_query_arg('theme', $t->slug);
			echo ' | <a href="' . esc_url($url) . '">' . esc_html($t->name) . '</a>';
		}
		echo '</p></div>';
	}

	if ($q->have_posts()) {
		echo '<div class="bpcab-grid"><ul>';
		while ($q->have_posts()) {
			$q->the_post();
			$title = get_the_title();
			$url   = get_permalink();
			$excerpt = get_the_excerpt();

			echo '<li class="bpcab-card">';
			echo '<h3><a href="' . esc_url($url) . '">' . esc_html($title) . '</a></h3>';
			if ($excerpt) {
				echo '<p>' . esc_html($excerpt) . '</p>';
			}
			echo '</li>';
		}
		echo '</ul></div>';

		// Pagination
		$big = 999999999;
		$links = paginate_links([
			'base' => str_replace($big, '%#%', esc_url(get_pagenum_link($big))),
			'format' => '?paged=%#%',
			'current' => $paged,
			'total' => (int) $q->max_num_pages,
			'type' => 'list',
		]);

		if ($links) {
			echo '<div class="bpcab-pagination">' . $links . '</div>';
		}

	} else {
		echo '<p>Aucune ressource trouvée.</p>';
	}

	wp_reset_postdata();

	return ob_get_clean();
});

3) CSS minimal (optionnel mais utile)

Ajoutez un petit enqueue CSS, sinon vos listes seront “brutes”.

<?php
add_action('wp_enqueue_scripts', function () {
	// On charge partout pour simplifier. Si vous voulez optimiser : chargez uniquement si shortcode présent.
	wp_register_style(
		'bpcab-ressources',
		plugins_url('assets/ressources.css', __FILE__),
		[],
		'1.0.0'
	);
	wp_enqueue_style('bpcab-ressources');
});

Créez ensuite /wp-content/plugins/bpcab-divi5-ressources/assets/ressources.css :

.bpcab-grid ul{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;list-style:none;padding:0;margin:0}
.bpcab-card{border:1px solid rgba(0,0,0,.1);padding:16px;border-radius:10px}
.bpcab-related ul{list-style:disc;padding-left:18px}
.bpcab-pagination ul{display:flex;gap:8px;list-style:none;padding:0}
@media (max-width: 980px){.bpcab-grid ul{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width: 600px){.bpcab-grid ul{grid-template-columns:1fr}}

Résultat attendu : vos shortcodes affichent une grille simple, responsive, sans dépendre d’un module Divi spécifique.


Étape 3 : Construire les templates dynamiques dans Divi 5 Theme Builder

Ici, on assemble le “visuel” en s’appuyant sur le modèle de contenu. Le point clé : ne mélangez pas un template destiné au CPT avec un template global “Tous les posts” si vous voulez éviter les effets de bord.

1) Template “Single Ressource”

  1. Allez dans Divi → Theme Builder.
  2. Cliquez Ajouter un nouveau modèle.
  3. Choisissez RessourcesToutes les ressources (ou “Single” selon l’UI).
  4. Ajoutez un Body personnalisé (Custom Body) puis Construire le body.

Structure recommandée (simple et robuste)

  • Section 1 (Hero)
    • Module Titre : Titre du post (contenu dynamique).
    • Module Texte : extrait (ou un champ dynamique si vous avez).
  • Section 2 (Meta)
    • Module Texte : niveau + durée (via champs meta).
  • Section 3 (Contenu)
    • Module Post Content (contenu principal).
  • Section 4 (CTA)
    • Bouton : URL dynamique depuis bpcab_url_download (avec fallback).
  • Section 5 (Ressources liées)
    • Module Code / Texte : shortcode [bpcab_ressources_liees limit="3"].

2) Gérer les fallbacks (champ vide) proprement

Divi propose des champs dynamiques, mais le “si vide alors…” varie selon les modules. Le moyen le plus stable est de générer un CTA HTML via shortcode, parce que vous contrôlez 100% du rendu et des conditions.

Ajoutez ce shortcode dans votre plugin :

<?php
add_shortcode('bpcab_ressource_cta', function () {
	if (!is_singular('bpcab_ressource')) {
		return '';
	}

	$post_id = get_the_ID();
	if (!$post_id) {
		return '';
	}

	$url = (string) get_post_meta($post_id, 'bpcab_url_download', true);

	// Fallback : si pas d’URL, on propose une action alternative.
	if (!$url) {
		$contact_url = site_url('/contact/');
		return '<div class="bpcab-cta"><p><strong>Téléchargement indisponible</strong><br>Demandez l’accès ou une alternative.</p><p><a href="' . esc_url($contact_url) . '">Contacter</a></p></div>';
	}

	return '<div class="bpcab-cta"><p><a href="' . esc_url($url) . '" rel="nofollow">Télécharger la ressource</a></p></div>';
});

Dans Divi 5, placez un module Texte (ou Code) et mettez :

[bpcab_ressource_cta]

3) Template “Archive Ressources”

Deux options :

  • Option A (simple) : utilisez l’archive native du CPT (/ressources/) et un template Divi assigné à “Archive Ressources”.
  • Option B (contrôlée) : créez une page “Bibliothèque” et placez le shortcode [bpcab_ressources_archive]. C’est souvent plus stable si vous voulez filtrer par query string et garder le même layout.

Pour Option A :

  1. Divi → Theme Builder → Nouveau modèle
  2. Assignez-le à Archive du CPT Ressource (et éventuellement aux archives de taxonomie bpcab_theme).
  3. Dans le Body : insérez un module Code/Texte avec [bpcab_ressources_archive per_page="9"] si vous voulez garder la même logique partout.

Étape 4 : Conditions avancées et variantes (par taxonomie, par auteur, par type)

Le Theme Builder devient vraiment utile quand vous arrêtez de faire “un template pour tout”. Divi 5 permet d’assigner des modèles par règles. Le piège classique : un template trop large qui écrase un template plus spécifique.

1) Variante “Vidéo” vs “PDF” (sans multiplier les CPT)

Ajoutez une taxonomie “Format” (ou réutilisez un champ). Ici, on ajoute une taxonomie bpcab_format et on crée deux templates Divi : un pour “format=video”, un pour “format=pdf”.

<?php
add_action('init', function () {
	register_taxonomy('bpcab_format', ['bpcab_ressource'], [
		'labels' => ['name' => 'Formats', 'singular_name' => 'Format'],
		'public' => true,
		'show_in_rest' => true,
		'hierarchical' => false,
		'rewrite' => ['slug' => 'format-ressource', 'with_front' => false],
	]);
}, 20);

Côté Divi 5 :

  1. Créez un template “Single Ressource (Vidéo)” et assignez-le à Ressourcesin taxonomy → Format = vidéo.
  2. Créez un template “Single Ressource (PDF)” et assignez-le à Format = pdf.
  3. Gardez un template “Single Ressource (Default)” pour tout le reste.

2) Variante par auteur (sites multi-auteurs)

Sur des blogs multi-auteurs, j’ai souvent vu des “ressources premium” gérées par un auteur spécifique. Vous pouvez assigner un template Divi à un auteur précis. Si vous voulez que ce soit automatique (ex : tous les auteurs ayant un rôle), faites-le côté contenu (taxo “éditeur”), pas côté Divi.

3) Ordre de priorité (le vrai piège)

Quand deux templates matchent, Divi applique le plus spécifique… sauf si votre configuration se chevauche mal. Gardez une règle simple :

  • Templates très spécifiques (taxo/format/auteur) en haut.
  • Template default du CPT ensuite.
  • Template global (sitewide) en dernier.

Étape 5 : Performance (cache, pagination, invalidation)

Les templates dynamiques “avancés” deviennent vite lourds si vous empilez modules + requêtes + shortcodes. Deux points changent tout : réduire les requêtes et maîtriser le cache.

1) Cache simple des shortcodes (transients)

Pour les “ressources liées”, on peut mettre un cache de 10 minutes. Attention : si vous éditez souvent, réduisez la durée ou invalidez à la sauvegarde.

<?php
function bpcab_transient_key(string $prefix, array $parts): string {
	return $prefix . '_' . md5(wp_json_encode($parts));
}

add_shortcode('bpcab_ressources_liees', function ($atts) {
	if (!is_singular('bpcab_ressource')) {
		return '';
	}

	$post_id = get_the_ID();
	if (!$post_id) {
		return '';
	}

	$atts = shortcode_atts(['limit' => 3], $atts, 'bpcab_ressources_liees');
	$limit = max(1, min(12, (int) $atts['limit']));

	$key = bpcab_transient_key('bpcab_related', [$post_id, $limit]);
	$cached = get_transient($key);
	if (is_string($cached) && $cached !== '') {
		return $cached;
	}

	$terms = wp_get_post_terms($post_id, 'bpcab_theme', ['fields' => 'ids']);
	if (is_wp_error($terms) || empty($terms)) {
		return '<p>Aucune ressource liée (pas de thème assigné).</p>';
	}

	$q = new WP_Query([
		'post_type' => 'bpcab_ressource',
		'posts_per_page' => $limit,
		'post__not_in' => [$post_id],
		'ignore_sticky_posts' => true,
		'tax_query' => [[
			'taxonomy' => 'bpcab_theme',
			'field' => 'term_id',
			'terms' => $terms,
		]],
		'no_found_rows' => true,
	]);

	ob_start();
	if ($q->have_posts()) {
		echo '<div class="bpcab-related"><ul>';
		while ($q->have_posts()) {
			$q->the_post();
			echo '<li><a href="' . esc_url(get_permalink()) . '">' . esc_html(get_the_title()) . '</a></li>';
		}
		echo '</ul></div>';
	} else {
		echo '<p>Aucune ressource liée.</p>';
	}
	wp_reset_postdata();

	$html = ob_get_clean();
	set_transient($key, $html, 10 * MINUTE_IN_SECONDS);

	return $html;
}, 10);

2) Invalidation du cache à la sauvegarde

Sans ça, vous allez “voir l’ancien contenu” et croire que Divi bug. C’est un classique.

<?php
add_action('save_post_bpcab_ressource', function (int $post_id) {
	// Invalidation simple : on supprime les transients liés à ce post.
	// Comme on a hashé la clé, on ne peut pas lister facilement sans table dédiée.
	// Approche pragmatique : utiliser un "versioning" global.
	$ver = (int) get_option('bpcab_cache_ver', 1);
	update_option('bpcab_cache_ver', $ver + 1, false);
}, 20);

function bpcab_transient_key(string $prefix, array $parts): string {
	$ver = (int) get_option('bpcab_cache_ver', 1);
	$parts[] = $ver;
	return $prefix . '_' . md5(wp_json_encode($parts));
}

Oui, c’est volontairement “global”. Sur un site moyen, c’est suffisant et ça évite une complexité inutile.

3) Pagination : évitez les conflits

Sur une page builder, la pagination peut se casser si :

  • Vous avez une page statique avec un slug qui interagit avec /page/2/.
  • Un plugin SEO réécrit les URLs.
  • Vous avez un cache agressif qui sert la page 1 partout.

Si vous voyez ce symptôme, testez d’abord en désactivant le cache (ou en mode incognito) et régénérez les permaliens.


Le résultat complet

Si vous voulez tout copier d’un coup, voici une version “assemblée” (CPT + taxos + meta + metabox + shortcodes + cache + CSS enqueue). Remplacez votre fichier principal de plugin par ce contenu (ou comparez, si vous avez déjà ajouté des morceaux).

<?php
/**
 * Plugin Name: BPCAB - Divi 5 Ressources (CPT + champs + shortcodes)
 * Description: Base de contenu + shortcodes pour templates dynamiques Divi 5.
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 */

if (!defined('ABSPATH')) {
	exit;
}

function bpcab_cache_ver(): int {
	return (int) get_option('bpcab_cache_ver', 1);
}

function bpcab_transient_key(string $prefix, array $parts): string {
	$parts[] = bpcab_cache_ver();
	return $prefix . '_' . md5(wp_json_encode($parts));
}

add_action('init', function () {

	register_post_type('bpcab_ressource', [
		'labels' => [
			'name' => 'Ressources',
			'singular_name' => 'Ressource',
			'add_new_item' => 'Ajouter une ressource',
			'edit_item' => 'Modifier la ressource',
		],
		'public' => true,
		'show_in_rest' => true,
		'menu_icon' => 'dashicons-media-document',
		'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author', 'revisions'],
		'has_archive' => true,
		'rewrite' => ['slug' => 'ressources', 'with_front' => false],
	]);

	register_taxonomy('bpcab_theme', ['bpcab_ressource'], [
		'labels' => ['name' => 'Thèmes', 'singular_name' => 'Thème'],
		'public' => true,
		'show_in_rest' => true,
		'hierarchical' => true,
		'rewrite' => ['slug' => 'theme-ressource', 'with_front' => false],
	]);

	register_taxonomy('bpcab_format', ['bpcab_ressource'], [
		'labels' => ['name' => 'Formats', 'singular_name' => 'Format'],
		'public' => true,
		'show_in_rest' => true,
		'hierarchical' => false,
		'rewrite' => ['slug' => 'format-ressource', 'with_front' => false],
	]);

	$meta_args_common = [
		'single' => true,
		'show_in_rest' => true,
		'auth_callback' => function () {
			return current_user_can('edit_posts');
		},
	];

	register_post_meta('bpcab_ressource', 'bpcab_niveau', $meta_args_common + [
		'type' => 'string',
		'sanitize_callback' => function ($value) {
			$allowed = ['debutant', 'intermediaire', 'avance'];
			$value = is_string($value) ? strtolower($value) : '';
			return in_array($value, $allowed, true) ? $value : 'intermediaire';
		},
	]);

	register_post_meta('bpcab_ressource', 'bpcab_duree_minutes', $meta_args_common + [
		'type' => 'integer',
		'sanitize_callback' => function ($value) {
			return max(0, (int) $value);
		},
	]);

	register_post_meta('bpcab_ressource', 'bpcab_url_download', $meta_args_common + [
		'type' => 'string',
		'sanitize_callback' => function ($value) {
			$value = is_string($value) ? trim($value) : '';
			return $value ? esc_url_raw($value) : '';
		},
	]);
});

register_activation_hook(__FILE__, function () {
	do_action('init');
	flush_rewrite_rules();
});

register_deactivation_hook(__FILE__, function () {
	flush_rewrite_rules();
});

add_action('wp_enqueue_scripts', function () {
	wp_register_style(
		'bpcab-ressources',
		plugins_url('assets/ressources.css', __FILE__),
		[],
		'1.0.0'
	);
	wp_enqueue_style('bpcab-ressources');
});

add_action('add_meta_boxes', function () {
	add_meta_box(
		'bpcab_ressource_meta',
		'Détails de la ressource',
		'bpcab_render_ressource_metabox',
		'bpcab_ressource',
		'normal',
		'default'
	);
});

function bpcab_render_ressource_metabox(WP_Post $post): void {
	wp_nonce_field('bpcab_save_ressource_meta', 'bpcab_ressource_meta_nonce');

	$niveau = get_post_meta($post->ID, 'bpcab_niveau', true);
	$duree  = (int) get_post_meta($post->ID, 'bpcab_duree_minutes', true);
	$url    = (string) get_post_meta($post->ID, 'bpcab_url_download', true);
	?>
	<div>
		<p>
			<strong>Niveau</strong><br>
			<select name="bpcab_niveau">
				<option value="debutant" <?php selected($niveau, 'debutant'); ?>>Débutant</option>
				<option value="intermediaire" <?php selected($niveau, 'intermediaire'); ?>>Intermédiaire</option>
				<option value="avance" <?php selected($niveau, 'avance'); ?>>Avancé</option>
			</select>
		</p>

		<p>
			<strong>Durée (minutes)</strong><br>
			<input type="number" name="bpcab_duree_minutes" min="0" step="1" value="<?php echo esc_attr((string) $duree); ?>" />
		</p>

		<p>
			<strong>URL de téléchargement (optionnel)</strong><br>
			<input type="url" name="bpcab_url_download" style="width:100%;" value="<?php echo esc_attr($url); ?>" placeholder="https://..." />
		</p>
	</div>
	<?php
}

add_action('save_post_bpcab_ressource', function (int $post_id) {

	if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
		return;
	}

	if (!isset($_POST['bpcab_ressource_meta_nonce']) || !wp_verify_nonce((string) $_POST['bpcab_ressource_meta_nonce'], 'bpcab_save_ressource_meta')) {
		return;
	}

	if (!current_user_can('edit_post', $post_id)) {
		return;
	}

	$niveau = isset($_POST['bpcab_niveau']) ? sanitize_text_field((string) $_POST['bpcab_niveau']) : 'intermediaire';
	$duree  = isset($_POST['bpcab_duree_minutes']) ? (int) $_POST['bpcab_duree_minutes'] : 0;
	$url    = isset($_POST['bpcab_url_download']) ? esc_url_raw((string) $_POST['bpcab_url_download']) : '';

	update_post_meta($post_id, 'bpcab_niveau', $niveau);
	update_post_meta($post_id, 'bpcab_duree_minutes', max(0, $duree));
	update_post_meta($post_id, 'bpcab_url_download', $url);

	// Invalidation cache (version globale)
	update_option('bpcab_cache_ver', bpcab_cache_ver() + 1, false);

}, 10, 1);

add_shortcode('bpcab_ressource_cta', function () {
	if (!is_singular('bpcab_ressource')) {
		return '';
	}

	$post_id = get_the_ID();
	if (!$post_id) {
		return '';
	}

	$url = (string) get_post_meta($post_id, 'bpcab_url_download', true);

	if (!$url) {
		$contact_url = site_url('/contact/');
		return '<div class="bpcab-cta"><p><strong>Téléchargement indisponible</strong><br>Demandez l’accès ou une alternative.</p><p><a href="' . esc_url($contact_url) . '">Contacter</a></p></div>';
	}

	return '<div class="bpcab-cta"><p><a href="' . esc_url($url) . '" rel="nofollow">Télécharger la ressource</a></p></div>';
});

add_shortcode('bpcab_ressources_liees', function ($atts) {
	if (!is_singular('bpcab_ressource')) {
		return '';
	}

	$post_id = get_the_ID();
	if (!$post_id) {
		return '';
	}

	$atts = shortcode_atts(['limit' => 3], $atts, 'bpcab_ressources_liees');
	$limit = max(1, min(12, (int) $atts['limit']));

	$key = bpcab_transient_key('bpcab_related', [$post_id, $limit]);
	$cached = get_transient($key);
	if (is_string($cached) && $cached !== '') {
		return $cached;
	}

	$terms = wp_get_post_terms($post_id, 'bpcab_theme', ['fields' => 'ids']);
	if (is_wp_error($terms) || empty($terms)) {
		return '<p>Aucune ressource liée (pas de thème assigné).</p>';
	}

	$q = new WP_Query([
		'post_type' => 'bpcab_ressource',
		'posts_per_page' => $limit,
		'post__not_in' => [$post_id],
		'ignore_sticky_posts' => true,
		'tax_query' => [[
			'taxonomy' => 'bpcab_theme',
			'field' => 'term_id',
			'terms' => $terms,
		]],
		'no_found_rows' => true,
	]);

	ob_start();
	if ($q->have_posts()) {
		echo '<div class="bpcab-related"><ul>';
		while ($q->have_posts()) {
			$q->the_post();
			echo '<li><a href="' . esc_url(get_permalink()) . '">' . esc_html(get_the_title()) . '</a></li>';
		}
		echo '</ul></div>';
	} else {
		echo '<p>Aucune ressource liée.</p>';
	}
	wp_reset_postdata();

	$html = ob_get_clean();
	set_transient($key, $html, 10 * MINUTE_IN_SECONDS);

	return $html;
});

add_shortcode('bpcab_ressources_archive', function ($atts) {

	$atts = shortcode_atts(['per_page' => 9], $atts, 'bpcab_ressources_archive');
	$per_page = max(3, min(30, (int) $atts['per_page']));

	$paged = max(1, (int) get_query_var('paged'));

	$tax_query = [];
	$theme_slug = isset($_GET['theme']) ? sanitize_title((string) $_GET['theme']) : '';
	if ($theme_slug) {
		$tax_query[] = [
			'taxonomy' => 'bpcab_theme',
			'field' => 'slug',
			'terms' => [$theme_slug],
		];
	}

	$q = new WP_Query([
		'post_type' => 'bpcab_ressource',
		'posts_per_page' => $per_page,
		'paged' => $paged,
		'ignore_sticky_posts' => true,
		'tax_query' => $tax_query,
	]);

	ob_start();

	$themes = get_terms([
		'taxonomy' => 'bpcab_theme',
		'hide_empty' => true,
	]);

	if (!is_wp_error($themes) && !empty($themes)) {
		echo '<div class="bpcab-filter"><p><strong>Filtrer par thème :</strong> ';
		echo '<a href="' . esc_url(remove_query_arg('theme')) . '">Tous</a>';
		foreach ($themes as $t) {
			$url = add_query_arg('theme', $t->slug);
			echo ' | <a href="' . esc_url($url) . '">' . esc_html($t->name) . '</a>';
		}
		echo '</p></div>';
	}

	if ($q->have_posts()) {
		echo '<div class="bpcab-grid"><ul>';
		while ($q->have_posts()) {
			$q->the_post();
			echo '<li class="bpcab-card">';
			echo '<h3><a href="' . esc_url(get_permalink()) . '">' . esc_html(get_the_title()) . '</a></h3>';
			$excerpt = get_the_excerpt();
			if ($excerpt) {
				echo '<p>' . esc_html($excerpt) . '</p>';
			}
			echo '</li>';
		}
		echo '</ul></div>';

		$big = 999999999;
		$links = paginate_links([
			'base' => str_replace($big, '%#%', esc_url(get_pagenum_link($big))),
			'format' => '?paged=%#%',
			'current' => $paged,
			'total' => (int) $q->max_num_pages,
			'type' => 'list',
		]);

		if ($links) {
			echo '<div class="bpcab-pagination">' . $links . '</div>';
		}
	} else {
		echo '<p>Aucune ressource trouvée.</p>';
	}

	wp_reset_postdata();

	return ob_get_clean();
});

Personnalisation rapide

  • Changez les slugs (ressources, theme-ressource) avant de mettre en production.
  • Ajoutez des champs meta supplémentaires via register_post_meta si vous avez des besoins (prix, accès, durée vidéo…).
  • Remplacez le fallback /contact/ par une page réelle (ou un lien mailto).

Adapter pour Divi 5 / Elementor / Avada

Divi 5 (recommandé ici)

  • Placez les shortcodes dans un module Texte ou Code.
  • Pour les champs meta : utilisez les options “contenu dynamique” si disponibles, sinon affichez-les via un shortcode dédié (plus stable).
  • Si vous utilisez le cache Divi (ou un plugin de cache), videz-le après modification des templates.

Elementor (même base, même logique)

  • Theme Builder Elementor : créez un template Single et un template Archive pour le CPT.
  • Insérez les shortcodes via le widget Shortcode.
  • Les champs meta déclarés (register_post_meta) sont plus faciles à exploiter via des widgets dynamiques ou via un plugin de champs.

Avada (Fusion Builder)

  • Créez un layout pour le CPT via Avada Layouts (conditions d’affichage similaires).
  • Utilisez l’élément Shortcode pour [bpcab_ressources_archive] et [bpcab_ressources_liees].
  • Si Avada minifie/concatène, vérifiez que votre CSS plugin n’est pas “déplacé” de façon inattendue.

Vérification finale

  • Créez 5 ressources, 2 thèmes, 2 formats (pdf/vidéo).
  • Assignez des thèmes/formats variés, laissez une ressource sans URL de download.
  • Vérifiez :
    • Single : le CTA affiche “Télécharger” si URL présente, sinon le fallback “Contacter”.
    • Ressources liées : sur une ressource avec thème, vous voyez 1 à 3 liens (jamais le post courant).
    • Archive : la pagination change bien le contenu (page 2 différente).
    • Filtre : ?theme=slug filtre réellement.
  • Ouvrez /wp-content/debug.log (staging) : aucune notice PHP répétée.

Si le résultat n’est pas celui attendu

Symptôme Cause probable Vérification Solution
Archive /ressources/ en 404 Règles de réécriture non flush Réglages → Permaliens, ou logs Enregistrer les permaliens, réactiver le plugin, vérifier flush_rewrite_rules()
Le shortcode n’affiche rien dans Divi Shortcode collé dans un module qui le neutralise, ou mauvais contexte Testez le shortcode dans un article simple Utilisez module Texte/Code, vérifiez is_singular() et le type de post
Pagination affiche toujours la page 1 Cache serveur/plugin, ou conflit de permaliens Test en navigation privée + cache désactivé Videz caches, vérifiez permaliens, testez ?paged=2
CTA “Télécharger” ne s’affiche jamais Meta non enregistrée ou clé incorrecte Inspectez la meta via un plugin (ou DB) Vérifiez bpcab_url_download, nonce, droits, et sauvegardez à nouveau
Erreurs PHP après collage Point-virgule/parenthèse manquant, ou code collé dans le mauvais fichier Logs + écran blanc Revenir en arrière via FTP, corriger la syntaxe, utiliser un IDE
  • Erreur fréquente : coller du PHP dans un module Divi “Code”. Ça ne s’exécute pas côté serveur. Le PHP va dans un plugin (ou thème enfant), les shortcodes vont dans Divi.
  • Erreur fréquente : tester directement sur production sans sauvegarde. Un ; manquant peut mettre le site HS.

Pièges et erreurs courantes

Erreur Cause Solution
Le template Divi “global” écrase le template CPT Conditions trop larges Rendre les templates spécifiques prioritaires, limiter le global
Champs meta visibles pour tous (ou modifiables via REST) show_in_rest sans contrôle Gardez auth_callback, vérifiez les rôles/caps
Shortcode lent Requêtes sans optimisation, pas de cache no_found_rows quand possible, transients, limiter posts_per_page
Conflit avec un plugin de cache/minify HTML mis en cache sans variation (query string) Configurer le cache pour varier sur ?theme=, ou utiliser une page dédiée par thème
Code d’un ancien tuto incompatible Snippets obsolètes (sanitize, REST, hooks) Vérifier la doc officielle WP 6.9+, tester sur staging

Variante / alternative

Si vous voulez zéro code, vous pouvez :

  • Utiliser un plugin de champs (ex : ACF) pour gérer les meta, puis lier ces champs dans Divi.
  • Utiliser un plugin de filtres/loop builder (selon votre stack) pour générer des listes filtrées.

Ce que vous perdez souvent :

  • Un fallback propre (champ vide) sans multiplier les templates.
  • Une maîtrise fine de la requête (exclusion du post courant, no_found_rows, cache).
  • La portabilité (Divi → Elementor → Avada) avec la même base.

Conseils sécurité, performance et maintenance

  • Nonces et permissions : la meta box enregistre uniquement si nonce OK + droit edit_post. Ne supprimez pas ces checks.
  • Sanitization : même si register_post_meta sanitize, gardez une validation côté input. Les doubles filets évitent des surprises.
  • Cache : si vous activez un cache page, vérifiez que les query strings (ex : ?theme=) ne servent pas la même page à tout le monde.
  • Performance : utilisez no_found_rows quand vous n’avez pas besoin de pagination. Limitez les “related” à 3-6 items.
  • Maintenance : gardez ce code dans un plugin (pas dans le thème). Les templates Divi restent dans Divi, la logique métier reste dans WordPress.

Pour aller plus loin

  • Ajouter un endpoint REST custom (si vous avez une app front) en vous basant sur les meta déjà déclarées.
  • Ajouter un “bloc” Gutenberg dédié (si vous voulez sortir des shortcodes) qui rend la grille côté serveur.
  • Ajouter un système d’accès (membres) : ne mettez pas ça dans Divi, faites-le au niveau des capacités/roles + vérification côté serveur.
  • Remplacer le filtre query string par des URLs propres (ex : /bibliotheque/theme/seo/) via une page + rewrite (plus avancé).

Ressources


FAQ

Divi 5 peut-il afficher des champs meta sans plugin de champs ?

Oui, mais selon votre configuration, l’UI “dynamic content” peut être limitée. La voie la plus stable reste : meta déclarées + shortcode (ou module qui sait lire les meta). Ici, on a choisi des shortcodes pour les zones “logiques” (CTA, related).

Pourquoi déclarer les meta avec register_post_meta au lieu de faire update_post_meta et basta ?

Parce que vous fixez un type, une sanitization, et une exposition REST. Sur WordPress 6.9.4, c’est une base saine pour éviter les meta “sales” et les surprises en intégration.

Est-ce que ça marche avec un thème enfant Divi ?

Oui. Mieux : la logique est dans un plugin, donc vous n’êtes pas dépendant du thème enfant. Le thème enfant peut rester minimal (CSS/ajustements).

Pourquoi utiliser des shortcodes au lieu du module Blog/Posts Divi ?

Parce que vous contrôlez la requête (tax_query, exclusion, pagination), la perf (no_found_rows) et le cache. Les modules builder sont pratiques, mais dès que vous sortez des cas standards, vous perdez du temps à contourner.

Comment afficher le niveau et la durée dans Divi si je ne veux pas de shortcode ?

Vous pouvez créer un petit shortcode “meta” (ex : [bpcab_meta key="bpcab_niveau"]) ou utiliser le contenu dynamique si Divi le propose pour les champs personnalisés. Je préfère un rendu contrôlé (ex : “Durée : 15 min”) via shortcode pour éviter les champs vides.

Pourquoi ma page “Bibliothèque” ne pagine pas ?

Souvent parce que la query var paged n’est pas propagée sur une page statique, ou parce qu’un cache sert la page 1 partout. Testez ?paged=2, videz les caches, et vérifiez que vos permaliens ne sont pas cassés.

Est-ce compatible Elementor/Avada si je change de builder ?

Oui, car la base (CPT, taxos, meta, shortcodes) est builder-agnostic. Vous refaites les templates, mais vous ne refaites pas la structure de données.

Est-ce que je peux remplacer les shortcodes par des blocs Gutenberg ?

Oui. À partir de cette base, vous pouvez créer un bloc dynamique (render côté serveur) qui appelle la même logique de requête. C’est plus moderne, mais plus long à mettre en place.

Est-ce risqué côté sécurité de permettre une URL de téléchargement ?

Ça dépend de ce que vous mettez derrière. Si l’URL pointe vers un fichier public, pas de souci majeur. Si c’est un contenu restreint, il faut gérer l’accès côté serveur (capabilities + endpoints + contrôles), pas dans Divi.

Pourquoi mon shortcode affiche du HTML “non stylé” dans Divi ?

Parce que le CSS n’est pas chargé (mauvais chemin, cache, ou minify). Vérifiez que assets/ressources.css existe et que l’URL est correcte, puis videz les caches.