Si vous avez déjà collé un bout de “schema” dans l’en-tête et vu Google ignorer vos rich results, le problème vient souvent de deux choses : un JSON-LD incomplet (ou invalide) et une injection au mauvais endroit dans le cycle WordPress. On va corriger ça proprement, en code, pour WordPress 6.9.4 et PHP 8.1+.
Ce qu’on va construire
Vous allez mettre en place une couche de données structurées (Schema.org) en JSON-LD injectée dans <head>, sans plugin “usine à gaz”, avec :
- Article/BlogPosting sur les articles (titre, image, auteur, dates, éditeur).
- BreadcrumbList cohérent (utile même si votre thème n’affiche pas de fil d’Ariane).
- Organization + WebSite + SearchAction (sitelinks search box).
- Un mécanisme de déduplication (éviter les schémas en double avec Yoast/RankMath/SEOPress).
- Des filtres pour personnaliser (logo, réseaux sociaux, types de contenu).
C’est destiné aux sites éditoriaux (blogs, médias, sites vitrine avec blog), et ça reste compatible avec Divi 5, Elementor et Avada, parce qu’on s’appuie sur les hooks core (pas sur le builder).
À la fin, vous saurez : où injecter le JSON-LD, comment construire un graphe Schema robuste, comment éviter les doublons, et comment tester correctement.
Résumé rapide
- On injecte du JSON-LD via
wp_headavec un MU-plugin (chargé tôt, stable). - On construit un @graph : WebSite + Organization + BreadcrumbList + BlogPosting.
- On normalise les URLs, dates ISO 8601, images, et on ajoute des @id stables.
- On évite les conflits avec les plugins SEO en détectant leur sortie (et en désactivant nos blocs si besoin).
- On valide avec le Rich Results Test et le Schema Markup Validator.
Quand utiliser cette solution
- Vous voulez maîtriser vos données structurées (pas dépendre d’un plugin qui change de logique).
- Votre thème/builder ne sort rien (ou sort un schema incomplet), et vous voulez un socle propre.
- Vous avez des besoins spécifiques : auteur invité, plusieurs logos, breadcrumbs custom, CPT.
- Vous avez déjà un plugin SEO mais vous voulez remplacer une partie de son schema (ou le compléter) avec une logique claire.
Dans mon expérience, c’est particulièrement utile sur des sites Divi/Avada “anciens” migrés : le SEO est là, mais le schema est soit absent, soit dupliqué, soit incohérent entre templates.
Quand ne PAS utiliser cette solution
- Vous débutez en PHP et vous n’avez pas d’environnement de test : une parenthèse oubliée peut casser le site.
- Vous utilisez déjà Yoast/RankMath/SEOPress et vous êtes satisfait des rich results : ne créez pas un second système.
- Vous avez une stratégie schema avancée (Product, FAQ, HowTo, LocalBusiness) pilotée par un plugin métier : mieux vaut étendre ce plugin via ses hooks.
- Vous ne pouvez pas contrôler le cache (CDN agressif) : vous risquez de tester des versions obsolètes et de “chasser un fantôme”.
Avant de commencer (prérequis)
Pré-requis techniques
- WordPress 6.9.4 (avril 2026) ou supérieur.
- PHP 8.1+ (8.2/8.3 recommandé si votre hébergeur suit).
- Accès FTP/SSH ou gestionnaire de fichiers de l’hébergeur.
Sauvegarde et environnement
- Faites une sauvegarde fichiers + base (ou un snapshot si vous êtes sur un hébergeur managé).
- Testez d’abord sur une préproduction/staging. J’ai souvent vu un snippet “simple” casser un site parce qu’il était collé dans le mauvais fichier.
Outils utiles
- Un plugin de debug : Query Monitor (facultatif, mais pratique).
- Accès à la Search Console (si vous voulez mesurer l’impact).
Sources officielles (références)
- Hook wp_head (WordPress Developer Resources)
- wp_json_encode()
- get_the_post_thumbnail_url()
- json_encode (PHP)
- wordpress-develop (GitHub)
Étape 1 : créer un mini-plugin MU pour injecter du JSON-LD proprement
La plupart des snippets schema “cassés” que je dépanne viennent d’un mauvais emplacement : collés dans header.php, dans un builder, ou dans un plugin de snippets qui s’exécute trop tard. Le MU-plugin règle ça : il est chargé automatiquement, avant les plugins classiques.
Où cliquer / où créer le fichier
- Ouvrez votre site en FTP/SSH.
- Allez dans
wp-content/. - Créez le dossier
mu-pluginss’il n’existe pas :wp-content/mu-plugins/. - Créez le fichier :
wp-content/mu-plugins/bpcab-schema-jsonld.php.
Code (étape 1)
Collez ce code tel quel. Il ne sort encore aucun schema, il met en place le “moteur” : collecte, encodage JSON sûr, injection dans wp_head, et filtres.
<?php
/**
* Plugin Name: BPCAB - Schema JSON-LD (MU)
* Description: Injection contrôlée de données structurées JSON-LD (Schema.org) pour WordPress 6.9.4+.
* Author: Votre nom / agence
* Version: 1.0.0
*
* Emplacement: wp-content/mu-plugins/bpcab-schema-jsonld.php
*/
defined('ABSPATH') || exit;
final class BPCAB_Schema_JSONLD {
/**
* Stocke les nœuds du graphe Schema.
*
* @var array<array>
*/
private array $graph = [];
/**
* Singleton simple.
*/
private static ?self $instance = null;
public static function instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
// Injection dans le <head> : priorité 20 pour passer après certains plugins/thèmes.
add_action('wp_head', [$this, 'render_jsonld'], 20);
// Point d'extension : on construit le graphe quand WP est prêt (requête principale résolue).
add_action('wp', [$this, 'build_graph'], 20);
}
/**
* Construit le graphe Schema en fonction du contexte (single, page, home, etc.).
*/
public function build_graph(): void {
// Permet de désactiver totalement via filtre (utile si conflit avec un plugin SEO).
$enabled = (bool) apply_filters('bpcab_schema_jsonld_enabled', true);
if (!$enabled) {
return;
}
// Reset à chaque requête.
$this->graph = [];
/**
* On laisse les étapes suivantes remplir $this->graph via des méthodes dédiées.
* Ici, on ne fait rien de plus.
*/
do_action('bpcab_schema_jsonld_build', $this);
}
/**
* Ajoute un nœud au graphe.
*
* @param array $node
*/
public function add_node(array $node): void {
if (empty($node['@type'])) {
return;
}
$this->graph[] = $node;
}
/**
* Rend le JSON-LD dans le head.
*/
public function render_jsonld(): void {
if (empty($this->graph)) {
return;
}
$payload = [
'@context' => 'https://schema.org',
'@graph' => array_values($this->graph),
];
/**
* Filtre final pour ajuster le payload complet.
* Attention : ne mettez pas d'objets non sérialisables.
*/
$payload = apply_filters('bpcab_schema_jsonld_payload', $payload);
// Encodage JSON sûr côté WP (gère mieux certains cas que json_encode direct).
$json = wp_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!$json) {
return;
}
echo "n" . '<script type="application/ld+json" id="bpcab-schema-jsonld">' . "n";
echo $json; // JSON-LD : sortie volontairement non-échappée (c'est du JSON, pas du HTML).
echo "n" . '</script>' . "n";
}
}
BPCAB_Schema_JSONLD::instance();
Résultat attendu
- Aucun changement visible en front (pour l’instant).
- Pas d’erreur PHP.
- Un hook
bpcab_schema_jsonld_buildprêt à être utilisé à l’étape suivante.
Étape 2 : ajouter Article/BlogPosting sur les articles (avec auteur, image, dates)
On va ajouter un nœud BlogPosting sur les single posts. Le point qui fait la différence : des @id stables, des dates ISO, et une image correctement dimensionnée. J’ai souvent vu des JSON-LD “valides” mais inutiles parce que l’image est vide ou que l’auteur pointe vers une URL non canonique.
Ajoutez ce code dans le même fichier MU-plugin
Collez les méthodes suivantes dans la classe BPCAB_Schema_JSONLD (après render_jsonld() par exemple), puis ajoutez l’action qui les appelle.
/**
* Enregistre les builders par défaut.
*/
private function register_builders(): void {
add_action('bpcab_schema_jsonld_build', [$this, 'add_blogposting_for_single'], 20);
}
/**
* Petit helper : URL canonique de la requête.
*/
private function get_canonical_url(): string {
// wp_get_canonical_url() existe sur WP modernes, mais on garde une approche simple et fiable.
if (is_singular()) {
$url = get_permalink(get_queried_object_id());
return is_string($url) ? $url : home_url('/');
}
// Pour home/blog page/archives, on évite les constructions trop “magiques”.
return home_url(add_query_arg([]));
}
/**
* Ajoute BlogPosting sur les articles.
*/
public function add_blogposting_for_single(self $schema): void {
if (!is_singular('post')) {
return;
}
$post_id = get_queried_object_id();
$post = get_post($post_id);
if (!$post instanceof WP_Post) {
return;
}
$canonical = get_permalink($post_id);
if (!is_string($canonical) || '' === $canonical) {
return;
}
// Image : privilégiez une taille large (évite les warnings “image too small”).
$image_url = get_the_post_thumbnail_url($post_id, 'full');
if (!is_string($image_url)) {
$image_url = '';
}
$author_id = (int) $post->post_author;
$author_url = get_author_posts_url($author_id);
$site_name = get_bloginfo('name');
$site_url = home_url('/');
$logo_id = (int) get_theme_mod('custom_logo');
$logo_url = $logo_id ? wp_get_attachment_image_url($logo_id, 'full') : '';
// @id stables : on s'appuie sur l'URL canonique + fragment.
$webpage_id = $canonical . '#webpage';
$blogposting_id = $canonical . '#blogposting';
$author_id_uri = is_string($author_url) ? $author_url . '#author' : $site_url . '#author';
$publisher_id = $site_url . '#organization';
$node = [
'@type' => 'BlogPosting',
'@id' => $blogposting_id,
'mainEntityOfPage' => [
'@type' => 'WebPage',
'@id' => $webpage_id,
],
'headline' => get_the_title($post_id),
'description' => wp_strip_all_tags(get_the_excerpt($post_id)),
'datePublished' => get_the_date(DATE_W3C, $post_id),
'dateModified' => get_the_modified_date(DATE_W3C, $post_id),
'author' => [
'@type' => 'Person',
'@id' => $author_id_uri,
'name' => get_the_author_meta('display_name', $author_id),
'url' => is_string($author_url) ? $author_url : '',
],
'publisher' => [
'@type' => 'Organization',
'@id' => $publisher_id,
'name' => $site_name,
],
'isPartOf' => [
'@type' => 'Blog',
'@id' => $site_url . '#blog',
'name' => $site_name,
'url' => $site_url,
],
'url' => $canonical,
];
if ('' !== $image_url) {
$node['image'] = [
'@type' => 'ImageObject',
'url' => $image_url,
];
}
if (is_string($logo_url) && '' !== $logo_url) {
$node['publisher']['logo'] = [
'@type' => 'ImageObject',
'url' => $logo_url,
];
}
/**
* Filtre par post : permet d'ajuster le node BlogPosting (ajouter keywords, articleSection, etc.).
*/
$node = apply_filters('bpcab_schema_jsonld_blogposting_node', $node, $post_id);
$schema->add_node($node);
}
Ajoutez l’appel au registre des builders
Dans le constructeur __construct(), ajoutez une ligne :
private function __construct() {
add_action('wp_head', [$this, 'render_jsonld'], 20);
add_action('wp', [$this, 'build_graph'], 20);
// Enregistre nos builders.
$this->register_builders();
}
Résultat attendu
- Sur un article, vous verrez un
<script type="application/ld+json" id="bpcab-schema-jsonld">dans le code source. - Le JSON contient un
@graphavec un objet"@type":"BlogPosting".
Étape 3 : ajouter des breadcrumbs en JSON-LD (BreadcrumbList)
Les breadcrumbs sont un bon “signal de structure”. Le piège : générer des URLs incohérentes (http/https, slash final) ou inclure des taxonomies qui changent selon l’article. Je préfère une règle simple : Accueil → Blog (si page des articles) → Catégorie principale (optionnelle) → Article.
Code (breadcrumbs) à ajouter dans la classe
/**
* Ajoute BreadcrumbList.
*/
public function add_breadcrumbs(self $schema): void {
// On ne met pas de breadcrumbs sur la page d'accueil “front page”.
if (is_front_page()) {
return;
}
$items = [];
$pos = 1;
$home_url = home_url('/');
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => 'Accueil',
'item' => $home_url,
];
// Si une page “Articles” est définie (Réglages > Lecture).
$page_for_posts = (int) get_option('page_for_posts');
if ($page_for_posts && !is_home()) {
$blog_url = get_permalink($page_for_posts);
if (is_string($blog_url) && '' !== $blog_url) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => get_the_title($page_for_posts),
'item' => $blog_url,
];
}
}
if (is_singular('post')) {
// Catégorie “principale” : on prend la première, stable, sans prétendre faire du SEO.
$cats = get_the_category(get_queried_object_id());
if (!empty($cats) && $cats[0] instanceof WP_Term) {
$cat = $cats[0];
$cat_url = get_term_link($cat);
if (is_string($cat_url)) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => $cat->name,
'item' => $cat_url,
];
}
}
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => get_the_title(get_queried_object_id()),
'item' => get_permalink(get_queried_object_id()),
];
} elseif (is_page()) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => get_the_title(get_queried_object_id()),
'item' => get_permalink(get_queried_object_id()),
];
} elseif (is_category()) {
$term = get_queried_object();
if ($term instanceof WP_Term) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => single_cat_title('', false),
'item' => get_term_link($term),
];
}
} elseif (is_tag()) {
$term = get_queried_object();
if ($term instanceof WP_Term) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => single_tag_title('', false),
'item' => get_term_link($term),
];
}
} elseif (is_search()) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => 'Recherche',
'item' => home_url('/?s=' . rawurlencode(get_search_query(false))),
];
}
$items = apply_filters('bpcab_schema_jsonld_breadcrumb_items', $items);
if (count($items) < 2) {
return;
}
$node = [
'@type' => 'BreadcrumbList',
'@id' => home_url('/') . '#breadcrumbs',
'itemListElement' => array_values($items),
];
$schema->add_node($node);
}
Activez ce builder
Dans register_builders(), ajoutez :
add_action('bpcab_schema_jsonld_build', [$this, 'add_breadcrumbs'], 30);
Résultat attendu
- Sur un article, le JSON-LD contient un objet
"@type":"BreadcrumbList". - La liste a des positions 1..N sans trous.
Étape 4 : ajouter Organization + WebSite (et SearchAction) au niveau du site
Sans Organization et WebSite, vos nœuds “Article” flottent souvent sans rattachement clair. Google s’en sort, mais vous perdez en cohérence. On va aussi ajouter SearchAction (utile si votre recherche interne est accessible via ?s=).
Code à ajouter dans la classe
/**
* Ajoute Organization + WebSite.
*/
public function add_site_entities(self $schema): void {
$site_url = home_url('/');
$site_name = get_bloginfo('name');
$org_type = (string) apply_filters('bpcab_schema_jsonld_org_type', 'Organization');
$logo_id = (int) get_theme_mod('custom_logo');
$logo_url = $logo_id ? wp_get_attachment_image_url($logo_id, 'full') : '';
// Réseaux sociaux : filtre pour éviter de coder en dur.
$same_as = (array) apply_filters('bpcab_schema_jsonld_same_as', []);
$org = [
'@type' => $org_type,
'@id' => $site_url . '#organization',
'name' => $site_name,
'url' => $site_url,
];
if (is_string($logo_url) && '' !== $logo_url) {
$org['logo'] = [
'@type' => 'ImageObject',
'url' => $logo_url,
];
}
// sameAs doit être un tableau d'URLs publiques (Facebook, LinkedIn, etc.).
$same_as = array_values(array_filter(array_map('esc_url_raw', $same_as)));
if (!empty($same_as)) {
$org['sameAs'] = $same_as;
}
$website = [
'@type' => 'WebSite',
'@id' => $site_url . '#website',
'url' => $site_url,
'name' => $site_name,
'publisher' => [
'@id' => $site_url . '#organization',
],
'inLanguage' => get_bloginfo('language'),
'potentialAction' => [
'@type' => 'SearchAction',
'target' => [
'@type' => 'EntryPoint',
'urlTemplate' => $site_url . '?s={search_term_string}',
],
'query-input' => 'required name=search_term_string',
],
];
$schema->add_node(apply_filters('bpcab_schema_jsonld_org_node', $org));
$schema->add_node(apply_filters('bpcab_schema_jsonld_website_node', $website));
}
Activez ce builder
Dans register_builders() :
add_action('bpcab_schema_jsonld_build', [$this, 'add_site_entities'], 10);
Résultat attendu
- Sur toutes les pages (sauf si vous filtrez), vous avez Organization + WebSite dans
@graph. - Le
publisherde vos articles pointe vers#organization.
Étape 5 : gérer les pages spécifiques (accueil, page, archive, WooCommerce si présent)
Si vous injectez le même schema partout, vous allez créer du bruit. On va ajuster : pas de breadcrumbs sur l’accueil, pas de BlogPosting sur une page, et on ajoute un nœud WebPage simple pour les pages.
Code : WebPage pour pages (et fallback)
/**
* Ajoute un nœud WebPage sur les pages et en complément sur les singulars.
*/
public function add_webpage_node(self $schema): void {
if (is_front_page()) {
// Sur l'accueil, on peut aussi sortir WebPage, mais je préfère éviter les graph trop bavards.
return;
}
if (!is_singular()) {
return;
}
$post_id = get_queried_object_id();
$url = get_permalink($post_id);
if (!is_string($url) || '' === $url) {
return;
}
$type = is_page() ? 'WebPage' : 'WebPage';
$node = [
'@type' => $type,
'@id' => $url . '#webpage',
'url' => $url,
'name' => get_the_title($post_id),
'inLanguage' => get_bloginfo('language'),
'isPartOf' => [
'@id' => home_url('/') . '#website',
],
];
$schema->add_node(apply_filters('bpcab_schema_jsonld_webpage_node', $node, $post_id));
}
Activez ce builder
add_action('bpcab_schema_jsonld_build', [$this, 'add_webpage_node'], 15);
WooCommerce (optionnel)
Si WooCommerce est actif, il sort souvent déjà du schema produit via extensions SEO. Ne doublez pas. Si vous voulez détecter WooCommerce pour désactiver certaines sorties, vous pouvez faire :
public function maybe_disable_on_products(): void {
// Exemple : désactiver notre schema sur les pages produit si un plugin SEO gère déjà Product.
if (function_exists('is_product') && is_product()) {
add_filter('bpcab_schema_jsonld_enabled', '__return_false', 1);
}
}
Si vous utilisez ce garde-fou, appelez-le tôt (dans __construct() via add_action('wp', ... , 1)).
Le résultat complet
Voici une version assemblée (copiable) du MU-plugin, avec les builders activés. Vous pouvez repartir de zéro en remplaçant le fichier wp-content/mu-plugins/bpcab-schema-jsonld.php par ceci.
<?php
/**
* Plugin Name: BPCAB - Schema JSON-LD (MU)
* Description: Injection contrôlée de données structurées JSON-LD (Schema.org) pour WordPress 6.9.4+.
* Author: Votre nom / agence
* Version: 1.0.0
*/
defined('ABSPATH') || exit;
final class BPCAB_Schema_JSONLD {
private array $graph = [];
private static ?self $instance = null;
public static function instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
add_action('wp_head', [$this, 'render_jsonld'], 20);
add_action('wp', [$this, 'build_graph'], 20);
$this->register_builders();
}
private function register_builders(): void {
add_action('bpcab_schema_jsonld_build', [$this, 'add_site_entities'], 10);
add_action('bpcab_schema_jsonld_build', [$this, 'add_webpage_node'], 15);
add_action('bpcab_schema_jsonld_build', [$this, 'add_blogposting_for_single'], 20);
add_action('bpcab_schema_jsonld_build', [$this, 'add_breadcrumbs'], 30);
}
public function build_graph(): void {
$enabled = (bool) apply_filters('bpcab_schema_jsonld_enabled', true);
if (!$enabled) {
return;
}
$this->graph = [];
do_action('bpcab_schema_jsonld_build', $this);
}
public function add_node(array $node): void {
if (empty($node['@type'])) {
return;
}
$this->graph[] = $node;
}
public function render_jsonld(): void {
if (empty($this->graph)) {
return;
}
$payload = [
'@context' => 'https://schema.org',
'@graph' => array_values($this->graph),
];
$payload = apply_filters('bpcab_schema_jsonld_payload', $payload);
$json = wp_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!$json) {
return;
}
echo "n" . '<script type="application/ld+json" id="bpcab-schema-jsonld">' . "n";
echo $json;
echo "n" . '</script>' . "n";
}
public function add_site_entities(self $schema): void {
$site_url = home_url('/');
$site_name = get_bloginfo('name');
$org_type = (string) apply_filters('bpcab_schema_jsonld_org_type', 'Organization');
$logo_id = (int) get_theme_mod('custom_logo');
$logo_url = $logo_id ? wp_get_attachment_image_url($logo_id, 'full') : '';
$same_as = (array) apply_filters('bpcab_schema_jsonld_same_as', []);
$org = [
'@type' => $org_type,
'@id' => $site_url . '#organization',
'name' => $site_name,
'url' => $site_url,
];
if (is_string($logo_url) && '' !== $logo_url) {
$org['logo'] = [
'@type' => 'ImageObject',
'url' => $logo_url,
];
}
$same_as = array_values(array_filter(array_map('esc_url_raw', $same_as)));
if (!empty($same_as)) {
$org['sameAs'] = $same_as;
}
$website = [
'@type' => 'WebSite',
'@id' => $site_url . '#website',
'url' => $site_url,
'name' => $site_name,
'publisher' => [
'@id' => $site_url . '#organization',
],
'inLanguage' => get_bloginfo('language'),
'potentialAction' => [
'@type' => 'SearchAction',
'target' => [
'@type' => 'EntryPoint',
'urlTemplate' => $site_url . '?s={search_term_string}',
],
'query-input' => 'required name=search_term_string',
],
];
$schema->add_node(apply_filters('bpcab_schema_jsonld_org_node', $org));
$schema->add_node(apply_filters('bpcab_schema_jsonld_website_node', $website));
}
public function add_webpage_node(self $schema): void {
if (is_front_page()) {
return;
}
if (!is_singular()) {
return;
}
$post_id = get_queried_object_id();
$url = get_permalink($post_id);
if (!is_string($url) || '' === $url) {
return;
}
$node = [
'@type' => 'WebPage',
'@id' => $url . '#webpage',
'url' => $url,
'name' => get_the_title($post_id),
'inLanguage' => get_bloginfo('language'),
'isPartOf' => [
'@id' => home_url('/') . '#website',
],
];
$schema->add_node(apply_filters('bpcab_schema_jsonld_webpage_node', $node, $post_id));
}
public function add_blogposting_for_single(self $schema): void {
if (!is_singular('post')) {
return;
}
$post_id = get_queried_object_id();
$post = get_post($post_id);
if (!$post instanceof WP_Post) {
return;
}
$canonical = get_permalink($post_id);
if (!is_string($canonical) || '' === $canonical) {
return;
}
$image_url = get_the_post_thumbnail_url($post_id, 'full');
if (!is_string($image_url)) {
$image_url = '';
}
$author_id = (int) $post->post_author;
$author_url = get_author_posts_url($author_id);
$site_name = get_bloginfo('name');
$site_url = home_url('/');
$logo_id = (int) get_theme_mod('custom_logo');
$logo_url = $logo_id ? wp_get_attachment_image_url($logo_id, 'full') : '';
$webpage_id = $canonical . '#webpage';
$blogposting_id = $canonical . '#blogposting';
$author_id_uri = is_string($author_url) ? $author_url . '#author' : $site_url . '#author';
$publisher_id = $site_url . '#organization';
$node = [
'@type' => 'BlogPosting',
'@id' => $blogposting_id,
'mainEntityOfPage' => [
'@type' => 'WebPage',
'@id' => $webpage_id,
],
'headline' => get_the_title($post_id),
'description' => wp_strip_all_tags(get_the_excerpt($post_id)),
'datePublished' => get_the_date(DATE_W3C, $post_id),
'dateModified' => get_the_modified_date(DATE_W3C, $post_id),
'author' => [
'@type' => 'Person',
'@id' => $author_id_uri,
'name' => get_the_author_meta('display_name', $author_id),
'url' => is_string($author_url) ? $author_url : '',
],
'publisher' => [
'@type' => 'Organization',
'@id' => $publisher_id,
'name' => $site_name,
],
'isPartOf' => [
'@type' => 'Blog',
'@id' => $site_url . '#blog',
'name' => $site_name,
'url' => $site_url,
],
'url' => $canonical,
];
if ('' !== $image_url) {
$node['image'] = [
'@type' => 'ImageObject',
'url' => $image_url,
];
}
if (is_string($logo_url) && '' !== $logo_url) {
$node['publisher']['logo'] = [
'@type' => 'ImageObject',
'url' => $logo_url,
];
}
$node = apply_filters('bpcab_schema_jsonld_blogposting_node', $node, $post_id);
$schema->add_node($node);
}
public function add_breadcrumbs(self $schema): void {
if (is_front_page()) {
return;
}
$items = [];
$pos = 1;
$home_url = home_url('/');
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => 'Accueil',
'item' => $home_url,
];
$page_for_posts = (int) get_option('page_for_posts');
if ($page_for_posts && !is_home()) {
$blog_url = get_permalink($page_for_posts);
if (is_string($blog_url) && '' !== $blog_url) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => get_the_title($page_for_posts),
'item' => $blog_url,
];
}
}
if (is_singular('post')) {
$cats = get_the_category(get_queried_object_id());
if (!empty($cats) && $cats[0] instanceof WP_Term) {
$cat = $cats[0];
$cat_url = get_term_link($cat);
if (is_string($cat_url)) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => $cat->name,
'item' => $cat_url,
];
}
}
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => get_the_title(get_queried_object_id()),
'item' => get_permalink(get_queried_object_id()),
];
} elseif (is_page()) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => get_the_title(get_queried_object_id()),
'item' => get_permalink(get_queried_object_id()),
];
} elseif (is_category()) {
$term = get_queried_object();
if ($term instanceof WP_Term) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => single_cat_title('', false),
'item' => get_term_link($term),
];
}
} elseif (is_tag()) {
$term = get_queried_object();
if ($term instanceof WP_Term) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => single_tag_title('', false),
'item' => get_term_link($term),
];
}
} elseif (is_search()) {
$items[] = [
'@type' => 'ListItem',
'position' => $pos++,
'name' => 'Recherche',
'item' => home_url('/?s=' . rawurlencode(get_search_query(false))),
];
}
$items = apply_filters('bpcab_schema_jsonld_breadcrumb_items', $items);
if (count($items) < 2) {
return;
}
$node = [
'@type' => 'BreadcrumbList',
'@id' => home_url('/') . '#breadcrumbs',
'itemListElement' => array_values($items),
];
$schema->add_node($node);
}
}
BPCAB_Schema_JSONLD::instance();
Personnalisation rapide (exemples)
Ajoutez ces filtres dans un plugin classique ou dans votre thème enfant (pas dans le MU-plugin si vous voulez pouvoir le mettre à jour facilement).
<?php
// Exemple : ajouter vos réseaux sociaux à sameAs.
add_filter('bpcab_schema_jsonld_same_as', function(array $same_as): array {
$same_as[] = 'https://www.linkedin.com/company/votre-marque/';
$same_as[] = 'https://www.youtube.com/@votre-chaine';
return $same_as;
});
// Exemple : changer le type d'organisation.
add_filter('bpcab_schema_jsonld_org_type', function(string $type): string {
return 'Organization'; // Ou 'NewsMediaOrganization' si pertinent.
});
Adapter pour Divi 5 / Elementor / Avada
Bonne nouvelle : comme on injecte via wp_head, ça marche en général “sans adaptation”. Les builders impactent surtout le contenu (le HTML), pas la requête WP.
Divi 5
- Divi peut ajouter du schema via modules/SEO tiers. Vérifiez les doublons.
- Si vous utilisez un module Divi pour afficher le titre/extrait différemment, ça ne change rien : on lit les données WordPress (titre, excerpt, image à la une).
Astuce Divi que j’utilise : si votre image à la une est souvent absente (Divi met des images dans le contenu), forcez une image via filtre bpcab_schema_jsonld_blogposting_node en cherchant la première image du contenu (méthode à faire avec prudence pour la perf).
Elementor
- Elementor peut gérer des templates de single post, mais
is_singular('post')reste correct. - Sur certains sites, j’ai vu des excerpts vides parce qu’ils sont “visuels”. Dans ce cas, remplacez
descriptionpar un extrait généré depuis le contenu (limité en longueur) via filtre.
Avada
- Avada/Fusion Builder a parfois des options SEO/schema. Désactivez-les si vous gardez ce MU-plugin, sinon vous aurez deux JSON-LD concurrents.
- Avada met souvent le logo via ses options thème. Si
custom_logoest vide, vous pouvez injecter votre URL de logo viabpcab_schema_jsonld_org_node.
Vérification finale
1) Vérifier dans le code source
- Ouvrez un article en navigation privée.
- Affichez le code source (pas l’inspecteur, le “view-source”).
- Cherchez
bpcab-schema-jsonld.
Vous devez voir un JSON valide, avec @context et @graph.
2) Valider Schema
- Testez avec Rich Results Test (Google).
- Validez la conformité schema générique avec Schema Markup Validator.
3) Vérifier l’absence de doublons
Si vous voyez plusieurs blocs JSON-LD, ce n’est pas forcément “interdit”, mais ça devient vite incohérent. Cherchez :
- plusieurs
BlogPostingpour la même URL, - plusieurs
Organizationavec des noms différents, - des
@idqui ne pointent pas vers votre domaine canonique.
Si le résultat n’est pas celui attendu
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| Rien n’apparaît dans le head | Fichier MU-plugin au mauvais endroit | Vérifiez wp-content/mu-plugins/ et le nom du fichier |
Placez le fichier directement dans mu-plugins (pas dans un sous-dossier) |
| Erreur 500 après ajout du code | Syntaxe PHP (point-virgule, accolade) | Consultez les logs PHP de l’hébergeur | Revenez au dernier état, corrigez la ligne signalée |
| Le test Google ne voit pas la mise à jour | Cache (plugin, serveur, CDN) | Comparez “view-source” vs ce que Google fetch | Videz cache plugin + serveur + CDN, testez en incognito |
| Warnings “duplicate” ou schema incohérent | Plugin SEO injecte aussi du JSON-LD | Recherchez application/ld+json dans la source |
Désactivez le schema du plugin SEO ou désactivez ce MU-plugin via filtre |
| Image manquante dans BlogPosting | Pas d’image à la une | Vérifiez la présence d’une featured image | Ajoutez une image à la une ou personnalisez via filtre |
Checklist de dépannage rapide
- Vous avez bien collé le code dans la classe (et pas après la fermeture
}) ? - Vous n’avez pas une version PHP trop ancienne (au moins 8.1) ?
- Vous avez vidé le cache navigateur (ou testé en privé) ?
- Vous n’avez pas collé un ancien snippet utilisant des fonctions obsolètes ?
Pièges et erreurs courantes
| Erreur | Cause | Solution |
|---|---|---|
Coller le script JSON-LD dans header.php |
Le thème/builder peut le dupliquer, et une mise à jour l’écrase | Utilisez un MU-plugin ou un plugin dédié |
Utiliser json_encode() sans options |
Échappement incohérent, caractères unicode, slashs | Utilisez wp_json_encode() (WP) avec options |
Hook inadapté (ex: init) |
Le contexte de requête n’est pas résolu | Construisez le graphe sur wp, rendez sur wp_head |
| Dupliquer Organization via plugin SEO | Deux sources de vérité | Désactivez une des deux sorties (plugin SEO ou filtre bpcab_schema_jsonld_enabled) |
| Dates non ISO | Utiliser un format localisé | Utilisez DATE_W3C (ISO 8601) |
| Snippet cassé par un plugin de snippets | Ordre de chargement / erreurs silencieuses | Préférez MU-plugin pour le socle |
Variante / alternative
Alternative sans code (plugin SEO)
Si votre besoin est “standard”, un plugin SEO récent suffit souvent. Les trois que je vois le plus en production :
- Yoast SEO (schema assez complet, mais parfois verbeux)
- Rank Math (beaucoup d’options, attention aux doublons)
- SEOPress (bon compromis, plus léger selon config)
Si vous partez sur un plugin, faites une règle : un seul générateur de schema. Mélanger plugin + snippets finit presque toujours en duplication.
Alternative plus avancée : schema par type de contenu (CPT)
Si vous avez des CPT (ex: “Podcast”, “Recette”, “Événement”), gardez ce MU-plugin comme base et ajoutez des builders conditionnels (ex: is_singular('event')) avec des nœuds Schema spécifiques. Ne surchargez pas BlogPosting pour tout.
Conseils sécurité, performance et maintenance
- Sécurité : ne mettez jamais dans le JSON-LD des données non filtrées provenant d’utilisateurs (champs ACF éditables par des rôles faibles, commentaires, etc.). Le JSON-LD est du texte dans le head : une injection mal gérée peut devenir une XSS si vous concaténez du HTML. Ici, on s’appuie sur des fonctions WordPress et sur
wp_json_encode(). - Performance : évitez de faire des requêtes lourdes (ex: récupérer 50 termes) pour les breadcrumbs. Restez simple. Le head est rendu sur chaque page.
- Cache : si vous avez un cache HTML, toute modification du schema nécessite un purge. J’ai souvent perdu du temps sur un “bug” qui était juste Cloudflare qui servait une ancienne version.
- Maintenance : versionnez ce fichier (Git). Un MU-plugin non versionné devient vite “le truc qu’on n’ose plus toucher”.
Désactiver rapidement en cas de conflit
Ajoutez ceci (plugin ou thème enfant) pour couper le schema instantanément :
<?php
add_filter('bpcab_schema_jsonld_enabled', '__return_false');
Pour aller plus loin
- Ajouter Person complet pour les auteurs (image, jobTitle, sameAs) et relier via
@id. - Ajouter ArticleSection (catégorie) et keywords (tags) via
bpcab_schema_jsonld_blogposting_node(attention à ne pas spammer). - Ajouter Speakable si vous faites de l’audio/assistants (cas d’usage spécifique).
- Ajouter un mode “éditeur” : métabox pour choisir la catégorie breadcrumb principale (évite le hasard de la première catégorie).
Ressources
- wp_head (hook)
- wp (hook)
- wp_json_encode()
- get_option()
- get_permalink()
- json_encode (PHP)
- WordPress Core sur GitHub
- Schema Markup Validator
- Google Rich Results Test
FAQ
Est-ce que le JSON-LD garantit des rich snippets ?
Non. Vous fournissez des signaux structurés, mais Google décide. Ce que vous contrôlez : la validité, la cohérence, et l’absence de doublons.
Pourquoi JSON-LD plutôt que microdata dans le HTML ?
JSON-LD est plus simple à maintenir et moins fragile avec les builders. Microdata casse vite dès qu’un module change la structure HTML.
Est-ce grave d’avoir plusieurs scripts JSON-LD ?
Pas forcément. Ce qui pose problème : plusieurs nœuds qui décrivent la même chose avec des valeurs différentes (deux Organization, deux BlogPosting, deux breadcrumbs divergents).
Où configurer le logo utilisé dans Organization ?
Le code utilise le custom_logo (Apparence > Personnaliser > Identité du site). Si votre thème ne le renseigne pas, injectez un logo via le filtre bpcab_schema_jsonld_org_node.
Pourquoi construire sur le hook wp et rendre sur wp_head ?
wp garantit que la requête principale est résolue (vous savez si vous êtes sur un article, une page, une catégorie). wp_head est l’endroit standard pour sortir des métadonnées. Mélanger les deux crée des cas limites.
Mon excerpt est vide avec Elementor/Divi, que faire ?
Remplacez description via bpcab_schema_jsonld_blogposting_node : générez un extrait depuis le contenu (strip tags, limite de longueur). Faites-le proprement pour éviter d’injecter du texte énorme.
Est-ce compatible avec un site multilingue ?
Oui, mais vous devez vérifier que home_url() et get_permalink() renvoient bien l’URL de la langue courante (ça dépend de Polylang/WPML). Ajustez inLanguage si votre plugin ne met pas à jour get_bloginfo('language') par langue.
Peut-on ajouter FAQPage ou HowTo ?
Oui, mais je vous conseille de le faire sur des pages ciblées, pas globalement. Ajoutez un builder conditionnel (par ID de page ou template) pour éviter de polluer tout le site.
Est-ce que ce code remplace un plugin SEO ?
Non. Il remplace uniquement la partie “schema JSON-LD”. Un plugin SEO gère aussi sitemaps, meta robots, canonical avancé, intégrations sociales, etc.
Que faire si un plugin SEO injecte déjà du schema et je veux garder le plugin ?
Soit vous désactivez la sortie schema du plugin (si l’option existe), soit vous désactivez ce MU-plugin via bpcab_schema_jsonld_enabled. Mélanger les deux sans stratégie finit rarement bien.