Si vous avez déjà vu un site “fonctionner” mais ne plus envoyer d’e-mails, ne plus publier des contenus programmés, ou laisser un panier WooCommerce expiré traîner pendant des jours sans aucune erreur visible, vous avez probablement un WP-Cron qui échoue en silence.

Le problème

Le plus frustrant avec WP-Cron, c’est qu’il échoue souvent sans message dans l’admin. Vous le découvrez par effet de bord (posts planifiés en retard, e-mails non envoyés, nettoyage jamais exécuté), ou dans des logs quand vous avez de la chance.

Erreurs typiques qu’on retrouve dans wp-content/debug.log (ou dans les logs PHP-FPM/Apache) :

PHP Fatal error:  Uncaught Error: Call to undefined function myplugin_do_cleanup() in /wp-content/plugins/myplugin/myplugin.php:123
Stack trace:
#0 /wp-includes/class-wp-hook.php(324): myplugin_cleanup_callback()
#1 /wp-includes/class-wp-hook.php(348): WP_Hook->apply_filters()
#2 /wp-includes/plugin.php(517): WP_Hook->do_action()
#3 /wp-cron.php(188): do_action_ref_array()
#4 {main}
  thrown in /wp-content/plugins/myplugin/myplugin.php on line 123
cURL error 28: Operation timed out after 5000 milliseconds with 0 bytes received
WordPress database error Deadlock found when trying to get lock; try restarting transaction for query UPDATE wp_options SET option_value = ... WHERE option_name = 'cron'

Où ça apparaît :

  • Front-end : parfois nulle part, car WP-Cron est déclenché en arrière-plan après une requête visiteur.
  • Admin : posts “Planifié” qui restent bloqués, actions différées (indexation, synchronisations) qui n’avancent pas.
  • wp-cron.php : si vous le lancez manuellement, vous pouvez voir des 200/500, ou des timeouts côté serveur.
  • API / AJAX : certains plugins planifient des tâches lors d’appels REST, puis la tâche n’exécute jamais.

Circonstances typiques :

  • Après activation d’un plugin de cache/CDN/WAF (Cloudflare, cache serveur, plugin de performance) qui bloque ou met en cache l’appel à wp-cron.php.
  • Après migration : domaine changé, HTTPS forcé, règles de firewall plus strictes, ou cron système mal configuré.
  • Après mise à jour PHP : passage à PHP 8.1+ et une tâche cron exécute un vieux code déprécié ou fatal.
  • Sur sites à faible trafic : WP-Cron ne se déclenche pas “à l’heure”.

À qui s’adresse ce guide : vous gérez des sites WordPress 6.9.4 (avril 2026) et vous voulez identifier précisément quelles tâches échouent, pourquoi, et appliquer des corrections robustes (code + serveur). À la fin, vous saurez instrumenter WP-Cron, isoler les causes (verrou cron, cache, timeouts, hooks mal déclarés), et valider la remise en route.

Résumé rapide

  • WP-Cron n’est pas un “vrai cron” : il dépend d’un déclenchement HTTP. Sans trafic ou si wp-cron.php est bloqué, vos tâches n’exécutent pas.
  • Commencez par observer : loggez l’exécution des hooks cron (début/fin/erreur) et mesurez la durée.
  • Vérifiez le verrou (doing_cron) et la santé du stockage des événements (option cron), surtout en environnement multi-serveurs.
  • Les échecs les plus fréquents : hook non enregistré au bon moment, mauvais nombre d’arguments, fatal PHP, timeout HTTP, cache/WAF qui bloque wp-cron.php.
  • La solution la plus fiable en production : désactiver le déclenchement “visiteurs” et utiliser un cron système (ou un scheduler externe) qui appelle WP-CLI.

Les symptômes

Ce que vous voyez côté site (du plus courant au plus trompeur) :

  • Articles planifiés qui restent en “Planifié” ou passent en “Publié” avec plusieurs heures/jours de retard.
  • Emails (formulaires, notifications, digests) envoyés en retard ou jamais.
  • Nettoyages (transients, logs, révisions, sessions) jamais exécutés : base qui grossit, performances qui se dégradent.
  • Tâches de plugins bloquées : synchronisation CRM, import/export, indexation, purge cache, régénération miniatures.
  • Pic CPU/IO aléatoire : quand WP-Cron finit par se déclencher, il “rattrape” un backlog.
  • Erreurs 500 sporadiques sur /wp-cron.php dans les logs serveur, sans impact visible immédiat.
  • REST/AJAX : des actions différées ne se produisent pas (ex : webhooks en attente), sans erreur dans la console navigateur.

Signaux secondaires que j’ai souvent croisés sur des sites avec Divi 5 / Elementor / Avada :

  • Un builder déclenche des opérations en arrière-plan (génération CSS, optimisation, préchargement) planifiées via cron. Si WP-Cron est KO, vous voyez des incohérences (CSS manquant, cache jamais purgé) et vous cherchez au mauvais endroit.
  • Certains plugins “performance” couplés à ces thèmes ajoutent une couche de cache agressive qui finit par cacher ou bloquer l’appel à wp-cron.php.

Pourquoi ça arrive

Version simple (pour remettre les idées en place)

WP-Cron exécute des tâches planifiées quand quelqu’un visite votre site (ou quand un serveur appelle wp-cron.php). S’il n’y a pas de visite, pas de déclenchement. Si l’appel est bloqué (cache, firewall, DNS, SSL), pas de tâches. Et si une tâche plante (fatal PHP), les suivantes peuvent ne jamais s’exécuter, sans notification claire.

Version technique (ce qui se passe en coulisses)

WordPress stocke les événements cron dans l’option cron (en base). À chaque requête, spawn_cron() peut tenter de déclencher un appel HTTP non bloquant vers wp-cron.php, en posant un verrou via l’option doing_cron pour éviter les exécutions concurrentes. Ensuite wp-cron.php charge WordPress et exécute les hooks planifiés via do_action_ref_array().

Variantes qui mènent aux mêmes symptômes :

  • Déclenchement impossible : wp-cron.php renvoie 403 (WAF), 301 en boucle (HTTPS), timeout (DNS), ou est mis en cache.
  • Déclenchement rare : site à faible trafic, ou pages servies en cache statique sans exécuter PHP (et donc sans opportunité de spawn).
  • Exécution qui plante : fatal PHP, mémoire, timeout max_execution_time, deadlock DB, mauvaise signature de callback.
  • Verrou bloqué : doing_cron reste “collé” après un crash, un kill PHP-FPM, ou en environnement multi-serveurs mal coordonné.

Causes probables (du plus fréquent au plus rare) :

  1. Cache/WAF/CDN bloque ou met en cache /wp-cron.php.
  2. Faible trafic + WP-Cron “visiteurs” : rien ne se déclenche.
  3. Fatal PHP dans une tâche (souvent après update PHP 8.1+ ou update plugin).
  4. Hook mal enregistré (enregistré trop tard, ou seulement en admin), ou mauvais nombre d’arguments.
  5. Timeout (HTTP API, requêtes externes, traitement d’images) qui dépasse le temps serveur.
  6. Verrou doing_cron bloqué / concurrence (trafic élevé, multi-serveurs, object cache).
  7. Stockage cron corrompu (option cron trop grosse, autoload problématique, DB instable).

Tableau de diagnostic (rapide mais utile)

Symptôme Cause probable Vérification Solution
Posts planifiés en retard Site à faible trafic / wp-cron.php bloqué Tester /wp-cron.php, vérifier logs 403/timeout Cron système + désactiver WP-Cron visiteurs
Rien ne s’exécute, aucune erreur Cache/CDN met en cache wp-cron.php Headers cache sur /wp-cron.php, règles WAF Exclure /wp-cron.php du cache/WAF
Certaines tâches seulement Fatal PHP dans un callback debug.log, logs PHP-FPM, Query Monitor (si déclenchable) Corriger le callback, gérer erreurs, réduire charge
Exécutions irrégulières Verrou doing_cron collé / concurrence Inspecter option doing_cron, observer timestamps Nettoyer verrou + cron système + éviter concurrence
wp-cron.php en 500 Timeout/mémoire/fatal error_log serveur, augmenter logs, reproduire via CLI WP-CLI cron event run + patch code/perf

Prérequis avant de commencer

  • Sauvegarde : base + fichiers, ou snapshot si vous êtes sur VPS/Cloud. Ne testez pas “au hasard” en prod.
  • Environnement de test : clone staging avec mêmes versions PHP/extensions et même cache (sinon vous ratez la cause).
  • Versions : WordPress 6.9.4, PHP 8.1+ (8.2/8.3 ok), et plugins à jour.
  • Activer les logs sur staging (ou temporairement en prod) :
/**
 * wp-config.php (staging idéalement)
 * Objectif : capturer les fatals et warnings pendant les exécutions cron.
 */
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false ); // Évite d'afficher des erreurs aux visiteurs
  • Outils :
    • Query Monitor (utile pour erreurs PHP/HTTP, mais pas toujours pour wp-cron.php si c’est hors cycle normal).
    • Health Check & Troubleshooting (isoler conflits thème/plugins).
    • WP-CLI (quand possible) : indispensable pour lancer des événements sans HTTP.

Docs officielles utiles à garder ouvertes :

Solution 1 : observer et journaliser WP-Cron (sans deviner)

Le piège classique : vous “pensez” que WP-Cron ne tourne pas, alors que le vrai problème est une tâche qui plante, ou un hook mal déclaré. Donc on instrumente.

1) Vérifier ce qui est planifié (WP-CLI recommandé)

Sur le serveur :

# Liste des événements cron planifiés
wp cron event list --fields=hook,next_run,recurrence,args --format=table

# Lancer tous les événements "dus" maintenant (attention en prod)
wp cron event run --due-now

Si wp cron event list est vide alors que des plugins devraient planifier des tâches, vous avez souvent :

  • un plugin qui planifie uniquement à l’activation (et l’activation a échoué),
  • un site cloné sans exécuter la routine d’activation,
  • une tâche supprimée lors d’une migration.

2) Ajouter un “logger” minimaliste pour les hooks cron ciblés

Je préfère un mini-plugin mu-plugin (must-use) pour éviter qu’un thème enfant ou un plugin de snippets désactivé casse votre instrumentation.

Créez wp-content/mu-plugins/cron-observer.php :

<?php
/**
 * Plugin Name: MU - Cron Observer
 * Description: Journalise l'exécution de certains hooks WP-Cron (WordPress 6.9.4+).
 */

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

final class BPCAB_Cron_Observer {

	/**
	 * Liste des hooks à observer.
	 * Ajoutez ici les hooks critiques (newsletter, purge cache, sync, etc.).
	 */
	private array $hooks = [
		'wp_version_check',
		'wp_update_plugins',
		'wp_update_themes',
		// Exemples : remplacez par vos hooks réels
		'myplugin_cleanup_daily',
		'myplugin_sync_hourly',
	];

	public function register(): void {
		foreach ( $this->hooks as $hook ) {
			// Priorité très basse pour être exécuté tôt, et accepter jusqu'à 10 arguments.
			add_action( $hook, [ $this, 'log_start' ], 0, 10 );
			add_action( $hook, [ $this, 'log_end' ], PHP_INT_MAX, 10 );
		}
	}

	public function log_start( ...$args ): void {
		$hook = current_filter();
		$this->log( sprintf( '[CRON][START] hook=%s args=%s', $hook, wp_json_encode( $args ) ) );
	}

	public function log_end( ...$args ): void {
		$hook = current_filter();
		$this->log( sprintf( '[CRON][END] hook=%s', $hook ) );
	}

	private function log( string $message ): void {
		// Utilise error_log : récupérable via debug.log si WP_DEBUG_LOG=true, sinon logs PHP-FPM.
		error_log( $message );
	}
}

add_action( 'plugins_loaded', static function () {
	$observer = new BPCAB_Cron_Observer();
	$observer->register();
} );

Pourquoi ça aide : si vous voyez [START] sans [END] pour un hook, vous avez probablement un fatal PHP, un timeout, ou un exit sauvage dans le callback d’un plugin.

3) Capturer les fatals (shutdown handler)

Les fatals ne passent pas toujours proprement dans vos logs applicatifs. Ajoutez un handler de fin d’exécution (toujours dans le mu-plugin) :

<?php
// À ajouter dans le même fichier MU, ou un autre MU-plugin.

add_action( 'muplugins_loaded', static function () {
	register_shutdown_function( static function () {
		$error = error_get_last();
		if ( empty( $error ) ) {
			return;
		}

		$fatal_types = [ E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR ];
		if ( ! in_array( $error['type'], $fatal_types, true ) ) {
			return;
		}

		// Si l'exécution provient probablement de wp-cron.php, on le note explicitement.
		$is_cron = ( defined( 'DOING_CRON' ) && DOING_CRON );

		error_log(
			sprintf(
				'[CRON][FATAL] doing_cron=%s message=%s file=%s line=%d',
				$is_cron ? 'yes' : 'no',
				$error['message'],
				$error['file'],
				(int) $error['line']
			)
		);
	} );
} );

Edge case réel : sur certains hébergeurs, les logs PHP sont tronqués. Le préfixe [CRON] vous permet de filtrer rapidement.

4) Détecter un verrou “doing_cron” qui reste collé

Quand WP-Cron se lance, WordPress pose un verrou via l’option doing_cron. Si un processus meurt, le verrou peut rester (ou être renouvelé en boucle par une mauvaise config).

Snippet de diagnostic (WP-CLI eval) :

wp eval 'var_dump( get_option("doing_cron") );'

Si vous voyez une valeur “récente” qui ne bouge jamais alors que rien ne s’exécute, vous avez souvent :

  • un appel à wp-cron.php qui boucle/timeout,
  • un cache d’objets persistant mal configuré en multi-serveurs,
  • un WAF qui coupe la requête après la pose du verrou.

Solution 2 : fiabiliser le déclenchement du cron (trafic, cache, serveur)

Si WP-Cron ne se déclenche pas, vous pouvez corriger 50 callbacks : rien ne changera. On commence par rendre l’exécution fiable.

Cas 1 (très fréquent) : /wp-cron.php bloqué par cache/WAF/CDN

Vérification rapide :

  • Ouvrez https://votre-domaine.tld/wp-cron.php?doing_wp_cron=1 dans un navigateur (ou via curl).
  • Regardez le code HTTP et les headers cache.
curl -I "https://votre-domaine.tld/wp-cron.php?doing_wp_cron=1"

Ce que vous voulez :

  • HTTP 200 (ou 204 selon serveur),
  • pas de redirection en boucle (301/302 répétés),
  • pas de cache agressif,
  • pas de challenge/bot protection sur cette URL.

Correctifs typiques :

  • Exclure /wp-cron.php du cache plugin (Rocket/Perfmatters/etc.) et du cache serveur.
  • Dans Cloudflare/WAF : créer une règle “Skip” (pas de cache, pas de challenge) pour /wp-cron.php.
  • Si vous forcez HTTPS via plugin et que le serveur force aussi : corriger la source (mieux vaut une redirection côté serveur).

Cas 2 : site à faible trafic (WP-Cron “visiteurs” insuffisant)

Sur un site vitrine/blog avec peu de visites, WP-Cron est mécaniquement en retard. La solution robuste en 2026 : cron système + WP-CLI.

Étape A : désactiver WP-Cron “visiteurs”

Dans wp-config.php :

/**
 * Désactive le déclenchement WP-Cron lors des visites.
 * Vous DEVEZ configurer un cron système derrière, sinon plus rien ne tourne.
 */
define( 'DISABLE_WP_CRON', true );

Étape B : configurer un cron système (Linux) via WP-CLI

Exemple : exécuter toutes les 5 minutes.

*/5 * * * * cd /var/www/votre-site && /usr/bin/wp cron event run --due-now --quiet

Pourquoi WP-CLI plutôt qu’un curl sur wp-cron.php :

  • Pas de dépendance HTTP/DNS/SSL/WAF.
  • Meilleure observabilité (stdout/stderr, exit codes).
  • Moins de surprises avec le cache.

Edge case : en environnement multi-serveurs, exécutez ce cron sur un seul nœud (ou utilisez un lock distribué). Sinon, vous aurez des exécutions concurrentes et des effets de bord (double envoi d’e-mails, double purge, etc.).

Cas 3 : timeouts HTTP lors du spawn (loopback requests)

WP-Cron dépend souvent de requêtes loopback (le site s’appelle lui-même). Sur certains hébergements, ça échoue (DNS interne, IPv6, firewall). Vérifiez “Loopback Requests” dans Santé du site, ou via logs.

Source officielle : Site Health

Correctifs fréquents :

  • Autoriser le serveur à joindre son propre domaine (firewall sortant).
  • Corriger DNS/hosts si nécessaire (rare, mais je l’ai vu sur des stacks Docker mal montées).
  • Passer au cron système (encore une fois).

Solution 3 : corriger les tâches qui échouent (mauvais hook, arguments, timeouts)

Ici on traite le cas où WP-Cron se déclenche bien, mais une ou plusieurs tâches échouent. C’est là que les erreurs “silencieuses” coûtent cher.

Exemple 1 : hook enregistré au mauvais endroit (ou seulement en admin)

Code AVANT (cassé). J’ai souvent vu ce pattern dans un thème enfant, ou un snippet collé dans un builder :

<?php
// functions.php (AVANT) - problème : le hook cron n'est enregistré qu'en admin
if ( is_admin() ) {
	add_action( 'myplugin_cleanup_daily', 'myplugin_cleanup_callback' );
}

function myplugin_cleanup_callback() {
	// Nettoyage...
}

Pourquoi ça casse : wp-cron.php n’est pas forcément “admin”, et votre callback n’est pas attaché au hook au moment de l’exécution. Résultat : l’événement se déclenche, mais ne fait rien (silencieux).

Code APRÈS (corrigé) :

<?php
// functions.php ou (mieux) un plugin dédié (APRÈS)
add_action( 'myplugin_cleanup_daily', 'myplugin_cleanup_callback' );

/**
 * Callback cron : doit être enregistré à chaque chargement (front/admin/cron).
 */
function myplugin_cleanup_callback(): void {
	// Nettoyage...
}

Détail : si votre code dépend de plugins tiers, accrochez-vous à plugins_loaded (ou plus tard) pour garantir que les dépendances sont chargées, mais pas à un hook admin-only.

Exemple 2 : mauvais nombre d’arguments (fatal PHP 8.x)

Code AVANT (cassé). L’événement passe 1 argument, mais le callback en attend 2 :

<?php
// AVANT : le callback attend 2 arguments, mais add_action n'en accepte qu'1 par défaut
add_action( 'myplugin_sync_hourly', 'myplugin_sync_callback' );

function myplugin_sync_callback( int $site_id, string $mode ): void {
	// ...
}

En PHP 8.1+, ce type de mismatch peut provoquer des warnings, voire des fatals selon le code (types stricts, opérations sur null, etc.). Et en cron, vous le voyez rarement à l’écran.

Code APRÈS (corrigé) :

<?php
// APRÈS : on déclare le nombre d'arguments acceptés
add_action( 'myplugin_sync_hourly', 'myplugin_sync_callback', 10, 2 );

/**
 * @param int    $site_id Identifiant du site.
 * @param string $mode    Mode de synchronisation.
 */
function myplugin_sync_callback( int $site_id, string $mode ): void {
	// ...
}

Et côté planification, assurez-vous de passer les arguments :

<?php
// Planification correcte (exemple)
if ( ! wp_next_scheduled( 'myplugin_sync_hourly', [ 42, 'delta' ] ) ) {
	wp_schedule_event( time() + 60, 'hourly', 'myplugin_sync_hourly', [ 42, 'delta' ] );
}

Exemple 3 : planification en double (événements dupliqués, backlog)

Code AVANT (cassé) : planifier à chaque chargement sans garde-fou.

<?php
// AVANT : chaque page vue ajoute un nouvel événement
add_action( 'init', function () {
	wp_schedule_event( time(), 'hourly', 'myplugin_cleanup_daily' );
} );

Symptômes : vous voyez des dizaines/centaines d’événements identiques dans la liste cron, et des pics CPU quand ça se déclenche.

Code APRÈS (corrigé) :

<?php
add_action( 'init', function () {
	// Garde-fou : ne planifie que si rien n'est déjà planifié
	if ( ! wp_next_scheduled( 'myplugin_cleanup_daily' ) ) {
		wp_schedule_event( time() + 300, 'daily', 'myplugin_cleanup_daily' );
	}
} );

Note : si vous avez besoin d’arguments, wp_next_scheduled() doit recevoir exactement le même tableau d’args, sinon vous ne “verrez” pas l’événement existant.

Exemple 4 : tâche trop longue (timeout) et absence de chunking

Code AVANT (cassé) : traitement massif en une fois (ex : 50 000 posts). En cron HTTP, vous allez taper le max_execution_time ou la mémoire.

<?php
add_action( 'myplugin_rebuild_index', function () {
	$posts = get_posts( [
		'post_type'      => 'post',
		'posts_per_page' => -1,
		'fields'         => 'ids',
	] );

	foreach ( $posts as $post_id ) {
		// Rebuild index (long)
		myplugin_index_post( $post_id );
	}
} );

Code APRÈS (corrigé) : on découpe en lots et on re-planifie un single event tant qu’il reste du travail. C’est le pattern le plus fiable que j’utilise en prod.

<?php
add_action( 'myplugin_rebuild_index_batch', 'myplugin_rebuild_index_batch_callback', 10, 2 );

/**
 * Traite l'indexation par lots pour éviter les timeouts.
 *
 * @param int $offset Décalage.
 * @param int $limit  Taille du lot.
 */
function myplugin_rebuild_index_batch_callback( int $offset, int $limit ): void {
	$ids = get_posts( [
		'post_type'      => 'post',
		'posts_per_page' => $limit,
		'offset'         => $offset,
		'fields'         => 'ids',
		'orderby'        => 'ID',
		'order'          => 'ASC',
	] );

	foreach ( $ids as $post_id ) {
		myplugin_index_post( (int) $post_id );
	}

	// S'il reste des posts, on replanifie un lot.
	if ( count( $ids ) === $limit ) {
		wp_schedule_single_event( time() + 30, 'myplugin_rebuild_index_batch', [ $offset + $limit, $limit ] );
	}
}

/**
 * Démarre la reconstruction (à appeler depuis un écran admin ou WP-CLI).
 */
function myplugin_start_rebuild_index(): void {
	wp_schedule_single_event( time() + 5, 'myplugin_rebuild_index_batch', [ 0, 200 ] );
}

Pourquoi ça corrige : vous ramenez le temps d’exécution à une fenêtre contrôlée, vous évitez les timeouts et vous reprenez après interruption. C’est aussi plus “fair” pour la DB.

Exemple 5 : erreurs silencieuses côté HTTP API (wp_remote_get) et absence de gestion

Code AVANT (cassé) : on ignore les erreurs, donc vous ne savez pas si c’est un timeout, un 401, ou un DNS.

<?php
add_action( 'myplugin_sync_hourly', function () {
	$response = wp_remote_get( 'https://api.exemple.tld/sync' );
	$data     = json_decode( wp_remote_retrieve_body( $response ), true );

	// ... utilise $data sans vérifier
} );

Code APRÈS (corrigé) : gestion d’erreurs + log + backoff simple.

<?php
add_action( 'myplugin_sync_hourly', 'myplugin_sync_hourly_callback' );

function myplugin_sync_hourly_callback(): void {
	$url      = 'https://api.exemple.tld/sync';
	$response = wp_remote_get( $url, [
		'timeout' => 15, // Évitez 5s si l'API est lente
	] );

	if ( is_wp_error( $response ) ) {
		error_log( '[CRON][SYNC][ERROR] wp_remote_get failed: ' . $response->get_error_message() );

		// Backoff : retenter dans 10 minutes
		wp_schedule_single_event( time() + 600, 'myplugin_sync_hourly' );
		return;
	}

	$status = (int) wp_remote_retrieve_response_code( $response );
	if ( $status < 200 || $status >= 300 ) {
		error_log( sprintf( '[CRON][SYNC][ERROR] HTTP %d for %s', $status, $url ) );
		return;
	}

	$body = wp_remote_retrieve_body( $response );
	$data = json_decode( $body, true );

	if ( JSON_ERROR_NONE !== json_last_error() ) {
		error_log( '[CRON][SYNC][ERROR] JSON invalide: ' . json_last_error_msg() );
		return;
	}

	// Traitement...
}

Ce que ça évite : des tâches qui “réussissent” mais ne font rien, et vous perdez des heures à suspecter WP-Cron alors que c’est l’API distante.


Vérifications après correction

  • WP-CLI :
    • wp cron event list : vos hooks existent, avec une prochaine exécution cohérente.
    • wp cron event run --due-now : s’exécute sans fatal et sans timeout.
  • Logs : vous voyez des paires [CRON][START] / [CRON][END] pour les hooks observés.
  • wp-cron.php : un curl -I renvoie 200 sans redirection suspecte.
  • Fonctionnel : un post planifié se publie à l’heure, une purge se fait, un e-mail de test part.

Test simple que j’utilise : planifier un single event “dans 2 minutes” et vérifier qu’il s’exécute.

# Planifie un événement test (hook "bpcab_cron_test") dans 120 secondes
wp eval 'wp_schedule_single_event(time()+120, "bpcab_cron_test"); echo "OKn";'

# Ajoutez ensuite un callback (MU-plugin) pour logguer ce hook.
<?php
// Dans un MU-plugin de test
add_action( 'bpcab_cron_test', static function () {
	error_log( '[CRON][TEST] événement test exécuté' );
} );

Si ça ne marche toujours pas

Procédure que j’applique quand le problème résiste (ordre important) :

  1. Confirmez le mode de déclenchement :

    • Si DISABLE_WP_CRON est à true, vérifiez que le cron système existe et tourne (logs cron, systemd timers, panneau hébergeur).
    • Sinon, testez /wp-cron.php au curl et cherchez 403/301/timeout.
  2. Réduisez le scope :

    • Désactivez temporairement cache/WAF côté plugin si possible.
    • Avec Health Check, passez en “troubleshooting mode” et testez uniquement le plugin qui planifie la tâche.
  3. Traquez le fatal :

    • Activez WP_DEBUG_LOG et ajoutez le shutdown handler (Solution 1).
    • Consultez les logs PHP-FPM/Apache/Nginx (souvent plus complets que debug.log).
  4. Vérifiez la concurrence :

    • Multi-serveurs : un seul exécuteur cron, ou lock distribué.
    • Object cache persistant : assurez-vous qu’il est stable (Redis/Memcached) et correctement configuré.
  5. Vérifiez la DB :

    • Deadlocks/locks : regardez les logs MySQL/MariaDB.
    • Option cron énorme : souvent signe d’événements dupliqués.
  6. Testez via WP-CLI :

    • wp cron event run <hook> pour isoler une tâche.
    • Si WP-CLI marche mais HTTP échoue : c’est réseau/WAF/cache, pas le code.

À ce stade, regardez aussi la console navigateur uniquement si votre plugin déclenche des planifications via AJAX/REST (ex : action planifiée après une requête admin). Un 401/403 REST peut empêcher la planification initiale.

Pièges et erreurs courantes

Symptôme Cause probable Solution recommandée
Vous ne voyez aucun log WP_DEBUG_LOG désactivé, ou logs PHP non accessibles Activer debug.log sur staging + utiliser error_log + logs serveur
Les tâches existent mais ne font rien Callback attaché uniquement en admin, ou fichier non chargé Déplacer add_action() dans un plugin chargé partout
Fatal “undefined function” Fonction déclarée après usage, ou dépendance non chargée Accrocher le code à plugins_loaded et vérifier l’ordre de chargement
Timeouts aléatoires Tâches trop longues (boucles massives, API lente) Chunking + single events + timeouts HTTP adaptés
Double envoi d’e-mails Cron système lancé sur 2 serveurs / concurrence Un seul exécuteur cron, ou lock applicatif
Vous avez “réparé” en prod et ça empire Test sans sauvegarde, pas de staging Rollback + reproduire sur staging
Erreur après copier/coller Parenthèse/point-virgule manquant dans un snippet Passer par un MU-plugin versionné, CI lint PHP
Hook jamais déclenché Vous avez utilisé un hook inadapté (init vs activation) Planifier à l’activation + garde-fou wp_next_scheduled()
Le code d’un vieux tuto ne marche plus Incompatibilités PHP 8.1+, pratiques obsolètes Mettre à niveau (types, gestion erreurs, WP-CLI)

Erreur que je vois encore : “copier le code au mauvais endroit”. Un add_action() dans un module Divi/Elementor (ou un snippet manager) peut ne pas être chargé lors de wp-cron.php. Si vous devez absolument utiliser un snippet manager, vérifiez qu’il s’exécute sur front + admin + cron, sinon passez en MU-plugin.

Variante / alternative

Méthode sans code : plugin de gestion du cron

Pour inspecter rapidement les événements cron via l’admin, un plugin comme WP Crontrol reste pratique. Vous pouvez :

  • voir les hooks planifiés,
  • exécuter un événement manuellement,
  • supprimer des événements dupliqués.

Limite : ça ne corrige pas un /wp-cron.php bloqué, et ça ne remplace pas WP-CLI en production.

Méthode avancée : exécuteur cron via service container (pattern “propre”)

Si vous développez un plugin qui planifie des tâches, structurez vos callbacks comme des services testables. Ça réduit les “fatals silencieux” et facilite l’instrumentation.

Exemple minimal (plugin) : un container très simple + un runner. Ce n’est pas un framework, juste une discipline.

<?php
/**
 * Exemple pédagogique : container minimaliste pour tâches cron.
 * Objectif : éviter les fonctions globales et faciliter les tests/logs.
 */

final class BPCAB_Container {
	private array $factories = [];
	private array $instances = [];

	public function set( string $id, callable $factory ): void {
		$this->factories[ $id ] = $factory;
	}

	public function get( string $id ) {
		if ( isset( $this->instances[ $id ] ) ) {
			return $this->instances[ $id ];
		}
		if ( ! isset( $this->factories[ $id ] ) ) {
			throw new RuntimeException( 'Service introuvable: ' . $id );
		}
		$this->instances[ $id ] = ( $this->factories[ $id ] )( $this );
		return $this->instances[ $id ];
	}
}

final class BPCAB_Cron_Task_Cleanup {

	public function run(): void {
		// Exemple : opération idempotente
		global $wpdb;
		$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_%' AND option_value < UNIX_TIMESTAMP()" );
	}
}

// Bootstrap
add_action( 'plugins_loaded', static function () {
	$container = new BPCAB_Container();

	$container->set( 'task.cleanup', static fn() => new BPCAB_Cron_Task_Cleanup() );

	add_action( 'bpcab_cleanup_daily', static function () use ( $container ) {
		try {
			$container->get( 'task.cleanup' )->run();
		} catch ( Throwable $e ) {
			// Journalisation explicite : sinon ce sera "silencieux"
			error_log( '[CRON][CLEANUP][FATAL] ' . $e->getMessage() );
			throw $e; // Optionnel : selon votre stratégie
		}
	} );
} );

Avec cette approche, vous pouvez remplacer error_log par Monolog via un bridge, ou router vers un service externe. Et surtout : vous savez exactement où entourer le code de try/catch.

Éviter ce problème à l’avenir

  • Préférez un cron système en production, surtout si le site est peu visité ou fortement caché. WP-Cron “visiteurs” est un fallback, pas une stratégie.
  • Rendez vos tâches idempotentes : si elles s’exécutent deux fois, elles ne doivent pas casser (ex : utiliser des verrous applicatifs, vérifier un état avant envoi).
  • Chunking pour tout traitement potentiellement long. Si une tâche peut dépasser 10–20 secondes, découpez.
  • Journalisez : au minimum début/fin + erreurs WP_Error + durées. Les “silencieux” coûtent plus cher que les erreurs bruyantes.
  • Évitez de planifier sur init sans garde-fou. Planifiez à l’activation du plugin, et utilisez wp_next_scheduled().
  • Surveillez : un check externe toutes les X minutes (uptime monitor) qui appelle une endpoint interne sécurisée ou qui vérifie qu’un heartbeat cron a bougé.

Sécurité : n’exposez pas une URL publique “run cron now” sans authentification. J’ai déjà vu des sites où un endpoint non protégé permettait à n’importe qui de déclencher des imports lourds (DoS applicatif).

Ressources

Questions fréquentes

WP-Cron est-il censé être fiable “par défaut” ?

Pour un blog avec du trafic constant et sans cache agressif, ça passe souvent. Dès que vous avez du cache full-page, un WAF strict, ou peu de visites, la fiabilité baisse nettement. En production, je traite WP-Cron comme un mécanisme applicatif, et je le déclenche via cron système.

Est-ce que désactiver WP-Cron va casser Divi 5 / Elementor / Avada ?

Non, tant que vous remplacez le déclenchement par un cron système. Les builders et plugins autour peuvent dépendre de tâches planifiées (purges, régénérations). Sans exécuteur, vous verrez des effets de bord.

Pourquoi mes articles planifiés se publient quand je me connecte à l’admin ?

Parce que votre visite déclenche une requête PHP qui finit par “spawner” WP-Cron. Sur un site peu visité, c’est un symptôme classique : les tâches attendent la prochaine requête.

Comment savoir quelle tâche précise plante ?

Instrumentez : logger début/fin des hooks critiques + shutdown handler pour capturer les fatals. Ensuite lancez les hooks un par un via WP-CLI (wp cron event run <hook>) pour isoler.

Je vois des centaines d’événements identiques, c’est grave ?

Oui : ça indique une planification en double (souvent sur init sans wp_next_scheduled()). Vous aurez backlog, pics CPU, et parfois des actions doublées (e-mails). Corrigez le code, puis supprimez les doublons (WP-CLI ou WP Crontrol).

Un plugin de cache peut-il empêcher WP-Cron de tourner ?

Oui, surtout si /wp-cron.php est mis en cache, challengé, ou bloqué. Excluez cette URL du cache et des protections anti-bot. Testez au curl -I.

WP-CLI marche, mais wp-cron.php en HTTP timeout : je fais quoi ?

Vous avez un problème réseau/WAF/cache/SSL sur le chemin HTTP. Gardez WP-CLI comme exécuteur (cron système), et corrigez la cause HTTP si vous en avez besoin pour d’autres loopbacks.

Est-ce que je peux “forcer” WP-Cron à tourner plus souvent ?

Vous pouvez déclencher plus souvent via cron système (toutes les 1–5 minutes), mais ne planifiez pas des tâches lourdes trop fréquemment. Ajustez la fréquence, ajoutez du chunking, et mesurez.

Dois-je stocker mes propres logs cron dans la base ?

Évitez en premier réflexe : vous risquez d’ajouter de l’IO DB à des tâches déjà fragiles. Commencez par error_log + logs serveur. Si vous avez besoin d’historique, utilisez une table dédiée et un niveau de log configurable.

Comment éviter les doubles exécutions en multi-serveurs ?

Un seul exécuteur cron (recommandé). Sinon, implémentez un lock distribué (Redis) autour des tâches critiques. Sans ça, vous aurez des race conditions, surtout sur les envois et les synchronisations.