Si vous avez déjà vu des tâches WP-Cron “s’accumuler” dans un plugin de monitoring, ou au contraire ne jamais se déclencher sur un site à faible trafic, vous avez touché le cœur du problème : WP-Cron n’est pas un vrai cron système, et sa fiabilité dépend de détails d’implémentation.

Le problème / Le besoin

Vous voulez exécuter automatiquement une action dans WordPress : envoyer un digest email hebdomadaire, nettoyer des transients, synchroniser une API, régénérer un cache, ou recalculer des métriques. Vous avez besoin d’un mécanisme planifié, répétable, et surtout déployable proprement (activation/désactivation, logs, garde-fous).

WP-Cron (WordPress 6.9.4, avril 2026) reste l’outil natif pour planifier des tâches. Le piège, c’est qu’il est facile d’écrire un snippet qui “marche chez vous” puis se met à rater des exécutions en production, ou à doubler les événements après quelques mises à jour.

À la fin, vous saurez :

  • créer une tâche planifiée récurrente fiable (avec intervalle personnalisé),
  • éviter les doublons et gérer la désactivation proprement,
  • sécuriser une exécution manuelle (admin) avec nonce et permissions,
  • instrumenter votre tâche (logs, durée, erreurs),
  • dépanner WP-Cron quand “ça ne se lance pas”.

Résumé rapide

  • On crée un mini-plugin (recommandé) qui enregistre un hook WP-Cron et un custom schedule.
  • On planifie l’événement à l’activation avec wp_schedule_event(), et on le nettoie à la désactivation avec wp_clear_scheduled_hook().
  • On exécute le traitement dans un callback robuste : verrou anti-concurrence (transient), timeout HTTP, gestion d’erreurs, logs.
  • On ajoute une page Outils pour déclencher manuellement la tâche (nonce + current_user_can()).
  • On teste avec WP-CLI (wp cron event list, wp cron event run) et on dépanne avec une check-list orientée prod.

Quand utiliser cette solution

  • Nettoyage périodique : supprimer des transients expirés “métier”, purger des tables custom, rotation de logs.
  • Synchronisation API à fréquence raisonnable (toutes les 10 minutes / heure) avec tolérance à la dérive.
  • Recalculs (stats, index, scores) qui ne doivent pas bloquer une requête front.
  • Envoi d’emails en batch (digest), tant que vous contrôlez le volume et la délivrabilité.
  • Sites sans accès serveur (mutualisé) où vous ne pouvez pas configurer un cron Linux facilement.

Dans mon expérience, WP-Cron est très correct pour des tâches “non critiques” et idempotentes. Dès que vous avez un SLA (ex : “doit partir à 08:00 pile”), il faut réfléchir autrement.

Quand ne PAS utiliser cette solution

  • Tâches critiques à heure fixe (facturation, relances, exports réglementaires). WP-Cron peut dériver si le site n’a pas de trafic.
  • Gros volumes (des milliers d’emails ou de lignes à traiter). Préférez une queue (Action Scheduler, Redis queue, etc.).
  • Traitements longs (> 20–30 secondes). Vous risquez timeouts PHP, chevauchement, et saturation.
  • Multi-serveurs sans coordination (autoscaling) : vous devez gérer le verrouillage distribué.

Alternatives fréquentes :

  • cron système (Linux) qui appelle wp-cron.php ou WP-CLI,
  • Action Scheduler (souvent embarqué par WooCommerce) pour des jobs en file,
  • services externes (GitHub Actions, Cloud Scheduler) qui pingent un endpoint sécurisé.

Prérequis / avant de commencer

  • WordPress 6.9.4 (ou plus récent), PHP 8.1+.
  • Un environnement de test/staging. Ne testez pas ce genre de code directement en production sans sauvegarde.
  • Accès au filesystem pour créer un plugin, ou à défaut un plugin de snippets (mais j’ai souvent vu des snippets se désactiver après une erreur PHP, laissant des événements orphelins).
  • Optionnel mais très utile : WP-CLI.

Ressources officielles à garder sous la main :

Sécurité : si vous ajoutez une exécution manuelle via une page admin, utilisez nonce + capability. Sans ça, vous offrez un bouton “DoS” à n’importe quel compte compromis.

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

Voici le code que je vois encore partout (souvent collé dans functions.php) :

<?php
// ❌ Exemple à éviter : planification sur init à chaque chargement.
add_action('init', function () {
    if (!wp_next_scheduled('mon_hook_cron')) {
        wp_schedule_event(time(), 'hourly', 'mon_hook_cron');
    }
});

add_action('mon_hook_cron', function () {
    // Traitement...
});

Pourquoi ça finit mal :

  • Mauvais emplacement : dans functions.php, un changement de thème peut casser la planification (ou laisser l’événement tourner sans code).
  • Risque de doublons : si l’événement change d’arguments, ou si un autre code planifie le même hook, vous pouvez vous retrouver avec plusieurs entrées.
  • Pas de désactivation : rien ne “nettoie” l’événement. En plugin, vous voulez un register_deactivation_hook().
  • Pas de garde-fou : pas de verrou anti-concurrence, pas de logs, pas de gestion d’erreurs.
  • Fiabilité : WP-Cron dépend du trafic. Sur un site qui dort la nuit, votre “hourly” peut tourner à 08:17.

La bonne approche — tutoriel pas à pas

Étape 1 — Créez un mini-plugin (recommandé)

Créez le fichier : wp-content/plugins/bpcab-wpcron-demo/bpcab-wpcron-demo.php.

Pourquoi un plugin : activation/désactivation propre, code isolé, maintenable. C’est aussi plus simple à migrer entre staging et prod.

Étape 2 — Déclarez un intervalle personnalisé

WP-Cron propose des intervalles par défaut (hourly, twicedaily, daily). Pour “toutes les 10 minutes”, vous passez par cron_schedules.

Étape 3 — Planifiez à l’activation (et nettoyez à la désactivation)

On planifie une seule fois à l’activation. On évite de le faire sur init, sauf cas très particulier (ex : migration).

Étape 4 — Écrivez un callback robuste

Objectif : si le job est relancé pendant qu’il tourne (trafic + latence), on évite le chevauchement. On loggue, on limite la durée, et on rend la tâche idempotente.

Étape 5 — Ajoutez une exécution manuelle en admin (optionnel mais très pratique)

Quand vous débuggez, vous voulez pouvoir déclencher le job sans attendre. On ajoute une page dans “Outils” avec un bouton protégé.

Étape 6 — Testez avec WP-CLI et vos logs

Vous vérifiez la présence de l’événement, vous forcez l’exécution, puis vous contrôlez le résultat (option, log, transient, etc.).

Code complet

Copiez-collez ce fichier tel quel dans le plugin. Il est compatible WordPress 6.9.4+ et PHP 8.1+.

<?php
/**
 * Plugin Name: BPCAB - WP-Cron Demo (WordPress 6.9+)
 * Description: Exemple complet et robuste de tâche planifiée WP-Cron (intervalle custom, verrou, logs, exécution manuelle admin).
 * Version: 1.0.0
 * Author: BPCAB
 * Requires at least: 6.9
 * Requires PHP: 8.1
 */

defined('ABSPATH') || exit;

final class BPCAB_WPCron_Demo {

    /**
     * Hook WP-Cron (identifiant de l'événement).
     * Évitez les noms génériques pour limiter les collisions.
     */
    private const CRON_HOOK = 'bpcab_wpcron_demo/run';

    /**
     * Nom de l'intervalle custom.
     */
    private const SCHEDULE_NAME = 'bpcab_every_10_minutes';

    /**
     * Verrou anti-concurrence.
     */
    private const LOCK_TRANSIENT = 'bpcab_wpcron_demo_lock';

    /**
     * Option de debug (dernier run).
     */
    private const OPTION_LAST_RUN = 'bpcab_wpcron_demo_last_run';

    public static function init(): void {
        // Intervalle custom.
        add_filter('cron_schedules', [__CLASS__, 'register_schedule']);

        // Callback du cron.
        add_action(self::CRON_HOOK, [__CLASS__, 'handle_cron'], 10, 0);

        // Page d'outils pour exécution manuelle.
        add_action('admin_menu', [__CLASS__, 'register_tools_page']);
        add_action('admin_post_bpcab_wpcron_demo_run_now', [__CLASS__, 'handle_run_now_action']);
    }

    /**
     * Ajoute un intervalle de 10 minutes.
     *
     * @param array $schedules
     * @return array
     */
    public static function register_schedule(array $schedules): array {
        if (!isset($schedules[self::SCHEDULE_NAME])) {
            $schedules[self::SCHEDULE_NAME] = [
                'interval' => 10 * MINUTE_IN_SECONDS,
                'display'  => __('Toutes les 10 minutes (BPCAB Demo)', 'bpcab'),
            ];
        }
        return $schedules;
    }

    /**
     * Planifie l'événement à l'activation.
     * On utilise wp_schedule_event() (récurrent) et on évite les doublons.
     */
    public static function activate(): void {
        // Assurez-vous que les schedules custom sont enregistrés avant de planifier.
        self::register_schedule(wp_get_schedules());

        if (!wp_next_scheduled(self::CRON_HOOK)) {
            // Démarre dans 2 minutes pour éviter d'exécuter immédiatement sur activation.
            $timestamp = time() + 2 * MINUTE_IN_SECONDS;

            $ok = wp_schedule_event($timestamp, self::SCHEDULE_NAME, self::CRON_HOOK);

            if ($ok === false) {
                // En prod, vous pouvez aussi logguer dans un fichier dédié.
                error_log('[BPCAB_WPCron_Demo] Échec de planification wp_schedule_event().');
            }
        }
    }

    /**
     * Nettoie l'événement à la désactivation.
     */
    public static function deactivate(): void {
        // Supprime toutes les occurrences de ce hook (même si quelqu'un a créé des doublons).
        wp_clear_scheduled_hook(self::CRON_HOOK);

        // Nettoyage optionnel.
        delete_transient(self::LOCK_TRANSIENT);
        // On conserve OPTION_LAST_RUN pour diagnostic, mais vous pouvez la supprimer si vous préférez.
        // delete_option(self::OPTION_LAST_RUN);
    }

    /**
     * Traitement exécuté par WP-Cron.
     * Ici : exemple de "sync" API + stockage d'un résultat simple.
     *
     * Bonnes pratiques :
     * - Verrou anti-concurrence
     * - Timeout HTTP
     * - Gestion WP_Error
     * - Logs minimaux
     */
    public static function handle_cron(): void {
        $start = microtime(true);

        // Verrou anti-concurrence (évite les exécutions simultanées).
        if (get_transient(self::LOCK_TRANSIENT)) {
            error_log('[BPCAB_WPCron_Demo] Job ignoré : verrou actif (exécution déjà en cours).');
            return;
        }

        // Verrou pendant 15 minutes (à ajuster selon la durée max acceptable).
        set_transient(self::LOCK_TRANSIENT, 1, 15 * MINUTE_IN_SECONDS);

        try {
            // Exemple : requête HTTP vers une API (remplacez par votre endpoint).
            // Ici on utilise l'API HTTP WordPress.
            $request_url = 'https://wordpress.org/news/wp-json/wp/v2/posts?per_page=1';

            $response = wp_remote_get($request_url, [
                'timeout'     => 12,
                'redirection' => 3,
                'user-agent'  => 'BPCAB_WPCron_Demo/1.0; ' . home_url('/'),
            ]);

            if (is_wp_error($response)) {
                $message = $response->get_error_message();
                error_log('[BPCAB_WPCron_Demo] Erreur HTTP : ' . $message);

                self::update_last_run([
                    'status'   => 'error',
                    'message'  => $message,
                    'ts'       => time(),
                    'duration' => (int) round((microtime(true) - $start) * 1000),
                ]);
                return;
            }

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

            if ($code < 200 || $code >= 300) {
                error_log('[BPCAB_WPCron_Demo] Réponse HTTP inattendue : ' . $code);

                self::update_last_run([
                    'status'   => 'error',
                    'message'  => 'HTTP ' . $code,
                    'ts'       => time(),
                    'duration' => (int) round((microtime(true) - $start) * 1000),
                ]);
                return;
            }

            $data = json_decode($body, true);

            if (!is_array($data)) {
                error_log('[BPCAB_WPCron_Demo] JSON invalide.');
                self::update_last_run([
                    'status'   => 'error',
                    'message'  => 'JSON invalide',
                    'ts'       => time(),
                    'duration' => (int) round((microtime(true) - $start) * 1000),
                ]);
                return;
            }

            // Exemple de résultat : titre du dernier post WordPress.org/news.
            $last_title = '';
            if (!empty($data[0]['title']['rendered'])) {
                // On stocke une version "texte" simple.
                $last_title = wp_strip_all_tags((string) $data[0]['title']['rendered']);
            }

            self::update_last_run([
                'status'   => 'ok',
                'message'  => $last_title ? 'Dernier titre: ' . $last_title : 'OK',
                'ts'       => time(),
                'duration' => (int) round((microtime(true) - $start) * 1000),
            ]);

        } finally {
            // Toujours libérer le verrou, même si une exception PHP survient.
            delete_transient(self::LOCK_TRANSIENT);
        }
    }

    /**
     * Stocke un diagnostic simple dans une option.
     * @param array $payload
     */
    private static function update_last_run(array $payload): void {
        // Sécurisation minimale : on force une structure attendue.
        $safe = [
            'status'   => isset($payload['status']) ? sanitize_key((string) $payload['status']) : 'unknown',
            'message'  => isset($payload['message']) ? sanitize_text_field((string) $payload['message']) : '',
            'ts'       => isset($payload['ts']) ? (int) $payload['ts'] : time(),
            'duration' => isset($payload['duration']) ? (int) $payload['duration'] : 0,
        ];

        update_option(self::OPTION_LAST_RUN, $safe, false);
    }

    /**
     * Ajoute une page Outils > WP-Cron Demo.
     */
    public static function register_tools_page(): void {
        add_management_page(
            __('WP-Cron Demo', 'bpcab'),
            __('WP-Cron Demo', 'bpcab'),
            'manage_options',
            'bpcab-wpcron-demo',
            [__CLASS__, 'render_tools_page']
        );
    }

    /**
     * Affiche la page admin.
     */
    public static function render_tools_page(): void {
        if (!current_user_can('manage_options')) {
            wp_die(__('Accès refusé.', 'bpcab'));
        }

        $last = get_option(self::OPTION_LAST_RUN, []);
        $next = wp_next_scheduled(self::CRON_HOOK);

        $run_now_url = wp_nonce_url(
            admin_url('admin-post.php?action=bpcab_wpcron_demo_run_now'),
            'bpcab_wpcron_demo_run_now'
        );

        echo '<div class="wrap">';
        echo '<h1>' . esc_html__('WP-Cron Demo', 'bpcab') . '</h1>';

        echo '<p>Hook cron : <code>' . esc_html(self::CRON_HOOK) . '</code></p>';

        echo '<p>Prochaine exécution : ';
        if ($next) {
            echo '<strong>' . esc_html(wp_date('Y-m-d H:i:s', $next)) . '</strong>';
        } else {
            echo '<strong>' . esc_html__('Aucune planification trouvée', 'bpcab') . '</strong>';
        }
        echo '</p>';

        echo '<hr />';

        echo '<h2>' . esc_html__('Dernière exécution (diagnostic)', 'bpcab') . '</h2>';
        if (is_array($last) && !empty($last)) {
            echo '<ul>';
            echo '<li><strong>Statut</strong> : ' . esc_html($last['status'] ?? '') . '</li>';
            echo '<li><strong>Message</strong> : ' . esc_html($last['message'] ?? '') . '</li>';
            echo '<li><strong>Date</strong> : ' . esc_html(isset($last['ts']) ? wp_date('Y-m-d H:i:s', (int) $last['ts']) : '') . '</li>';
            echo '<li><strong>Durée</strong> : ' . esc_html((string) ($last['duration'] ?? 0)) . ' ms</li>';
            echo '</ul>';
        } else {
            echo '<p>' . esc_html__('Pas encore de run enregistré.', 'bpcab') . '</p>';
        }

        echo '<hr />';
        echo '<p><a class="button button-primary" href="' . esc_url($run_now_url) . '">' . esc_html__('Exécuter maintenant', 'bpcab') . '</a></p>';

        echo '</div>';
    }

    /**
     * Handler admin-post pour exécuter la tâche manuellement.
     */
    public static function handle_run_now_action(): void {
        if (!current_user_can('manage_options')) {
            wp_die(__('Accès refusé.', 'bpcab'));
        }

        check_admin_referer('bpcab_wpcron_demo_run_now');

        // Exécution immédiate (même code que WP-Cron).
        self::handle_cron();

        wp_safe_redirect(admin_url('tools.php?page=bpcab-wpcron-demo'));
        exit;
    }
}

BPCAB_WPCron_Demo::init();

register_activation_hook(__FILE__, ['BPCAB_WPCron_Demo', 'activate']);
register_deactivation_hook(__FILE__, ['BPCAB_WPCron_Demo', 'deactivate']);

Explication du code

Pourquoi un hook dédié (CRON_HOOK) ?

WP-Cron identifie les événements par un hook (string) et optionnellement des arguments. En pratique, un hook trop générique (run_cron) finit par collisionner avec un autre plugin ou un ancien snippet. Le nom bpcab_wpcron_demo/run est volontairement “namespaced”.

Le filtre cron_schedules

Le filtre cron_schedules vous permet d’ajouter un intervalle. Sans ça, wp_schedule_event() refusera un schedule inconnu.

Erreur fréquente : planifier l’événement avant que le filtre ne soit enregistré. Dans ce plugin, on enregistre le filtre dès init(), et à l’activation on appelle aussi register_schedule() pour éviter un cas limite selon l’ordre de chargement.

Activation / désactivation

register_activation_hook() est le bon endroit pour planifier. Vous évitez ainsi le pattern “je planifie sur init”.

À la désactivation, wp_clear_scheduled_hook() supprime toutes les occurrences. C’est volontaire : j’ai souvent récupéré des sites avec 10 événements identiques parce qu’un snippet avait été collé deux fois, ou parce qu’un hook avait changé d’arguments.

Verrou anti-concurrence

WP-Cron peut lancer le même hook plusieurs fois si les requêtes se chevauchent (trafic + latence + cache). Le transient bpcab_wpcron_demo_lock joue le rôle de mutex simple.

  • Avantage : simple, natif, fonctionne sur la plupart des sites.
  • Limite : sur un cluster multi-serveurs sans object cache partagé, ce verrou n’est pas distribué.

HTTP API et timeouts

On utilise wp_remote_get() plutôt que file_get_contents(). Ça respecte les réglages WordPress, gère SSL, proxies, et renvoie un WP_Error exploitable. Référence : HTTP API.

Le timeout est fixé (12s). Sans timeout, j’ai déjà vu des cron bloqués par une API lente, puis se chevaucher et saturer PHP-FPM.

Stockage du diagnostic

On enregistre un petit état dans une option. Ce n’est pas un système de logs, mais ça vous donne une visibilité immédiate dans l’admin : dernier statut, message, durée.

Sanitization :

  • sanitize_key() pour le statut (valeurs type ok, error),
  • sanitize_text_field() pour le message,
  • casting en int pour timestamps/durée.

Exécution manuelle sécurisée

On utilise :

  • admin_post_... pour traiter une action admin,
  • current_user_can('manage_options') pour limiter aux admins,
  • check_admin_referer() pour valider le nonce.

Sans nonce, un attaquant pourrait forcer l’exécution via CSRF (un simple lien cliqué par un admin connecté).

Variantes et cas d'usage

Variante 1 — Événement unique (one-off) avec wp_schedule_single_event()

Utile pour “relancer dans 5 minutes” après un événement (ex : après publication). Référence : wp_schedule_single_event().

<?php
// Exemple : planifier une seule exécution dans 5 minutes.
$timestamp = time() + 5 * MINUTE_IN_SECONDS;
wp_schedule_single_event($timestamp, 'bpcab_wpcron_demo/run');

Piège courant : oublier que WP-Cron n’est pas garanti à la seconde près. “Dans 5 minutes” peut devenir “dans 12 minutes” si aucun trafic.

Variante 2 — Passer des arguments (et éviter les doublons)

WP-Cron distingue les événements par hook + arguments. Si vous planifiez le même hook avec des args différents, vous aurez plusieurs événements.

<?php
// Exemple : traiter un ID (attention aux doublons si vous ne contrôlez pas la planification).
wp_schedule_single_event(time() + 60, 'bpcab_wpcron_demo/process_post', [(int) $post_id]);

add_action('bpcab_wpcron_demo/process_post', function (int $post_id) {
    // Traitement...
}, 10, 1);

Conseil : si vous voulez “un seul job par post”, mettez en place une vérification (ex : meta “scheduled”) ou utilisez wp_next_scheduled() avec les mêmes args.

Variante 3 — Basculer vers un vrai cron serveur (recommandé en prod critique)

Sur un serveur où vous contrôlez crontab, vous pouvez désactiver WP-Cron interne et le déclencher toutes les 5 minutes via système.

  • Dans wp-config.php : define('DISABLE_WP_CRON', true);
  • Cron Linux : appeler wp-cron.php ou WP-CLI.

Doc officielle : Hooking WP-Cron into the System Task Scheduler.

# Exemple (cron Linux) : toutes les 5 minutes
*/5 * * * * curl -sS https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1

Attention : sécurisez l’accès (WAF, rate-limit) et surveillez les erreurs. Et ne collez pas ça tel quel sans adapter votre domaine.

Compatibilité Divi 5 / Elementor / Avada

WP-Cron est côté serveur. Divi 5, Elementor et Avada n’interfèrent pas directement avec la planification. Les problèmes arrivent plutôt quand votre tâche dépend de contenu “builder” et que vous parsez du HTML lourd.

Divi 5

  • Si votre tâche génère du contenu (ex : extrait, index), évitez de rendre des layouts Divi complets en cron. C’est coûteux.
  • Préférez travailler sur des données (post meta, champs) plutôt que de “render” le builder.

Elementor

  • Elementor stocke beaucoup de structure en meta. Si vous scannez des posts, faites-le par batch (pagination) et stockez un curseur.
  • Sur des sites Elementor, j’ai souvent vu des cron échouer à cause de timeouts quand le job tente de recalculer trop de pages en une fois.

Avada (Fusion Builder)

  • Même logique : évitez les rendus complets. Travaillez sur des champs, et limitez le volume par run.

Conseil transversal : si votre tâche doit “reconstruire” un cache de pages builder, faites-le en petits lots (10–20 posts par exécution), sinon WP-Cron devient votre goulot d’étranglement.

Vérifications après mise en place

  • Activez le plugin, puis allez dans Outils → WP-Cron Demo.
  • Vérifiez “Prochaine exécution”. Si “Aucune planification trouvée”, l’activation n’a pas planifié (souvent un conflit de schedule).
  • Cliquez “Exécuter maintenant” : la section “Dernière exécution” doit afficher un statut ok ou error avec un message.

Vérifier avec WP-CLI (fortement recommandé)

# Lister les événements cron
wp cron event list --fields=hook,next_run,recurrence

# Filtrer sur votre hook
wp cron event list --hook="bpcab_wpcron_demo/run"

# Forcer l'exécution immédiate
wp cron event run "bpcab_wpcron_demo/run"

Documentation WP-CLI (référence) : WP-CLI cron commands.

Tableau de diagnostic rapide

Symptôme Cause probable Vérification Solution
“Aucune planification trouvée” Activation n’a pas planifié / schedule inconnu WP-CLI wp cron event list --hook=... Désactiver/réactiver le plugin, vérifier cron_schedules
Le cron ne se déclenche jamais Site sans trafic / DISABLE_WP_CRON activé Regarder wp-config.php, logs serveur Mettre un cron système ou ping externe
Exécutions en double Chevauchement (latence) / pas de lock Logs avec timestamps proches Ajouter/verifier le verrou (transient) + réduire durée
Erreurs HTTP Timeout / DNS / blocage firewall Logs PHP, tester URL depuis serveur Augmenter timeout, corriger réseau, gérer retry
Page admin “Exécuter maintenant” renvoie 403 Permissions / nonce invalide Êtes-vous admin ? URL contient-elle _wpnonce ? Recharger la page, vérifier current_user_can et nonce

Si ça ne marche pas

Procédure que j’applique quand WP-Cron “fait le mort”. Faites les étapes dans l’ordre.

1) Confirmez que WP-Cron est activé

  • Vérifiez dans wp-config.php la présence de DISABLE_WP_CRON.
  • Si DISABLE_WP_CRON est à true et que vous n’avez pas de cron système, rien ne tournera.

2) Vérifiez que l’événement est bien planifié

Avec WP-CLI :

wp cron event list --hook="bpcab_wpcron_demo/run"

Sans WP-CLI : installez temporairement un plugin de visualisation (ex : WP Crontrol). Je l’utilise souvent en audit, puis je le retire.

3) Cherchez les erreurs PHP

Activez un logging propre sur staging, ou consultez les logs serveur. Sur WordPress 6.9.4, une erreur fatale dans un plugin peut empêcher le chargement complet et donc l’exécution du cron.

Erreur réaliste : une parenthèse manquante dans un snippet ajouté via un plugin de code. Résultat : le plugin se désactive, mais l’événement cron reste planifié.

4) Vérifiez la capacité à faire des requêtes loopback

WP-Cron repose souvent sur des requêtes HTTP internes (loopback). Si votre hébergeur bloque les loopbacks, WP-Cron devient erratique.

Indice : dans “Santé du site”, vous voyez des avertissements sur les requêtes loopback. (Les libellés exacts varient selon versions.)

5) Désactivez temporairement cache agressif / sécurité

  • Certains caches page + règles WAF peuvent bloquer wp-cron.php.
  • Videz le cache (plugin + serveur + CDN) après changement. J’ai souvent vu un faux “ça ne marche pas” juste parce que l’admin affichait un état mis en cache.

Pièges et erreurs courantes

Erreur Cause Solution
Coller le code dans functions.php du thème Changement de thème = cron cassé, événements orphelins Mettre le code dans un plugin (comme ci-dessus)
Planifier sur init “par habitude” Risque de doublons, difficile à maintenir Planifier via register_activation_hook
Schedule custom non reconnu cron_schedules non chargé au moment de planifier Enregistrer le filtre tôt + replanifier à l’activation
Le job tourne 2 fois Chevauchement de requêtes, pas de verrou Transient lock + réduire durée + batch
Fatal error: Call to undefined function wp_remote_get() Code exécuté hors contexte WP (fichier appelé directement) Ne jamais appeler le fichier directement, garder defined('ABSPATH')
“Exécuter maintenant” redirige sans effet Nonce invalide / pas admin / action mal nommée Vérifier admin_post_..., nonce, capability
Le cron ne tourne qu’à certaines heures Trafic variable, cache, WAF Mettre un cron système, monitorer, réduire dépendance au trafic
Tester sur production sans sauvegarde Un bug cron peut spammer des emails ou surcharger le serveur Staging + logs + garde-fous + rollout progressif
Code d’un vieux tutoriel incompatible Snippets obsolètes, mauvaises pratiques (pas de lock, pas de cleanup) Adapter aux pratiques WP 6.9+ (activation hooks, verrou, WP-CLI)

Conseils sécurité, performance et maintenance

Sécurité

  • Ne déclenchez pas un cron via un endpoint public non protégé. Si vous ajoutez un endpoint, signez-le (token) et rate-limitez.
  • Pour l’exécution manuelle admin : nonce + capability, comme dans le plugin.
  • Évitez de logguer des données sensibles (tokens API, emails) dans error_log.

Performance

  • Gardez les runs courts. Si vous avez 10 000 éléments à traiter, faites 100 par run et replanifiez un single event (ou utilisez une queue).
  • Ajoutez des timeouts à toute requête réseau. Sans timeout, vous créez des verrous qui ne se libèrent pas “à temps”.
  • Sur sites avec object cache persistant (Redis/Memcached), les transients de verrou sont plus fiables et rapides.

Maintenance

  • Versionnez votre plugin (Git). Les bugs cron sont souvent régressifs (“ça marchait avant”).
  • Ajoutez un identifiant de version dans le user-agent HTTP pour tracer.
  • Surveillez : dernier run, durée, taux d’erreur. Une simple option + un écran admin suffit déjà.

Note sur la “précision” des horaires

WP-Cron n’exécute pas “à 10:00:00”, il exécute “au prochain hit après 10:00:00”. Si votre besoin métier exige la précision, basculez vers cron système. Ce point ne change pas en WordPress 6.9.4.

Ressources

FAQ

WP-Cron est-il un “vrai cron” ?

Non. WP-Cron est déclenché par des requêtes web (trafic) et/ou des loopbacks. Pour un vrai cron, utilisez le planificateur système (crontab) ou un scheduler externe.

Pourquoi mon événement “hourly” ne tourne pas toutes les heures pile ?

Parce que l’exécution dépend du prochain hit après l’heure prévue. Sur un site sans trafic, ça dérive. La seule solution fiable est un cron serveur qui déclenche régulièrement wp-cron.php ou WP-CLI.

Dois-je mettre le code dans un plugin plutôt que dans functions.php ?

Oui, presque toujours. Un thème n’est pas un endroit stable pour de la logique planifiée. En cas de changement de thème, vous perdez le callback mais l’événement peut rester en base.

Comment éviter que mon job s’exécute en double ?

Ajoutez un verrou (transient) comme dans l’exemple, et rendez votre traitement idempotent (si relancé, il ne doit pas casser l’état ni dupliquer).

Est-ce que wp_clear_scheduled_hook() supprime tout ?

Il supprime toutes les occurrences d’un hook (et, selon cas, celles avec arguments si vous les gérez explicitement). C’est ce que vous voulez à la désactivation pour éviter les événements orphelins.

Peut-on planifier toutes les minutes ?

Techniquement oui via cron_schedules, mais c’est rarement une bonne idée sur un site WordPress classique. Vous augmentez la charge et le risque de chevauchement. Si vous avez besoin d’une minute, passez plutôt par cron système + job optimisé.

Comment savoir si WP-Cron est bloqué par mon hébergeur ?

Regardez les erreurs de loopback dans “Santé du site”, testez l’accès à /wp-cron.php, et utilisez WP-CLI pour exécuter un événement manuellement. Les WAF/CDN peuvent aussi bloquer ou retarder.

Mon cron fait des requêtes HTTP et échoue aléatoirement, que faire ?

Ajoutez des timeouts, gérez WP_Error, et implémentez un retry raisonnable (ex : replanifier un single event dans 5 minutes). Évitez les boucles infinies.

Peut-on déclencher WP-Cron via un service externe (UptimeRobot, etc.) ?

Oui, mais ne laissez pas un endpoint ouvert sans protection. À minima, utilisez un cron système ou un endpoint signé + rate-limit. Sinon, n’importe qui peut surcharger votre serveur.

Pourquoi je vois des événements cron “fantômes” après avoir supprimé un plugin ?

Parce que le plugin n’a pas nettoyé ses hooks à la désactivation, ou parce qu’il a été supprimé sans passer par la désactivation. Nettoyez avec WP-CLI ou un outil type WP Crontrol, puis corrigez le plugin.

Quel est le meilleur outil pour inspecter WP-Cron ?

WP-CLI en premier (scriptable, fiable). En second, WP Crontrol pour une inspection visuelle rapide sur staging.