Si vous avez déjà tenté “d’embarquer WordPress dans WordPress” et que vous vous êtes retrouvé avec une page blanche, des boucles infinies ou un Fatal error: Cannot redeclare, vous avez touché un classique. On veut souvent afficher un mini-site dans une page, exécuter une requête WordPress “comme si on était ailleurs”, ou isoler un rendu pour un builder — et on finit par charger le cœur deux fois.

Le problème / Le besoin

L’expression “WordPress dans WordPress” recouvre plusieurs besoins réels :

  • Afficher le contenu d’un autre site WordPress (multisite ou site distant) dans une page, un module Divi/Elementor, ou une zone widget.
  • Exécuter un rendu WordPress isolé (un template, une boucle, un bloc) sans casser la requête principale ni les globals.
  • Générer une “vue” interne (HTML d’une page, d’un article, d’un CPT) pour un PDF, un email, un cache serveur, ou une API.

Le piège : beaucoup de développeurs essaient de faire un require 'wp-load.php' ou de simuler un second bootstrap WordPress. Sur WordPress 6.9.4 (avril 2026) avec PHP 8.1+, ça se termine souvent par :

  • des fonctions redéclarées (core ou plugins),
  • des hooks déclenchés deux fois,
  • une consommation mémoire qui explose,
  • des effets de bord (cache, cookies, query vars, redirections).

À la fin, vous saurez mettre en place une solution propre selon 3 scénarios : rendu interne isolé, rendu d’un autre site du même multisite, et rendu d’un site distant — sans recharger WordPress inutilement.

Résumé rapide

  • On évite de recharger WordPress (pas de require wp-load.php dans un plugin/thème).
  • On crée un shortcode + une route REST optionnelle pour produire un rendu “encapsulé”.
  • On isole la boucle avec WP_Query et on restaure les globals via wp_reset_postdata().
  • Pour le multisite, on bascule temporairement de blog avec switch_to_blog() puis restore_current_blog().
  • Pour un site distant, on passe par wp_remote_get() (ou mieux : une route REST exposée par le site distant) et on met en cache avec des transients.

Quand utiliser cette solution

  • Vous devez afficher un “mini-article” (titre + extrait + image) dans une page builder sans dupliquer le contenu.
  • Vous avez un multisite et vous voulez afficher les derniers articles du site A dans le site B.
  • Vous générez des emails transactionnels depuis WordPress et vous voulez réutiliser un template existant.
  • Vous voulez fournir un embed interne stable (HTML) pour une app front, sans exposer toute l’API.

J’ai souvent vu ce besoin sur des sites Avada/Divi où le client veut “une section blog” dans une landing page, mais sans que le builder ne gère correctement les requêtes ou sans multiplier les modules.

Quand ne PAS utiliser cette solution

  • Si vous voulez juste afficher des articles : un bloc Query Loop (éditeur de site) ou un widget natif du builder suffit souvent.
  • Si votre objectif est l’authentification SSO ou un “portail” : regardez plutôt OAuth/JWT et une architecture headless.
  • Si vous voulez “intégrer un WP complet” dans une page (wp-admin, thèmes, etc.) : ce n’est pas réaliste. Utilisez un sous-domaine ou un reverse proxy (Nginx) et acceptez les contraintes.
  • Si vous pensez “je vais inclure wp-load.php pour accéder aux fonctions WP depuis un script” : faites plutôt un endpoint REST sécurisé ou un WP-CLI.

Prérequis / avant de commencer

  • WordPress 6.9.4 (ou plus récent), PHP 8.1+.
  • Un environnement de test : local (WP-ENV, Local, DDEV) ou staging.
  • Une sauvegarde (fichiers + base) avant déploiement.
  • Si vous utilisez un plugin de snippets : attention, un snippet cassé peut bloquer le back-office. Gardez un accès FTP/SSH.

Sources officielles utiles :

Sécurité : dès qu’on expose du rendu via REST, on réfléchit aux permissions, au caching, et à la surface d’attaque (scraping, amplification, fuite de contenu privé).

L’approche naïve (et pourquoi l’éviter)

Le code que je vois encore en 2026, copié d’anciens tutos, ressemble à ça :

<?php
// ❌ À NE PAS FAIRE : recharger WordPress depuis WordPress.
require_once __DIR__ . '/wp-load.php';

$post_id = (int) $_GET['post_id'];
$post    = get_post( $post_id );

echo apply_filters( 'the_content', $post->post_content );
?>

Problèmes concrets :

  • Double bootstrap : si ce code est exécuté depuis un contexte déjà WordPress (plugin, thème, shortcode), vous rechargez core + plugins. Résultat : collisions, hooks doublés, mémoire.
  • Sécurité : $_GET sans nonce ni permission. Vous venez d’exposer du contenu potentiellement privé.
  • Performance : aucun cache. Sur une page builder très visitée, vous vous créez un DDoS “interne”.
  • Maintenabilité : dépend de chemins relatifs. Une migration ou un mu-plugin et tout casse.

La bonne approche — tutoriel pas à pas

Objectif technique

On va créer un mini-plugin qui fournit :

  • un shortcode [wp_in_wp] pour afficher un rendu encapsulé,
  • une route REST /wp-json/wp-in-wp/v1/render pour récupérer ce rendu en AJAX (utile pour builders),
  • un support multisite (switch de blog),
  • un support site distant (via REST distant + cache transient).

Étape 1 — Créez le plugin

Créez un fichier :

  • wp-content/plugins/wp-in-wp-render/wp-in-wp-render.php

Activez-le dans Extensions. Travaillez sur staging : un plugin qui parse des shortcodes et expose une route REST peut casser une page si vous faites une erreur de syntaxe (un point-virgule manquant, typiquement).

Étape 2 — Définissez un rendu interne “isolé”

L’idée : générer un HTML précis à partir d’un post_id et d’options (afficher l’image, l’extrait, etc.), sans toucher à la requête principale. On utilise WP_Query (ou get_post) et on échappe tout ce qui doit l’être.

Étape 3 — Ajoutez le mode multisite

Si vous êtes en multisite, on accepte un paramètre blog_id. On bascule temporairement avec switch_to_blog(), on rend, puis on restaure. C’est fiable, mais il faut être discipliné : toujours appeler restore_current_blog(), même en cas d’erreur.

Étape 4 — Ajoutez le mode distant

Pour un site distant, on ne “charge” pas le WordPress distant. On appelle un endpoint distant (idéalement une route REST que vous contrôlez sur le site distant) et on cache la réponse. Si vous n’avez pas la main sur le site distant, vous pouvez consommer /wp-json/wp/v2/posts, mais vous serez limité par l’auth, les champs, et la structure.

Étape 5 — Exposez une route REST (optionnelle mais pratique)

Les builders chargent parfois des sections en AJAX, ou vous voulez éviter de re-rendre côté PHP dans une page très lourde. Une route REST permet aussi de consommer le rendu depuis un script externe (avec auth).

Code complet

Copiez-collez ce fichier complet dans wp-content/plugins/wp-in-wp-render/wp-in-wp-render.php, puis activez l’extension.

<?php
/**
 * Plugin Name: WP in WP Render (pratique)
 * Description: Rend du contenu WordPress "dans WordPress" sans recharger le core : shortcode + REST + multisite + distant.
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 * Author: Votre Nom
 * License: GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

final class BPCAB_WP_In_WP_Render {

	const REST_NAMESPACE = 'wp-in-wp/v1';
	const TRANSIENT_PREFIX = 'bpcab_wpinwp_';

	public static function init(): void {
		add_shortcode( 'wp_in_wp', array( __CLASS__, 'shortcode' ) );

		add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );

		// Optionnel : un petit style minimal pour que le rendu soit présentable partout.
		add_action( 'wp_enqueue_scripts', array( __CLASS__, 'register_assets' ) );
	}

	public static function register_assets(): void {
		$handle = 'bpcab-wp-in-wp';
		wp_register_style(
			$handle,
			plugins_url( 'assets/wp-in-wp.css', __FILE__ ),
			array(),
			'1.0.0'
		);
	}

	/**
	 * Shortcode : [wp_in_wp post_id="123" mode="local|multisite|remote" blog_id="2" remote_url="https://exemple.com" template="card"]
	 */
	public static function shortcode( array $atts = array() ): string {
		$atts = shortcode_atts(
			array(
				'mode'       => 'local',     // local | multisite | remote
				'post_id'    => 0,
				'blog_id'    => 0,           // multisite uniquement
				'remote_url' => '',          // remote uniquement (base URL du site)
				'template'   => 'card',      // card | excerpt | title
				'thumb'      => '1',         // 1/0
				'excerpt'    => '1',         // 1/0
				'cache_ttl'  => 300,         // secondes
				'class'      => '',
			),
			$atts,
			'wp_in_wp'
		);

		$mode      = sanitize_key( (string) $atts['mode'] );
		$post_id   = absint( $atts['post_id'] );
		$blog_id   = absint( $atts['blog_id'] );
		$template  = sanitize_key( (string) $atts['template'] );
		$thumb     = ( (string) $atts['thumb'] === '1' );
		$excerpt   = ( (string) $atts['excerpt'] === '1' );
		$cache_ttl = max( 0, absint( $atts['cache_ttl'] ) );
		$class     = sanitize_html_class( (string) $atts['class'] );

		if ( $post_id <= 0 ) {
			return self::wrap_error( 'post_id manquant ou invalide.' );
		}

		$args = array(
			'template' => $template,
			'thumb'    => $thumb,
			'excerpt'  => $excerpt,
			'class'    => $class,
		);

		try {
			if ( $mode === 'multisite' ) {
				if ( ! is_multisite() ) {
					return self::wrap_error( 'Mode multisite demandé, mais WordPress n’est pas en multisite.' );
				}
				if ( $blog_id <= 0 ) {
					return self::wrap_error( 'blog_id manquant pour le mode multisite.' );
				}

				return self::render_multisite( $blog_id, $post_id, $args, $cache_ttl );
			}

			if ( $mode === 'remote' ) {
				$remote_url = esc_url_raw( (string) $atts['remote_url'] );
				if ( empty( $remote_url ) ) {
					return self::wrap_error( 'remote_url manquant pour le mode remote.' );
				}
				return self::render_remote( $remote_url, $post_id, $args, $cache_ttl );
			}

			// Par défaut : local.
			return self::render_local( $post_id, $args );

		} catch ( Throwable $e ) {
			// On évite de casser la page en front. En debug, loggez l’exception.
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				error_log( '[WP in WP Render] ' . $e->getMessage() );
			}
			return self::wrap_error( 'Erreur de rendu. Consultez les logs si WP_DEBUG est activé.' );
		}
	}

	public static function register_routes(): void {
		register_rest_route(
			self::REST_NAMESPACE,
			'/render',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( __CLASS__, 'rest_render' ),
					'permission_callback' => array( __CLASS__, 'rest_permissions' ),
					'args'                => array(
						'mode' => array(
							'type'              => 'string',
							'required'          => false,
							'default'           => 'local',
							'sanitize_callback' => 'sanitize_key',
						),
						'post_id' => array(
							'type'              => 'integer',
							'required'          => true,
							'sanitize_callback' => 'absint',
						),
						'blog_id' => array(
							'type'              => 'integer',
							'required'          => false,
							'sanitize_callback' => 'absint',
						),
						'remote_url' => array(
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'esc_url_raw',
						),
						'template' => array(
							'type'              => 'string',
							'required'          => false,
							'default'           => 'card',
							'sanitize_callback' => 'sanitize_key',
						),
						'thumb' => array(
							'type'              => 'boolean',
							'required'          => false,
							'default'           => true,
							'sanitize_callback' => array( __CLASS__, 'sanitize_bool' ),
						),
						'excerpt' => array(
							'type'              => 'boolean',
							'required'          => false,
							'default'           => true,
							'sanitize_callback' => array( __CLASS__, 'sanitize_bool' ),
						),
						'cache_ttl' => array(
							'type'              => 'integer',
							'required'          => false,
							'default'           => 300,
							'sanitize_callback' => 'absint',
						),
					),
				),
			)
		);
	}

	public static function sanitize_bool( mixed $value ): bool {
		// REST peut envoyer "true"/"false", 1/0, etc.
		return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
	}

	public static function rest_permissions( WP_REST_Request $request ): bool {
		/**
		 * Politique par défaut :
		 * - autoriser en public si le post est publié et public
		 * - sinon exiger la capacité de lecture du post (edit_post est trop strict pour certains rôles)
		 *
		 * Vous pouvez durcir : exiger un nonce (wp_rest) côté front ou un token applicatif.
		 */
		$post_id = absint( $request->get_param( 'post_id' ) );
		if ( $post_id <= 0 ) {
			return false;
		}

		$post = get_post( $post_id );
		if ( ! $post ) {
			return false;
		}

		if ( $post->post_status === 'publish' && $post->post_password === '' ) {
			return true;
		}

		// Si non publié/protégé, on vérifie les droits.
		return current_user_can( 'read_post', $post_id );
	}

	public static function rest_render( WP_REST_Request $request ): WP_REST_Response {
		$mode      = sanitize_key( (string) $request->get_param( 'mode' ) );
		$post_id   = absint( $request->get_param( 'post_id' ) );
		$blog_id   = absint( $request->get_param( 'blog_id' ) );
		$remote_url = (string) $request->get_param( 'remote_url' );

		$args = array(
			'template' => sanitize_key( (string) $request->get_param( 'template' ) ),
			'thumb'    => (bool) $request->get_param( 'thumb' ),
			'excerpt'  => (bool) $request->get_param( 'excerpt' ),
			'class'    => '',
		);

		$cache_ttl = max( 0, absint( $request->get_param( 'cache_ttl' ) ) );

		if ( $post_id <= 0 ) {
			return new WP_REST_Response(
				array(
					'ok'    => false,
					'error' => 'post_id invalide.',
				),
				400
			);
		}

		if ( $mode === 'multisite' ) {
			if ( ! is_multisite() ) {
				return new WP_REST_Response(
					array( 'ok' => false, 'error' => 'Multisite non activé.' ),
					400
				);
			}
			if ( $blog_id <= 0 ) {
				return new WP_REST_Response(
					array( 'ok' => false, 'error' => 'blog_id manquant.' ),
					400
				);
			}

			$html = self::render_multisite( $blog_id, $post_id, $args, $cache_ttl );
			return new WP_REST_Response( array( 'ok' => true, 'html' => $html ), 200 );
		}

		if ( $mode === 'remote' ) {
			$remote_url = esc_url_raw( $remote_url );
			if ( empty( $remote_url ) ) {
				return new WP_REST_Response(
					array( 'ok' => false, 'error' => 'remote_url manquant.' ),
					400
				);
			}

			$html = self::render_remote( $remote_url, $post_id, $args, $cache_ttl );
			return new WP_REST_Response( array( 'ok' => true, 'html' => $html ), 200 );
		}

		$html = self::render_local( $post_id, $args );
		return new WP_REST_Response( array( 'ok' => true, 'html' => $html ), 200 );
	}

	private static function render_local( int $post_id, array $args ): string {
		$post = get_post( $post_id );
		if ( ! $post ) {
			return self::wrap_error( 'Article introuvable.' );
		}

		// Respecte les règles de visibilité.
		if ( $post->post_status !== 'publish' ) {
			if ( ! current_user_can( 'read_post', $post_id ) ) {
				return self::wrap_error( 'Contenu non accessible.' );
			}
		}

		return self::render_post_html( $post_id, $args );
	}

	private static function render_multisite( int $blog_id, int $post_id, array $args, int $cache_ttl ): string {
		$cache_key = self::TRANSIENT_PREFIX . 'ms_' . $blog_id . '_' . $post_id . '_' . md5( wp_json_encode( $args ) );

		if ( $cache_ttl > 0 ) {
			$cached = get_transient( $cache_key );
			if ( is_string( $cached ) && $cached !== '' ) {
				return $cached;
			}
		}

		switch_to_blog( $blog_id );
		try {
			$html = self::render_local( $post_id, $args );
		} finally {
			restore_current_blog();
		}

		if ( $cache_ttl > 0 ) {
			set_transient( $cache_key, $html, $cache_ttl );
		}

		return $html;
	}

	private static function render_remote( string $remote_url, int $post_id, array $args, int $cache_ttl ): string {
		/**
		 * Stratégie recommandée :
		 * - le site distant expose un endpoint "render" contrôlé (même plugin)
		 * - on récupère directement du HTML déjà “propre”
		 *
		 * Fallback :
		 * - si l’endpoint n’existe pas, on tente wp/v2/posts/{id} et on rend localement (limité).
		 */
		$remote_url = untrailingslashit( $remote_url );

		$cache_key = self::TRANSIENT_PREFIX . 'remote_' . md5( $remote_url . '|' . $post_id . '|' . wp_json_encode( $args ) );
		if ( $cache_ttl > 0 ) {
			$cached = get_transient( $cache_key );
			if ( is_string( $cached ) && $cached !== '' ) {
				return $cached;
			}
		}

		// 1) Endpoint dédié (si vous installez le plugin aussi sur le site distant).
		$endpoint = $remote_url . '/wp-json/' . self::REST_NAMESPACE . '/render?mode=local&post_id=' . $post_id
			. '&template=' . rawurlencode( (string) $args['template'] )
			. '&thumb=' . ( $args['thumb'] ? '1' : '0' )
			. '&excerpt=' . ( $args['excerpt'] ? '1' : '0' );

		$response = wp_remote_get(
			$endpoint,
			array(
				'timeout' => 8,
				'headers' => array(
					'Accept' => 'application/json',
				),
			)
		);

		if ( ! is_wp_error( $response ) ) {
			$code = (int) wp_remote_retrieve_response_code( $response );
			$body = (string) wp_remote_retrieve_body( $response );

			if ( $code >= 200 && $code < 300 && $body !== '' ) {
				$data = json_decode( $body, true );
				if ( is_array( $data ) && ! empty( $data['ok'] ) && isset( $data['html'] ) && is_string( $data['html'] ) ) {
					$html = $data['html'];

					if ( $cache_ttl > 0 ) {
						set_transient( $cache_key, $html, $cache_ttl );
					}
					return $html;
				}
			}
		}

		// 2) Fallback : WP REST posts. On rend une “carte” minimaliste.
		$fallback = $remote_url . '/wp-json/wp/v2/posts/' . $post_id . '?_fields=id,link,title,excerpt,featured_media';
		$response = wp_remote_get(
			$fallback,
			array(
				'timeout' => 8,
				'headers' => array(
					'Accept' => 'application/json',
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			return self::wrap_error( 'Site distant injoignable : ' . esc_html( $response->get_error_message() ) );
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = (string) wp_remote_retrieve_body( $response );

		if ( $code < 200 || $code >= 300 || $body === '' ) {
			return self::wrap_error( 'Réponse distante invalide (HTTP ' . esc_html( (string) $code ) . ').' );
		}

		$data = json_decode( $body, true );
		if ( ! is_array( $data ) || empty( $data['id'] ) ) {
			return self::wrap_error( 'JSON distant invalide.' );
		}

		$title = '';
		if ( isset( $data['title']['rendered'] ) ) {
			// Le contenu "rendered" peut contenir du HTML.
			$title = wp_strip_all_tags( (string) $data['title']['rendered'] );
		}

		$excerpt = '';
		if ( isset( $data['excerpt']['rendered'] ) ) {
			$excerpt = wp_strip_all_tags( (string) $data['excerpt']['rendered'] );
		}

		$link = isset( $data['link'] ) ? esc_url( (string) $data['link'] ) : '';

		$html = self::render_card_from_remote_fields( $title, $excerpt, $link, $args );

		if ( $cache_ttl > 0 ) {
			set_transient( $cache_key, $html, $cache_ttl );
		}

		return $html;
	}

	private static function render_card_from_remote_fields( string $title, string $excerpt, string $link, array $args ): string {
		$template = (string) ( $args['template'] ?? 'card' );
		$class    = (string) ( $args['class'] ?? '' );

		$classes = trim( 'bpcab-wp-in-wp bpcab-wp-in-wp--remote bpcab-wp-in-wp--' . $template . ' ' . $class );

		ob_start();
		?>
		<div class="<?php echo esc_attr( $classes ); ?>">
			<div class="bpcab-wp-in-wp__body">
				<p class="bpcab-wp-in-wp__title">
					<a href="<?php echo esc_url( $link ); ?>" target="_blank" rel="noopener">
						<?php echo esc_html( $title ); ?>
					</a>
				</p>

				<?php if ( ! empty( $args['excerpt'] ) && $excerpt !== '' ) : ?>
					<p class="bpcab-wp-in-wp__excerpt"><?php echo esc_html( $excerpt ); ?></p>
				<?php endif; ?>
			</div>
		</div>
		<?php
		return (string) ob_get_clean();
	}

	private static function render_post_html( int $post_id, array $args ): string {
		$template = (string) ( $args['template'] ?? 'card' );
		$thumb    = ! empty( $args['thumb'] );
		$excerpt  = ! empty( $args['excerpt'] );
		$class    = (string) ( $args['class'] ?? '' );

		$classes = trim( 'bpcab-wp-in-wp bpcab-wp-in-wp--local bpcab-wp-in-wp--' . $template . ' ' . $class );

		// On charge la feuille de style si elle existe.
		if ( wp_style_is( 'bpcab-wp-in-wp', 'registered' ) ) {
			wp_enqueue_style( 'bpcab-wp-in-wp' );
		}

		$title = get_the_title( $post_id );
		$link  = get_permalink( $post_id );

		$img_html = '';
		if ( $thumb && has_post_thumbnail( $post_id ) ) {
			// wp_get_attachment_image() gère déjà l’escaping des attributs.
			$img_html = wp_get_attachment_image( get_post_thumbnail_id( $post_id ), 'medium', false, array(
				'class' => 'bpcab-wp-in-wp__thumb',
				'loading' => 'lazy',
			 ) );
		}

		$excerpt_text = '';
		if ( $excerpt ) {
			$excerpt_text = get_the_excerpt( $post_id );
		}

		ob_start();
		?>
		<div class="<?php echo esc_attr( $classes ); ?>">
			<?php if ( $img_html ) : ?>
				<div class="bpcab-wp-in-wp__media">
					<a href="<?php echo esc_url( $link ); ?>"><?php echo $img_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></a>
				</div>
			<?php endif; ?>

			<div class="bpcab-wp-in-wp__body">
				<?php if ( $template === 'title' ) : ?>
					<p class="bpcab-wp-in-wp__title">
						<a href="<?php echo esc_url( $link ); ?>"><?php echo esc_html( $title ); ?></a>
					</p>
				<?php else : ?>
					<p class="bpcab-wp-in-wp__title">
						<a href="<?php echo esc_url( $link ); ?>"><?php echo esc_html( $title ); ?></a>
					</p>

					<?php if ( $excerpt_text !== '' && $template !== 'title' ) : ?>
						<p class="bpcab-wp-in-wp__excerpt"><?php echo esc_html( $excerpt_text ); ?></p>
					<?php endif; ?>
				<?php endif; ?>
			</div>
		</div>
		<?php
		return (string) ob_get_clean();
	}

	private static function wrap_error( string $message ): string {
		return '<div class="bpcab-wp-in-wp bpcab-wp-in-wp--error"><strong>WP in WP:</strong> ' . esc_html( $message ) . '</div>';
	}
}

BPCAB_WP_In_WP_Render::init();

Optionnel : ajoutez un fichier CSS (sinon le rendu est brut). Créez :

  • wp-content/plugins/wp-in-wp-render/assets/wp-in-wp.css
.bpcab-wp-in-wp{border:1px solid rgba(0,0,0,.08);padding:14px;border-radius:10px;background:#fff}
.bpcab-wp-in-wp__title{margin:0 0 8px 0;font-weight:600}
.bpcab-wp-in-wp__excerpt{margin:0;color:rgba(0,0,0,.75)}
.bpcab-wp-in-wp__media{margin:0 0 10px 0}
.bpcab-wp-in-wp__thumb{display:block;max-width:100%;height:auto;border-radius:8px}
.bpcab-wp-in-wp--error{background:#fff6f6;border-color:#f2b8b8}

Explication du code

1) Pourquoi un shortcode ?

Parce que c’est universel. Divi, Elementor et Avada savent tous insérer un shortcode, et ça reste compatible avec l’éditeur de blocs. Dans la pratique, c’est le meilleur “format de glue” quand vous devez intégrer un rendu dans des environnements hétérogènes.

2) Pourquoi une route REST en plus ?

Quand un builder charge une section dynamiquement, ou quand vous voulez rafraîchir un rendu sans recharger la page, la route REST est plus propre qu’un admin-ajax.php bricolé. Et côté WordPress 6.9+, la REST API est un standard stable.

On enregistre la route avec register_rest_route() et on définit :

  • args avec sanitize_callback (évite les entrées sales),
  • permission_callback pour éviter de divulguer des contenus non publiés,
  • une réponse JSON simple : { ok: true, html: "..." }.

Référence : register_rest_route().

3) Isolation : pas de “deuxième WordPress”

Le cœur du “WordPress dans WordPress”, c’est d’accepter une contrainte : vous êtes déjà dans WordPress, donc vous utilisez les APIs existantes (posts, permaliens, thumbnails) au lieu de recharger wp-load.php. C’est ce qui évite 80% des bugs.

4) Multisite : switch_to_blog() avec finally

Sur multisite, switch_to_blog() modifie le contexte global (DB prefix, options, URLs…). Le finally garantit que restore_current_blog() sera appelé même si une exception survient. Sans ça, vous obtenez des bugs “fantômes” : liens qui pointent vers le mauvais site, options incohérentes, cache object pollué.

Référence : restore_current_blog().

5) Distant : wp_remote_get() + cache transient

Pour un site distant, on fait un appel HTTP. On met en cache via set_transient() pour éviter de dépendre du réseau à chaque vue. C’est une règle de survie si vous affichez ce rendu sur une homepage.

Référence : set_transient().

6) Sanitization / escaping

  • Entrées : absint(), sanitize_key(), esc_url_raw(), sanitize_html_class().
  • Sorties : esc_html(), esc_attr(), esc_url().

Quand on consomme du JSON distant, on se méfie : même si l’API WP renvoie du HTML “rendered”, vous ne voulez pas l’injecter tel quel dans votre page. Ici je fais un wp_strip_all_tags() pour un rendu minimaliste et sûr.

Variantes et cas d’usage

Variante 1 — Afficher une liste (derniers articles) au lieu d’un seul post_id

Si votre besoin est “3 derniers articles”, ne bouclez pas sur trois shortcodes (ça multiplie les rendus et les caches). Ajoutez un second shortcode dédié, ou étendez celui-ci avec count + post_type et un WP_Query.

Exemple (ajout rapide) : créez un nouveau shortcode [wp_in_wp_list] qui fait un WP_Query et appelle render_post_html() pour chaque ID. Attention à wp_reset_postdata() si vous utilisez the_post().

Variante 2 — Rendu “template part” plutôt qu’une carte HTML

Si votre thème a déjà un template part (ex: template-parts/card.php), vous pouvez remplacer render_post_html() par :

  • un set_query_var() + get_template_part(),
  • ou un locate_template() + load_template().

Je le fais souvent sur des sites très designés : vous réutilisez le markup exact du thème, et vous évitez de maintenir deux cartes différentes.

Variante 3 — Récupérer le rendu via JS (fetch) côté front

Si vous voulez charger le rendu après coup (lazy), vous pouvez appeler :

/wp-json/wp-in-wp/v1/render?mode=local&post_id=123

Pour du contenu non public, vous devrez passer un nonce REST (wp_rest) et l’envoyer dans l’en-tête X-WP-Nonce. Référence : Authentification REST.

Compatibilité Divi 5 / Elementor / Avada

Divi 5

  • Utilisez un module “Code” ou “Texte” et collez le shortcode.
  • Si Divi met en cache agressivement : videz le cache Divi (Divi > Theme Options > Builder) et votre cache serveur.

Exemple :

[wp_in_wp mode="local" post_id="123" template="card" thumb="1" excerpt="1"]

Elementor

  • Widget “Shortcode” : collez le shortcode.
  • Si vous utilisez l’appel REST en JS : Elementor peut minifier/différer des scripts. Faites vos tests sans optimisation, puis réactivez.

Avada (Fusion Builder)

  • Élément “Shortcode” ou “Text Block”.
  • Si vous avez un cache Avada : purge après modification des paramètres cache_ttl.

Vérifications après mise en place

  • Testez une page simple avec un seul shortcode (pas dans une mega page builder au début).
  • Vérifiez l’HTML généré : présence du conteneur .bpcab-wp-in-wp.
  • Testez un post publié et un post brouillon :
    • en déconnecté : le brouillon doit afficher “Contenu non accessible.”
    • en admin : le brouillon doit s’afficher (si vous avez read_post).
  • Multisite : vérifiez que les liens générés pointent vers le bon domaine/site.
  • Remote : coupez temporairement le réseau (ou changez l’URL) pour confirmer que le cache transient évite des timeouts répétés.

Si ça ne marche pas

Procédure rapide (dans l’ordre)

  1. Désactivez le cache (plugin de cache, cache serveur, cache builder) et retestez.
  2. Activez WP_DEBUG sur staging :
    • define('WP_DEBUG', true);
    • define('WP_DEBUG_LOG', true);
    • define('WP_DEBUG_DISPLAY', false);
  3. Vérifiez wp-content/debug.log : erreurs de syntaxe, classes non trouvées, etc.
  4. Confirmez que le plugin est bien chargé (Extensions activées) et que le shortcode n’est pas filtré par un champ WYSIWYG.
  5. Si REST : testez la route dans le navigateur et regardez le code HTTP.

Tableau de diagnostic

Symptôme Cause probable Vérification Solution
Le shortcode s’affiche en texte (non interprété) Le builder/zone n’exécute pas les shortcodes Testez dans un bloc “Shortcode” natif Utilisez le widget/élément dédié “Shortcode” (Divi/Elementor/Avada)
Page blanche après activation Erreur PHP (point-virgule, parenthèse) Consultez debug.log ou l’erreur serveur Corrigez la syntaxe, désactivez le plugin via FTP si besoin
“Contenu non accessible.” sur un post publié Post protégé par mot de passe ou statut non publish Vérifiez post_status et mot de passe Publiez le post ou ajustez la permission_callback REST
En multisite, les liens pointent vers le mauvais site restore_current_blog() non exécuté (ou conflit) Ajoutez des logs, vérifiez le code Gardez le try/finally, évitez les return avant restore
Remote : timeout / rendu lent HTTP distant lent + pas de cache Mesurez TTFB, regardez les transients Augmentez cache_ttl, mettez en place l’endpoint dédié sur le site distant
Le CSS ne charge pas Fichier manquant ou chemin incorrect Inspectez l’onglet réseau Créez assets/wp-in-wp.css ou enlevez l’enqueue

Pièges et erreurs courantes

Erreur Cause Solution
Copier le code dans functions.php du thème parent Mise à jour du thème = code perdu Utilisez un plugin (comme ici) ou un thème enfant
Fatal error: Cannot redeclare… Vous avez inclus wp-load.php ou chargé deux fois le même fichier Ne rechargez jamais WordPress depuis WordPress ; utilisez les APIs internes
Shortcode OK sur une page, KO sur une autre Conflit de cache builder / minification Testez sans optimisation, puis réactivez une par une
Hook inadapté pour enregistrer la route REST Code mis sur init au lieu de rest_api_init Gardez add_action('rest_api_init', ...)
Erreur “JSON distant invalide” Le site distant renvoie HTML (maintenance, WAF, redirection) Inspectez la réponse HTTP ; ajustez l’URL ; ajoutez un User-Agent si nécessaire
Le rendu remote contient du HTML cassé / inattendu Vous affichez rendered sans filtrer Strip tags ou passez par un endpoint distant dédié qui renvoie un HTML maîtrisé
Test en production sans sauvegarde Pression / habitude Déployez sur staging, puis production avec rollback
Erreur liée à PHP trop ancien Serveur en PHP 7.x Passez en PHP 8.1+ (requis par le plugin) ou adaptez le code (moins recommandé)
Permaliens bizarres après multisite switch Un plugin modifie des globals et ne les restaure pas Désactivez temporairement les plugins, isolez le conflit, loggez le contexte

Conseils sécurité, performance et maintenance

  • Ne rendez pas de contenu privé en public : la permission_callback de la route REST est votre garde-fou. Durcissez si nécessaire (nonce REST, authentification applicative).
  • Cachez le remote : sans transient, un simple module sur une homepage peut déclencher des appels HTTP à chaque visite.
  • Limitez les champs REST avec _fields sur le fallback wp/v2 : moins de bande passante, moins de parsing.
  • Surveillez la volumétrie : si vous affichez 20 embeds distants, vous devez passer à une stratégie batch (un endpoint qui renvoie une liste) ou à un cache persistant (object cache Redis).
  • Maintenance : conservez ce plugin dans Git et versionnez. J’ai vu trop de “snippets” non tracés devenir impossibles à auditer après 6 mois.
  • SEO : si vous chargez via REST côté client, Google peut ne pas indexer le contenu selon le contexte. Pour du SEO, préférez un rendu serveur (shortcode) ou du SSR.

Pour suivre l’évolution du core (REST, multisite, WP_Query), gardez un œil sur :

Ressources

FAQ

“WordPress dans WordPress”, ça veut dire charger un WP complet dans une page ?

Dans la majorité des cas, non. Le besoin réel est de rendre un contenu WordPress dans un autre contexte. Charger un second WordPress dans le même process PHP est presque toujours une mauvaise idée.

Puis-je utiliser ça pour afficher une page entière (pas juste une carte) ?

Oui, mais je vous conseille de créer un template dédié (ex: template="full") et de contrôler précisément ce que vous rendez. Évitez d’injecter the_content brut d’un site distant.

Pourquoi ne pas utiliser un iframe ?

Un iframe est parfois la meilleure solution (isolation CSS/JS), mais vous perdez l’intégration SEO, le style, et vous introduisez des contraintes (CSP, responsive, cookies, cross-domain).

Le shortcode est lent sur une page Divi très chargée. Que faire ?

Commencez par activer un cache (transient, puis object cache persistant). Ensuite, réduisez le nombre d’instances du shortcode et préférez une variante “liste” qui rend N posts en une seule passe.

Je veux afficher des articles d’un site distant privé. Comment sécuriser ?

Exposez une route REST sur le site distant qui exige une authentification (application password, token, ou OAuth). Évitez de rendre public un endpoint qui divulgue des brouillons.

Pourquoi utiliser wp_strip_all_tags() sur le distant ?

Parce que le HTML distant n’est pas sous votre contrôle. Même si WordPress filtre, vous ne savez pas quels plugins injectent quoi. Strip tags = surface d’attaque réduite. Si vous voulez du HTML riche, faites-le via un endpoint distant “render” maîtrisé.

Est-ce compatible avec un plugin de cache type WP Rocket / LiteSpeed Cache ?

Oui, mais souvenez-vous : si le rendu dépend de l’utilisateur (brouillons visibles pour les admins), un cache page peut servir une version “admin” à un visiteur. Dans ce cas, désactivez le cache sur ces pages ou forcez un rendu public uniquement.

Que faire si je vois “Réponse distante invalide (HTTP 403)” ?

403 est souvent un WAF, une protection anti-bot, ou une restriction REST. Testez l’URL dans un navigateur, puis depuis le serveur (curl). Vous devrez peut-être autoriser l’IP serveur ou ajouter une auth.

Je peux mettre ce code dans un mu-plugin ?

Oui, et c’est même une bonne option si vous voulez éviter qu’un client désactive l’extension. Placez-le dans wp-content/mu-plugins/ et adaptez les chemins d’assets (ou supprimez le CSS plugin).

Je reçois “Call to undefined function register_rest_route()”

Vous avez probablement exécuté ce code trop tôt (hors contexte WP) ou dans un script indépendant. Ce plugin suppose qu’il tourne dans WordPress. Si vous devez appeler WordPress depuis l’extérieur, utilisez plutôt la REST API du site.