Si vous avez déjà vu un “Fatal error on save_post” au moment de cliquer sur Mettre à jour ou Publier, vous savez à quel point ça coupe net : écran blanc, erreur 500, contenu non enregistré, et parfois un éditeur Gutenberg qui se met à tourner dans le vide.

Le problème

L’erreur est presque toujours déclenchée par du code exécuté sur l’action WordPress save_post. Une action (hook) est un point d’accroche où WordPress exécute votre fonction. save_post se déclenche quand un contenu (article, page, CPT) est enregistré, y compris lors d’autosauvegardes, de révisions et parfois via l’API REST.

Vous verrez typiquement un message de ce genre dans vos logs PHP (ou affiché si le debug est activé) :

Fatal error: Uncaught Error: Call to a member function ... on null in /wp-content/plugins/mon-plugin/mon-plugin.php:123
Stack trace:
#0 /wp-includes/class-wp-hook.php(324): ma_fonction_save_post()
#1 /wp-includes/class-wp-hook.php(348): WP_Hook->apply_filters()
#2 /wp-includes/plugin.php(517): WP_Hook->do_action()
#3 /wp-includes/post.php(5068): do_action('save_post', 42, Object(WP_Post), true)
...

Où ça apparaît :

  • Admin : au clic sur “Mettre à jour / Publier”, ou lors d’une mise à jour rapide.
  • REST API : en enregistrant depuis l’éditeur de blocs (Gutenberg) ou une intégration externe.
  • Cron : plus rare, mais possible si un plugin met à jour des posts en tâche planifiée.

Circonstances typiques que j’ai souvent croisées :

  • Après avoir collé un snippet “trouvé sur Internet” dans functions.php.
  • Après la mise à jour d’un plugin de champs (ACF-like) ou d’un builder (Divi/Elementor/Avada) qui change le timing des métadonnées.
  • Après un passage à PHP 8.1+ où des erreurs qui “passaient” avant deviennent fatales (types, null, méthodes supprimées).

À qui ça s’adresse : si vous êtes blogueur débutant mais que vous avez accès à l’admin WordPress et idéalement au FTP/gestionnaire de fichiers, vous pourrez diagnostiquer la cause, corriger le hook save_post, et vérifier que l’enregistrement fonctionne à nouveau sous WordPress 6.9.4 et PHP 8.1+.

Résumé rapide

  • 90% du temps, le hook save_post s’exécute pendant une autosauvegarde/révision et votre code n’était pas prévu pour ça.
  • Vérifiez d’abord les garde-fous : nonce, capabilities, type de post, autosave, révision.
  • Deuxième cause fréquente : récursion (vous appelez wp_update_post() dans save_post → boucle infinie → fatal).
  • Activez des logs propres (WP_DEBUG_LOG) et utilisez Query Monitor + Health Check pour isoler le plugin/thème fautif.
  • Sur Divi 5 / Elementor / Avada : le problème vient souvent d’un snippet “global” qui touche tous les post types, y compris les templates du builder.

Les symptômes

Selon le contexte, vous pouvez observer :

  • Erreur 500 au moment d’enregistrer, puis retour au tableau de bord.
  • Écran blanc (WSOD) après “Mettre à jour”.
  • L’éditeur de blocs affiche “Mise à jour échouée” / “La réponse n’est pas une réponse JSON valide” (car le fatal casse la réponse REST).
  • Le contenu est enregistré partiellement : titre ok, mais métadonnées non mises à jour.
  • En “Mode classique” ou Quick Edit, l’enregistrement échoue aussi.
  • Le problème n’existe qu’avec un rôle non-admin (droits/capacités).
  • Ça marche en local, mais pas en production (différences PHP, mémoire, cache objet, extensions).

Signaux qui orientent :

  • Si vous voyez “Invalid JSON response” dans Gutenberg, ouvrez la console navigateur : souvent vous verrez une requête /wp-json/wp/v2/... en erreur 500.
  • Si le fatal n’apparaît pas à l’écran, il est presque toujours dans wp-content/debug.log ou dans les logs PHP du serveur.
  • Si le site utilise un cache agressif (plugin de cache + CDN), vous pouvez avoir l’impression que “ça ne marche pas” alors que la correction est faite. Videz le cache admin et navigateur.

Tableau de diagnostic (symptôme → cause → vérification → solution)

Symptôme Cause probable Vérification Solution
Erreur 500 à l’enregistrement Fatal PHP dans save_post Consultez debug.log / logs serveur, cherchez “save_post” Appliquez Solution 1 ou 2 selon le message
“Réponse JSON invalide” (Gutenberg) Fatal pendant la réponse REST Onglet Réseau (DevTools), requête REST en 500 Corriger le hook + vérifier nonces/capacités
Ça ne casse que sur certaines pages Conflit avec CPT/templates builder Désactiver plugins via Health Check (mode dépannage) Limiter par post type + conditions
Boucle / timeout / mémoire épuisée Récursion wp_update_post() dans save_post Log “Allowed memory size exhausted” ou “maximum execution time” Solution 2 (remove_action / garde)
Erreur “nonce” / droits Vérif de sécurité manquante ou incorrecte Test avec éditeur vs admin, inspecter $_POST Ajouter nonce + current_user_can()

Pourquoi ça arrive

Explication simple (débutant)

save_post se déclenche souvent, pas seulement quand vous cliquez sur “Publier”. WordPress sauvegarde des brouillons automatiquement, crée des révisions, et peut enregistrer via l’API REST. Si votre code suppose que certains champs existent toujours, ou que l’utilisateur a toujours les droits, ou que vous êtes toujours sur “article”, il finira par tomber sur un cas où ce n’est pas vrai… et PHP 8.1+ ne pardonne pas les erreurs de type ou les appels sur null.

Explication technique (intermédiaire/pro)

L’action save_post reçoit trois paramètres : l’ID du post, l’objet WP_Post, et un booléen $update. Elle est exécutée pendant wp_insert_post(). Les erreurs fatales viennent typiquement de :

  • Autosave/révision : vous mettez à jour des metas sur une révision, ou vous lisez $_POST alors que le contexte n’est pas celui attendu.
  • Récursion : vous appelez wp_update_post() (ou wp_insert_post()) dans votre callback save_post, ce qui déclenche… save_post à nouveau.
  • Mauvais hook : vous utilisez save_post alors que vous vouliez save_post_{post_type} (plus précis), ou un hook de meta box.
  • Objets non chargés : vous appelez une fonction de plugin (ACF-like, builder) alors que ses fonctions ne sont pas disponibles à ce moment (ordre de chargement / conditions admin).
  • Incompatibilité PHP : anciennes libs, appels à des méthodes supprimées, erreurs de typage, paramètres manquants.

Causes classées du plus fréquent au plus rare (sur des sites WordPress 6.9.4 que je dépanne) :

  1. Absence de garde-fous (autosave/révision/capabilities/nonce).
  2. Récursion via wp_update_post() ou mise à jour de meta qui déclenche d’autres hooks.
  3. Snippet collé au mauvais endroit (thème parent, plugin de snippets qui s’exécute partout, ou fichier tronqué).
  4. Conflit plugin (SEO, cache, builder, champs personnalisés) qui modifie le payload de sauvegarde.
  5. Erreur serveur (mémoire PHP trop basse, OPcache instable, extension manquante) révélée au moment du save.

Prérequis avant de commencer

  • Sauvegarde : faites une sauvegarde fichiers + base de données. Ne testez pas “à l’aveugle” en production.
  • Environnement de test : si possible, reproduisez sur un staging.
  • Versions : WordPress 6.9.4, PHP 8.1+ (recommandé). Si vous êtes en dessous, corrigez d’abord la version PHP.
  • Outils :

Activez un logging propre (sans afficher les erreurs aux visiteurs). Dans wp-config.php, juste avant “That’s all…” :

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );     // Écrit dans wp-content/debug.log
define( 'WP_DEBUG_DISPLAY', false ); // Évite d'afficher les erreurs à l'écran
@ini_set( 'display_errors', '0' );   // Force côté PHP si possible

Où lire les logs :

  • wp-content/debug.log si WP_DEBUG_LOG est actif.
  • Logs PHP de l’hébergeur (souvent dans le panneau : “Erreurs”, “Logs”).

Solution 1 : Sécuriser votre hook save_post (autosave, révisions, capacités)

Le problème le plus courant : une fonction attachée à save_post qui manipule des metas ou des champs sans vérifier le contexte. Sur WordPress 6.9.4, ça casse particulièrement avec Gutenberg (REST), les builders et les autosaves.

Où appliquer le code :

  • Idéal : un plugin custom (recommandé) ou un mu-plugin (wp-content/mu-plugins/) pour éviter qu’un changement de thème désactive la correction.
  • À défaut : functions.php du thème enfant (jamais le thème parent).

Sauvegardez avant de modifier. Une parenthèse oubliée dans functions.php peut vous bloquer l’accès à l’admin.

Code AVANT (cassé)

Exemple réaliste : un snippet qui enregistre un champ “Sous-titre” depuis $_POST, sans garde-fous.

add_action( 'save_post', 'mon_sous_titre_save' );

function mon_sous_titre_save( $post_id ) {
	// ERREURS FRÉQUENTES :
	// - $_POST['mon_sous_titre'] n'existe pas lors d'un autosave ou via REST
	// - Pas de vérification de droits
	// - Pas de vérification de nonce
	// - S'exécute sur TOUS les post types (y compris révisions/templates builder)
	update_post_meta( $post_id, '_mon_sous_titre', sanitize_text_field( $_POST['mon_sous_titre'] ) );
}

Ce code peut provoquer :

  • Undefined array key "mon_sous_titre" (avertissement) qui devient fatal si une autre erreur se produit, ou si une logique dépend de cette valeur.
  • Des mises à jour de meta sur des révisions (comportement incohérent).
  • Des erreurs de droits si un rôle éditeur n’a pas la capacité attendue.

Code APRÈS (corrigé, robuste)

Version solide, compatible WordPress 6.9.4 / PHP 8.1+. Elle gère autosave, révisions, nonce, capacités, post type, et supporte REST (où $_POST peut être vide).

/**
 * Enregistre un sous-titre en meta de manière sécurisée.
 *
 * À placer dans un plugin custom, un mu-plugin, ou functions.php du thème enfant.
 */
add_action( 'save_post', 'mon_sous_titre_save', 10, 3 );

function mon_sous_titre_save( $post_id, $post, $update ) {
	// 1) Éviter autosaves et révisions.
	if ( wp_is_post_autosave( $post_id ) ) {
		return;
	}
	if ( wp_is_post_revision( $post_id ) ) {
		return;
	}

	// 2) Limiter au bon type de contenu (ex: 'post').
	// Adaptez selon votre besoin : 'page', 'product', etc.
	if ( $post instanceof WP_Post && $post->post_type !== 'post' ) {
		return;
	}

	// 3) Vérifier les droits (capability).
	// Pour un 'post', la capacité la plus sûre est edit_post sur l'ID.
	if ( ! current_user_can( 'edit_post', $post_id ) ) {
		return;
	}

	// 4) Vérifier un nonce si votre champ vient d'un formulaire/metabox.
	// Si vous n'avez pas encore de nonce, voyez plus bas.
	if ( isset( $_POST['mon_sous_titre_nonce'] ) ) {
		$nonce = sanitize_text_field( wp_unslash( $_POST['mon_sous_titre_nonce'] ) );
		if ( ! wp_verify_nonce( $nonce, 'mon_sous_titre_action' ) ) {
			return;
		}
	} else {
		// Si votre sauvegarde vient de REST (Gutenberg) ou d'un autre flux,
		// vous pouvez choisir :
		// - soit de ne rien faire si le nonce n'existe pas,
		// - soit d'accepter une autre source (ex: meta enregistrée via register_meta).
		// Ici on choisit la prudence : pas de nonce => pas de write.
		return;
	}

	// 5) Récupérer la valeur de manière sûre.
	// wp_unslash() est nécessaire car WordPress ajoute des slashes dans $_POST.
	$sous_titre = '';
	if ( isset( $_POST['mon_sous_titre'] ) ) {
		$sous_titre = sanitize_text_field( wp_unslash( $_POST['mon_sous_titre'] ) );
	}

	// 6) Mettre à jour / supprimer proprement.
	if ( $sous_titre === '' ) {
		delete_post_meta( $post_id, '_mon_sous_titre' );
		return;
	}

	update_post_meta( $post_id, '_mon_sous_titre', $sous_titre );
}

Pourquoi ça corrige

  • Autosave / révision : ces enregistrements n’ont pas votre formulaire complet. Sans exclusion, votre code lit des champs inexistants.
  • Nonce : c’est un jeton anti-CSRF. Sans nonce, n’importe quelle page pourrait tenter de déclencher une sauvegarde en arrière-plan (risque sécurité).
  • Capacités : évite qu’un utilisateur sans droits déclenche une erreur (et évite aussi des écritures non autorisées).
  • Post type : Divi 5, Elementor et Avada créent des contenus techniques (templates, layouts). Un snippet “global” qui s’exécute partout finit souvent par casser sur ces post types.

Ajouter le nonce (si vous avez une metabox)

Si votre champ est rendu via une metabox classique, ajoutez un nonce dans votre HTML. Exemple minimal :

function mon_sous_titre_metabox_html( $post ) {
	// Nonce : à vérifier dans save_post
	wp_nonce_field( 'mon_sous_titre_action', 'mon_sous_titre_nonce' );

	$value = get_post_meta( $post->ID, '_mon_sous_titre', true );
	echo '<label for="mon_sous_titre">Sous-titre</label>';
	echo '<input type="text" id="mon_sous_titre" name="mon_sous_titre" value="' . esc_attr( $value ) . '" />';
}

Si vous utilisez Gutenberg et des metas exposées, une approche plus moderne est d’enregistrer la meta via register_post_meta() avec show_in_rest. Ça évite de dépendre de $_POST et réduit les surprises sur save_post (j’en reparle dans “Éviter ce problème à l’avenir”).


Solution 2 : Éviter la récursion (wp_update_post dans save_post)

Deuxième grand classique : vous modifiez le post (titre, slug, contenu) dans le callback save_post avec wp_update_post(). Problème : wp_update_post() déclenche à nouveau save_post. Si vous ne cassez pas la boucle, vous partez en récursion jusqu’au crash (mémoire, temps d’exécution, fatal).

Symptômes typiques

  • Le site “freeze” au moment de publier, puis erreur 500.
  • Dans les logs : Allowed memory size exhausted ou Maximum execution time.
  • Ou un stack trace énorme où save_post apparaît en boucle.

Code AVANT (cassé)

Exemple : auto-ajouter un préfixe au titre.

add_action( 'save_post', 'mon_prefixe_titre' );

function mon_prefixe_titre( $post_id ) {
	$titre = get_the_title( $post_id );

	// Ajoute un préfixe si absent
	if ( strpos( $titre, '[Promo] ' ) !== 0 ) {
		wp_update_post( array(
			'ID'         => $post_id,
			'post_title' => '[Promo] ' . $titre,
		) );
	}
}

Ce code a l’air logique. En pratique, il peut boucler (selon révisions, autosaves, filtres de titre, ou si un autre plugin retouche le titre aussi).

Code APRÈS (corrigé)

Deux stratégies fiables :

  • Stratégie A : retirer temporairement l’action avant d’appeler wp_update_post(), puis la remettre.
  • Stratégie B : utiliser un “verrou” (flag statique) pour empêcher la ré-entrée.

Je privilégie souvent la stratégie A car elle évite des effets de bord si votre callback est appelé plusieurs fois dans le même cycle.

add_action( 'save_post', 'mon_prefixe_titre_corrige', 10, 3 );

function mon_prefixe_titre_corrige( $post_id, $post, $update ) {
	// Garde-fous minimum
	if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
		return;
	}
	if ( ! current_user_can( 'edit_post', $post_id ) ) {
		return;
	}
	if ( ! ( $post instanceof WP_Post ) ) {
		return;
	}
	if ( $post->post_type !== 'post' ) {
		return;
	}

	$titre = (string) $post->post_title;

	if ( str_starts_with( $titre, '[Promo] ' ) ) {
		return;
	}

	// Retirer l'action pour éviter la récursion
	remove_action( 'save_post', 'mon_prefixe_titre_corrige', 10 );

	wp_update_post(
		array(
			'ID'         => $post_id,
			'post_title' => '[Promo] ' . $titre,
		),
		true // Renvoie WP_Error en cas d'échec
	);

	// Remettre l'action
	add_action( 'save_post', 'mon_prefixe_titre_corrige', 10, 3 );
}

Pourquoi ça corrige

  • remove_action() empêche votre propre fonction d’être rappelée par l’update que vous déclenchez vous-même.
  • Les garde-fous évitent de modifier des révisions et de déclencher des écritures inattendues via REST/autosave.
  • En passant true en second paramètre de wp_update_post(), WordPress renvoie un WP_Error au lieu de “cacher” certains problèmes. En dépannage, c’est précieux.

Note pour Divi 5 / Elementor / Avada

J’ai souvent vu cette récursion sur des sites où un builder enregistre des “templates” comme des posts. Si vous préfixez tous les titres sans filtrer le post_type, vous finissez par modifier un layout du builder, qui déclenche un autre flux de sauvegarde interne. Résultat : boucle. Filtrez strictement par post type et, si besoin, par écran admin.


Solution 3 : Isoler la source avec logs, Query Monitor, Health Check et WP-CLI

Quand vous n’avez pas écrit le code fautif (plugin premium, snippet ancien, prestataire), le plus efficace est d’isoler qui accroche save_post et casse l’enregistrement.

Étape 1 : récupérer le message exact

Sans le message exact, on tourne vite en rond. Cherchez dans :

  • wp-content/debug.log
  • Logs serveur (Apache/Nginx + PHP-FPM)

Vous cherchez surtout :

  • Le fichier et la ligne (/wp-content/plugins/...:123)
  • Le type d’erreur (Call to undefined function, Call to a member function ... on null, TypeError)

Étape 2 : Query Monitor

Installez Query Monitor, puis reproduisez l’erreur. Sur certaines erreurs fatales, Query Monitor n’aura pas le temps d’afficher. Mais quand ça passe, il vous donne :

  • Les erreurs PHP et leurs traces
  • Les hooks exécutés
  • Les requêtes REST en admin

Plugin officiel : https://wordpress.org/plugins/query-monitor/

Étape 3 : Health Check (mode dépannage)

Le mode dépannage est une arme sous-estimée : vous pouvez désactiver tous les plugins pour vous uniquement (les visiteurs ne voient rien). Ensuite vous réactivez un par un jusqu’à reproduire le fatal.

Dans mon expérience, c’est le moyen le plus rapide de prouver “c’est tel plugin” sans casser le site en production.

Étape 4 : lister les callbacks sur save_post (snippet de diagnostic)

Si vous avez accès au code, vous pouvez loguer ce qui est attaché à save_post. Ce n’est pas un outil grand public, mais en dépannage c’est redoutable.

Où coller : mu-plugin temporaire (recommandé). Créez wp-content/mu-plugins/diag-save-post.php.

<?php
/**
 * Plugin Name: DIAG - save_post callbacks
 * Description: Diagnostic temporaire : logue les callbacks attachés à save_post.
 * Author: Vous
 */

add_action( 'admin_init', function () {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	global $wp_filter;

	if ( empty( $wp_filter['save_post'] ) ) {
		error_log( '[DIAG save_post] Aucun callback sur save_post.' );
		return;
	}

	$hook = $wp_filter['save_post'];

	// WP_Hook stocke les callbacks par priorité.
	$callbacks = $hook->callbacks ?? array();

	error_log( '[DIAG save_post] --- Début liste callbacks ---' );

	foreach ( $callbacks as $priority => $items ) {
		foreach ( $items as $item ) {
			$fn = $item['function'];

			if ( is_string( $fn ) ) {
				error_log( "[DIAG save_post] priority={$priority} function={$fn}" );
			} elseif ( is_array( $fn ) ) {
				// [objet, méthode] ou [classe, méthode]
				$who = is_object( $fn[0] ) ? get_class( $fn[0] ) : (string) $fn[0];
				$method = (string) $fn[1];
				error_log( "[DIAG save_post] priority={$priority} method={$who}::{$method}" );
			} elseif ( $fn instanceof Closure ) {
				error_log( "[DIAG save_post] priority={$priority} closure" );
			} else {
				error_log( "[DIAG save_post] priority={$priority} callback_inconnu" );
			}
		}
	}

	error_log( '[DIAG save_post] --- Fin liste callbacks ---' );
} );

Ensuite, rechargez l’admin et regardez debug.log. Vous verrez quels plugins/thèmes accrochent save_post. Vous pourrez ensuite inspecter le plus suspect (priorité élevée, plugin récent, etc.).

Important : supprimez ce mu-plugin après diagnostic. Il logue beaucoup.

Étape 5 : WP-CLI (avancé, mais très pratique)

Si vous avez WP-CLI, vous pouvez rapidement vérifier versions et santé :

wp --info
wp core version
wp plugin list --status=active
wp theme list
wp config get WP_DEBUG
wp doctor check --all

WP-CLI : https://wp-cli.org/


Vérifications après correction

Après avoir appliqué une correction, testez dans cet ordre :

  1. Enregistrement simple : éditez un article, modifiez une phrase, cliquez “Mettre à jour”. Attendez un message de succès.
  2. Gutenberg : vérifiez qu’il n’y a plus “Réponse JSON invalide”. Ouvrez l’onglet Réseau : la requête REST doit renvoyer 200.
  3. Rôles : testez avec un compte Éditeur (ou Auteur) si votre site en a. Beaucoup de fatals viennent d’un current_user_can() absent.
  4. Autosave : laissez l’éditeur ouvert 1 à 2 minutes. Si votre code cassait sur autosave, vous verrez des erreurs revenir.
  5. Builders (si concernés) : éditez une page Elementor / Divi / Avada et sauvegardez. Si votre snippet touchait tous les post types, c’est ici que ça réapparaît.

Puis :

  • Videz les caches (plugin de cache, serveur, CDN) si vous avez modifié un mu-plugin ou un plugin custom.
  • Surveillez debug.log pendant 10 minutes d’édition : zéro nouvelle entrée est l’objectif.

Si ça ne marche toujours pas

Quand l’erreur persiste, je procède comme suit.

1) Revenir à un état “minimal” sans casser les visiteurs

  • Activez Health Check → mode dépannage.
  • Repassez temporairement sur un thème par défaut (si possible) uniquement pour vous.
  • Désactivez les plugins non indispensables, puis testez l’enregistrement.

2) Vérifier la mémoire et le temps d’exécution

Un fatal “sur save_post” peut être un symptôme d’un script trop lourd (génération PDF, API externe, traitement d’images). Sur l’hébergement mutualisé, ça explose au save.

  • Regardez les logs : Allowed memory size exhausted, Maximum execution time.
  • Vérifiez la configuration dans “Outils → Santé du site”.

Doc PHP mémoire : php.net memory_limit

3) Vérifier un conflit cache / optimisation

  • Désactivez temporairement la minification JS/CSS (certains plugins injectent des scripts admin).
  • Videz le cache navigateur (ou testez en navigation privée).
  • Si vous utilisez un cache objet persistant (Redis/Memcached), videz-le.

4) Vérifier les permaliens (rare, mais piégeux)

Si l’enregistrement passe mais Gutenberg dit “JSON invalide”, parfois la route REST est cassée par des règles de réécriture. Allez dans “Réglages → Permaliens” et cliquez “Enregistrer” (sans changer). Ça régénère les règles.

5) Vérifier les nonces et permissions

Si vous voyez des erreurs liées à la sécurité :

  • Assurez-vous que le nonce est généré et envoyé.
  • Assurez-vous que votre callback vérifie current_user_can( 'edit_post', $post_id ).

6) Vérifier que vous n’appelez pas une fonction “trop tôt”

Erreur typique : Call to undefined function acf_update_value() (ou équivalent) dans save_post. Ça arrive si le plugin n’est pas chargé dans ce contexte (ou si vous êtes sur un post type où il ne s’applique pas).

  • Ajoutez un test function_exists() avant d’appeler la fonction.
  • Ou déplacez la logique vers un hook plus adapté (spécifique au plugin, ou init / rest_api_init selon le cas).

Pièges et erreurs courantes

Symptôme / message Cause probable Solution recommandée
Call to a member function ... on null Vous supposez qu’un objet existe (ex: champ, post, plugin) alors qu’il est null pendant autosave/REST Solution 1 : garde-fous + vérifier l’existence avant usage
Undefined array key sur $_POST[...] Champ absent (autosave, quick edit, REST) Tester isset() + wp_unslash() + fallback
Allowed memory size exhausted Récursion, ou traitement trop lourd pendant save Solution 2 + déplacer le traitement en tâche différée (cron/queue)
“Réponse JSON invalide” (éditeur de blocs) Fatal PHP pendant la réponse REST Lire logs + corriger le hook, vider cache, vérifier permaliens
Erreur après avoir “collé un snippet” Code collé dans le mauvais fichier / parenthèse manquante / thème parent Restaurer via FTP, coller dans thème enfant ou plugin custom, valider la syntaxe
Ça marche en admin mais pas via API Dépendance à $_POST et non à la REST meta, ou vérif nonce trop stricte Enregistrer la meta via register_post_meta() + show_in_rest
Ça casse uniquement avec Divi/Elementor/Avada Votre code touche des post types techniques du builder Limiter par $post->post_type + conditions

Erreurs que je vois souvent “en vrai”

  • Copier le code dans functions.php du thème parent, puis perdre la correction à la prochaine mise à jour.
  • Oublier un point-virgule : l’erreur se produit “au save” parce que ce fichier est chargé à chaque requête admin.
  • Confondre action et filtre : vous utilisez add_filter au lieu de add_action (ou inversement), puis vous attendez un retour.
  • Utiliser un hook inadapté : save_post au lieu de save_post_post (spécifique au post type “post”).
  • Tester directement en production sans sauvegarde, puis ne plus pouvoir accéder à l’admin.

Variante / alternative

Méthode sans code (pour blogueurs)

Si vous ne voulez pas toucher au code :

  • Utilisez Health Check pour identifier le plugin fautif.
  • Remplacez le snippet par un plugin de snippets plus sûr qui permet la désactivation depuis l’admin (utile si vous faites une erreur). Certains plugins de snippets offrent un “safe mode”.
  • Si l’erreur vient d’un plugin premium : ouvrez un ticket support avec le stack trace complet (fichier + ligne + message). Sans ça, ils vous feront tourner.

Méthode plus avancée (développeurs) : meta enregistrée proprement (REST-friendly)

Si votre objectif est de sauvegarder des métadonnées depuis Gutenberg, évitez de dépendre de $_POST. Enregistrez la meta pour qu’elle soit gérée proprement par WordPress (validation, permissions, REST).

Où coller : plugin custom / mu-plugin.

add_action( 'init', function () {
	register_post_meta(
		'post',
		'_mon_sous_titre',
		array(
			'type'              => 'string',
			'single'            => true,
			'sanitize_callback' => 'sanitize_text_field',
			'auth_callback'     => function () {
				// Autorise uniquement les utilisateurs pouvant éditer des articles.
				return current_user_can( 'edit_posts' );
			},
			'show_in_rest'      => true, // Rend la meta utilisable via l'éditeur de blocs / REST
		)
	);
} );

Ça ne remplace pas tous les cas (metabox custom, logique métier), mais ça réduit énormément les “fatal on save_post” liés à REST.


Éviter ce problème à l’avenir

  • Utilisez save_post_{post_type} quand c’est possible. Exemple : save_post_post ne se déclenche que pour les articles. Ça évite de casser des CPT de builders.
  • Ajoutez toujours les garde-fous : autosave, révision, capabilities, nonce (si formulaire).
  • Évitez les appels externes (API, emails, génération) dans save_post. Déclenchez plutôt une tâche différée (cron) et loguez.
  • Ne modifiez pas le post dans save_post sans anti-récursion. Si vous devez le faire, utilisez remove_action ou un verrou.
  • Centralisez vos snippets dans un plugin custom versionné (Git si possible). Les snippets “éparpillés” dans le thème finissent par devenir ingérables.
  • Sur PHP 8.1+, traitez les warnings comme des signaux. Un Undefined array key aujourd’hui peut devenir un fatal demain quand une autre condition s’ajoute.

Exemple de hook plus ciblé

add_action( 'save_post_post', 'mon_save_post_article', 10, 3 );

function mon_save_post_article( $post_id, $post, $update ) {
	// Même garde-fous, mais on sait déjà qu'on est sur le post type "post".
	if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
		return;
	}

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

	// ...
}

Ressources


Questions fréquentes

Où se trouve exactement save_post ?

save_post est une action déclenchée par le cœur WordPress pendant l’enregistrement d’un post (via wp_insert_post()). Vous ne “trouvez” pas un fichier unique : vous vous accrochez au hook avec add_action(). Référence : developer.wordpress.org.

Pourquoi l’erreur arrive seulement avec Gutenberg et pas avec l’éditeur classique ?

Gutenberg enregistre beaucoup via l’API REST. Si votre code dépend de $_POST (metabox classique), il peut ne pas trouver vos champs dans certains flux REST, et déclencher des erreurs. La solution est soit d’ajouter des garde-fous, soit d’enregistrer la meta via register_post_meta() avec show_in_rest.

Est-ce que je peux désactiver save_post ?

Non, c’est un hook central. En revanche, vous pouvez retirer votre callback avec remove_action(), ou corriger sa logique pour qu’il ne s’exécute que dans les cas voulus.

J’ai une erreur 500, mais rien dans debug.log. Pourquoi ?

Deux causes fréquentes : (1) WP_DEBUG_LOG n’est pas actif ou WordPress n’a pas le droit d’écrire dans wp-content, (2) l’hébergeur redirige les erreurs vers ses propres logs PHP-FPM. Vérifiez aussi les permissions fichiers et l’espace disque.

Que faire si je n’ai plus accès à l’admin après avoir collé un snippet ?

Connectez-vous en FTP/gestionnaire de fichiers, renommez le dossier du plugin fautif (ou retirez le code du functions.php du thème enfant). Si vous utilisiez un plugin de snippets, renommez temporairement son dossier pour le désactiver.

Pourquoi faut-il éviter de faire des appels API dans save_post ?

Parce que l’enregistrement doit rester rapide et fiable. Un appel externe lent peut provoquer un timeout, et un échec réseau peut casser la sauvegarde. Mieux : stocker une “tâche à faire” en meta et traiter via cron/queue.

Divi 5 / Elementor / Avada peuvent-ils “causer” l’erreur ?

Ils ne causent pas forcément l’erreur, mais ils multiplient les post types et les flux de sauvegarde. Un snippet mal filtré (qui s’exécute sur tous les contenus) a plus de chances de tomber sur un cas inattendu et de casser.

Quelle est la différence entre save_post et save_post_{post_type} ?

save_post se déclenche pour tous les types de contenus. save_post_post, save_post_page, etc. se déclenchent uniquement pour un type précis. Pour réduire les risques, utilisez la version spécifique quand vous le pouvez.

Je vois “nonce verification failed”. C’est grave ?

Oui : soit votre formulaire n’envoie pas le nonce, soit vous vérifiez le mauvais nom/action, soit un cache/proxy altère la requête. Sans nonce valide, n’écrivez pas en base. Corrigez la génération (wp_nonce_field()) et la vérification (wp_verify_nonce()).

Est-ce que PHP 8.1 peut “révéler” un bug qui existait déjà ?

Oui. Des pratiques tolérées sur de vieux environnements (types implicites, appels sur null, librairies obsolètes) cassent plus facilement sur PHP 8.1+. Si l’erreur est apparue après une montée de version PHP, inspectez d’abord les plugins/snippets non mis à jour.