Si vous avez déjà vu un appel réseau partir vers /wp-admin/admin-ajax.php et revenir en 400 ou 0, vous avez touché du doigt le vrai sujet d’Ajax dans WordPress : ce n’est pas “faire du JavaScript”, c’est surtout câbler proprement le serveur, les permissions, la sécurité et le chargement des scripts.

WordPress 6.9.4 (avril 2026) propose plusieurs façons d’échanger des données (REST API, admin-post, Heartbeat…), mais les hooks wp_ajax_* restent une solution robuste pour des interactions ponctuelles, notamment côté admin, ou quand vous voulez profiter de admin-ajax.php sans exposer un endpoint REST public.

Le problème / Le besoin

Vous voulez déclencher une action côté serveur sans recharger la page : valider un formulaire, calculer un prix, charger des posts filtrés, enregistrer une préférence utilisateur, tester une connexion API, etc. En WordPress, beaucoup de sites finissent par “bricoler” une page PHP accessible publiquement ou par surcharger functions.php avec du code non sécurisé.

Le besoin réel : mettre en place un flux Ajax fiable, sécurisé (nonce, capacités), compatible avec WordPress 6.9.4+ et PHP 8.1+, et facile à déboguer.

À la fin, vous saurez :

  • déclarer des handlers Ajax avec wp_ajax_{action} et wp_ajax_nopriv_{action}
  • charger un script correctement avec wp_enqueue_script() + wp_localize_script() (ou wp_add_inline_script())
  • sécuriser les requêtes (nonce, capacités, sanitization) et renvoyer du JSON propre via wp_send_json_success()
  • diagnostiquer les erreurs classiques (0, 400, “action not found”, cache, mauvais hook…)

Résumé rapide

  • On crée un mini-plugin (recommandé) qui expose une action Ajax bpcab_demo_search (recherche de posts) via admin-ajax.php.
  • On enregistre deux hooks : wp_ajax_bpcab_demo_search (utilisateurs connectés) et wp_ajax_nopriv_bpcab_demo_search (visiteurs).
  • On charge un script JS sur le front, on lui passe ajax_url et un nonce.
  • Le JS envoie une requête POST (Fetch) et reçoit un JSON.
  • On ajoute des garde-fous : check_ajax_referer(), validation des paramètres, limites, et on gère les erreurs côté client.

Quand utiliser cette solution

  • Actions simples et ponctuelles : liker un contenu, ajouter aux favoris, charger une liste filtrée, test de connectivité, calcul côté serveur.
  • Back-office : beaucoup de plugins s’appuient encore sur admin-ajax.php pour des écrans d’admin, et ça reste parfaitement acceptable.
  • Compatibilité large : vous intervenez sur un site existant (Divi, Avada, Elementor) où l’écosystème utilise déjà admin-ajax. Dans mon expérience, c’est fréquent sur des sites qui ont accumulé des extensions “marketing”.
  • Vous ne voulez pas exposer un endpoint REST public (même si la REST API peut être sécurisée, certains clients préfèrent limiter la surface).

Quand ne PAS utiliser cette solution

  • API publique ou intégration headless : privilégiez la REST API (endpoints /wp-json/), mieux outillée, mieux cacheable, plus standard. Référence : REST API Handbook.
  • Upload de fichiers lourds ou traitements longs : Ajax peut marcher, mais vous allez vite rencontrer des timeouts. Mieux : tâches asynchrones (Action Scheduler, cron, queue) ou endpoints dédiés.
  • Besoin de cache HTTP/CDN sur la réponse : admin-ajax.php est souvent exclu des caches, ce qui peut coûter cher en perf. Un endpoint REST GET cacheable est souvent plus efficace.
  • Formulaires “classiques” sans besoin d’interactivité : admin-post.php peut suffire (POST + redirection), plus simple et plus accessible.

Prérequis / avant de commencer

Avant de toucher au code, faites simple et propre :

  • WordPress 6.9.4 (ou plus récent) et PHP 8.1+.
  • Un environnement de test (staging/local). J’ai souvent vu des sites se casser parce qu’un snippet Ajax a été collé en prod sans filet.
  • Une sauvegarde (fichiers + base).
  • Accès aux outils de debug :
    • DevTools navigateur (onglet Network)
    • Logs PHP / WordPress (WP_DEBUG_LOG)

Pour le debug WordPress, référez-vous à la doc officielle : Debugging in WordPress.

Précaution sécurité : ne traitez jamais une requête Ajax comme “fiable”. Même si l’appel vient de votre JS, il peut être reproduit. Nonce + capacités + validation stricte des paramètres, sinon vous ouvrez une porte.

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

Voici le genre de code que je vois encore (souvent dans functions.php) : une action Ajax qui exécute une requête SQL ou renvoie des données sans nonce, sans capacités, et avec un echo brut.

<?php
// ❌ Exemple à NE PAS copier.
add_action('wp_ajax_my_action', function () {
    // Pas de nonce, pas de permissions, pas de validation
    $q = $_POST['q']; // Non sanitizé
    global $wpdb;
    $results = $wpdb->get_results("SELECT ID, post_title FROM {$wpdb->posts} WHERE post_title LIKE '%$q%'");
    echo json_encode($results); // Pas de headers, pas de wp_die, pas d'échappement
});

Ce qui se passe en coulisses :

  • Injection SQL potentielle (concaténation directe).
  • Exposition de données (aucune vérification de droits).
  • Réponses “bizarres” (headers, encodage, sorties parasites) qui finissent en 0 côté navigateur.
  • Maintenance infernale : pas de structure, pas de nommage, pas de logs.

WordPress fournit des helpers précisément pour éviter ça : check_ajax_referer(), wp_send_json_success(), sanitize_text_field(), etc. Référence : check_ajax_referer() et wp_send_json_success().

La bonne approche — tutoriel pas à pas

On va construire un exemple réaliste : un champ de recherche qui renvoie 5 articles publiés correspondant à une requête. C’est volontairement “petit”, mais la structure est celle que je garde en production.

Étape 1 — Créer un mini-plugin (plutôt que functions.php)

Vous pouvez coller ça dans un plugin de snippets, mais le plus fiable reste un vrai plugin. Créez le fichier :

wp-content/plugins/bpcab-ajax-demo/bpcab-ajax-demo.php

Étape 2 — Enregistrer les handlers wp_ajax_ et wp_ajax_nopriv_

Le nom de l’action Ajax côté JS sera bpcab_demo_search. WordPress déclenchera :

  • wp_ajax_bpcab_demo_search si l’utilisateur est connecté
  • wp_ajax_nopriv_bpcab_demo_search si l’utilisateur ne l’est pas

C’est un point que beaucoup ratent : ils testent déconnectés et oublient nopriv, résultat : réponse 0 ou “action not found”.

Étape 3 — Charger le JS au bon endroit et passer ajax_url + nonce

On va charger un script front via wp_enqueue_scripts. On lui passe :

  • ajaxUrl = admin_url('admin-ajax.php')
  • nonce = wp_create_nonce('bpcab_demo_search')

Pour transmettre des variables à un script, wp_localize_script() reste une solution simple et stable. Référence : wp_localize_script().

Étape 4 — Ajouter un shortcode de démo

Pour tester sans dépendre d’un thème/page builder, on ajoute un shortcode [bpcab_ajax_search] qui affiche un champ, un bouton, et une zone de résultats.

Étape 5 — Côté serveur : valider, requêter, renvoyer du JSON

Points clés :

  • Nonce avec check_ajax_referer()
  • Sanitization des entrées (texte, entiers)
  • Limites (max 5 résultats, longueur de requête)
  • Réponse JSON avec wp_send_json_success() / wp_send_json_error()

Étape 6 — Côté JS : Fetch POST, gestion d’erreurs, rendu minimal

Je vois souvent des scripts qui supposent que “si HTTP 200 alors tout va bien”. Non. On gère :

  • HTTP non-200
  • JSON invalide (souvent à cause d’un echo parasite)
  • success: false renvoyé par WordPress

Code complet

Copiez-collez l’ensemble. Activez le plugin, puis insérez le shortcode [bpcab_ajax_search] dans une page.

1) Plugin PHP (serveur + shortcode + enqueue)

<?php
/**
 * Plugin Name: BPCAB Ajax Demo (wp_ajax_)
 * Description: Démo pédagogique d'appels AJAX via admin-ajax.php (wp_ajax_ / wp_ajax_nopriv_) pour WordPress 6.9.4+.
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 * Author: Votre Nom
 */

declare(strict_types=1);

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

final class BPCAB_Ajax_Demo {
    private const NONCE_ACTION = 'bpcab_demo_search';
    private const AJAX_ACTION  = 'bpcab_demo_search';
    private const SCRIPT_HANDLE = 'bpcab-ajax-demo';

    public static function init(): void {
        // Shortcode de démo
        add_shortcode('bpcab_ajax_search', [__CLASS__, 'render_shortcode']);

        // Chargement JS/CSS
        add_action('wp_enqueue_scripts', [__CLASS__, 'enqueue_assets']);

        // Handlers AJAX (connectés + visiteurs)
        add_action('wp_ajax_' . self::AJAX_ACTION, [__CLASS__, 'handle_ajax_search']);
        add_action('wp_ajax_nopriv_' . self::AJAX_ACTION, [__CLASS__, 'handle_ajax_search']);
    }

    public static function enqueue_assets(): void {
        // On charge seulement si le shortcode est présent (optimisation simple).
        // Anti-pattern courant : enqueuer partout "par flemme", et payer en perf.
        if (!is_singular()) {
            return;
        }

        global $post;
        if (!$post instanceof WP_Post) {
            return;
        }

        if (!has_shortcode($post->post_content, 'bpcab_ajax_search')) {
            return;
        }

        $plugin_url = plugin_dir_url(__FILE__);

        wp_enqueue_script(
            self::SCRIPT_HANDLE,
            $plugin_url . 'assets/bpcab-ajax-demo.js',
            [],
            '1.0.0',
            true
        );

        // Données accessibles dans window.BPCAB_AJAX_DEMO
        wp_localize_script(self::SCRIPT_HANDLE, 'BPCAB_AJAX_DEMO', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'action'  => self::AJAX_ACTION,
            'nonce'   => wp_create_nonce(self::NONCE_ACTION),
            'max'     => 5,
        ]);

        wp_enqueue_style(
            'bpcab-ajax-demo',
            $plugin_url . 'assets/bpcab-ajax-demo.css',
            [],
            '1.0.0'
        );
    }

    public static function render_shortcode(array $atts = []): string {
        // HTML minimal (pas de framework). Les builders peuvent styler autour.
        ob_start();
        ?>
        <div class="bpcab-ajax-demo" data-bpcab-ajax-demo>
            <label class="bpcab-ajax-demo__label">
                <span>Recherche d’articles</span><br>
                <input class="bpcab-ajax-demo__input" type="text" name="q" value="" placeholder="Tapez au moins 2 caractères">
            </label>
            <button class="bpcab-ajax-demo__button" type="button">Rechercher</button>

            <div class="bpcab-ajax-demo__status" aria-live="polite"></div>
            <div class="bpcab-ajax-demo__results"></div>
        </div>
        <?php
        return (string) ob_get_clean();
    }

    public static function handle_ajax_search(): void {
        // 1) Vérification nonce (CSRF)
        // Le paramètre POST attendu par défaut est "nonce" si on passe ce nom en 2e argument.
        // Si nonce invalide, WordPress renvoie -1 (souvent vu comme 400/403 selon contexte).
        check_ajax_referer(self::NONCE_ACTION, 'nonce');

        // 2) (Optionnel) Permissions
        // Ici on autorise les visiteurs, mais vous pouvez restreindre.
        // Exemple : réserver à des éditeurs
        // if (!current_user_can('edit_posts')) {
        //     wp_send_json_error(['message' => 'Permission refusée.'], 403);
        // }

        // 3) Récupération + sanitization
        $q = isset($_POST['q']) ? sanitize_text_field((string) wp_unslash($_POST['q'])) : '';
        $limit = isset($_POST['limit']) ? (int) $_POST['limit'] : 5;

        // 4) Validation métier
        $q = trim($q);
        if (mb_strlen($q) < 2) {
            wp_send_json_error([
                'message' => 'Veuillez saisir au moins 2 caractères.',
            ], 400);
        }

        // On borne la limite pour éviter les abus (anti-scraping / perf).
        $limit = max(1, min($limit, 10));

        // 5) Requête WP_Query (pas de SQL manuel)
        $query = new WP_Query([
            'post_type'              => 'post',
            'post_status'            => 'publish',
            's'                      => $q,
            'posts_per_page'         => $limit,
            'no_found_rows'          => true,  // perf : pas besoin de pagination
            'ignore_sticky_posts'    => true,
            'update_post_meta_cache' => false, // perf : pas utile ici
            'update_post_term_cache' => false, // perf : pas utile ici
        ]);

        $items = [];

        if ($query->have_posts()) {
            foreach ($query->posts as $post) {
                if (!$post instanceof WP_Post) {
                    continue;
                }

                $items[] = [
                    'id'    => (int) $post->ID,
                    'title' => html_entity_decode(get_the_title($post), ENT_QUOTES, 'UTF-8'),
                    'url'   => get_permalink($post),
                ];
            }
        }

        // 6) Réponse JSON normalisée
        wp_send_json_success([
            'query'   => $q,
            'count'   => count($items),
            'results' => $items,
        ]);
    }
}

BPCAB_Ajax_Demo::init();

2) JavaScript (Fetch + rendu)

Créez le fichier : wp-content/plugins/bpcab-ajax-demo/assets/bpcab-ajax-demo.js

/* global BPCAB_AJAX_DEMO */
(() => {
  'use strict';

  function qs(root, sel) {
    const el = root.querySelector(sel);
    if (!el) throw new Error(`Sélecteur introuvable: ${sel}`);
    return el;
  }

  async function postAjax(payload) {
    const body = new URLSearchParams(payload);

    const res = await fetch(BPCAB_AJAX_DEMO.ajaxUrl, {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
      },
      body: body.toString(),
    });

    // admin-ajax.php renvoie parfois 200 avec "0" si action non trouvée
    const text = await res.text();

    let json;
    try {
      json = JSON.parse(text);
    } catch (e) {
      // Très souvent : un warning PHP, un espace avant <?php, un plugin qui echo.
      throw new Error(`Réponse non-JSON: ${text.slice(0, 200)}`);
    }

    if (!res.ok) {
      const msg = (json && json.data && json.data.message) ? json.data.message : `HTTP ${res.status}`;
      throw new Error(msg);
    }

    if (!json || json.success !== true) {
      const msg = (json && json.data && json.data.message) ? json.data.message : 'Erreur Ajax inconnue.';
      throw new Error(msg);
    }

    return json.data;
  }

  function renderResults(container, items) {
    if (!items.length) {
      container.innerHTML = '<p>Aucun résultat.</p>';
      return;
    }

    const html = items
      .map((item) => {
        const title = escapeHtml(item.title);
        const url = escapeAttr(item.url);
        return `<p><a href="${url}">${title}</a></p>`;
      })
      .join('');

    container.innerHTML = html;
  }

  // Échappements simples côté client (évite d'injecter du HTML si titre bizarre)
  function escapeHtml(str) {
    return String(str)
      .replaceAll('&', '&amp;')
      .replaceAll('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;')
      .replaceAll("'", '&#039;');
  }

  function escapeAttr(str) {
    return escapeHtml(str);
  }

  function initWidget(root) {
    const input = qs(root, '.bpcab-ajax-demo__input');
    const button = qs(root, '.bpcab-ajax-demo__button');
    const status = qs(root, '.bpcab-ajax-demo__status');
    const results = qs(root, '.bpcab-ajax-demo__results');

    let busy = false;

    async function run() {
      const q = input.value.trim();
      if (q.length < 2) {
        status.textContent = 'Tapez au moins 2 caractères.';
        results.innerHTML = '';
        return;
      }

      if (busy) return;
      busy = true;

      status.textContent = 'Recherche en cours…';
      results.innerHTML = '';

      try {
        const data = await postAjax({
          action: BPCAB_AJAX_DEMO.action,
          nonce: BPCAB_AJAX_DEMO.nonce,
          q,
          limit: String(BPCAB_AJAX_DEMO.max || 5),
        });

        status.textContent = `${data.count} résultat(s).`;
        renderResults(results, data.results || []);
      } catch (err) {
        status.textContent = `Erreur: ${err.message}`;
        results.innerHTML = '';
      } finally {
        busy = false;
      }
    }

    button.addEventListener('click', run);

    // UX: Enter déclenche la recherche
    input.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        run();
      }
    });
  }

  document.addEventListener('DOMContentLoaded', () => {
    document.querySelectorAll('[data-bpcab-ajax-demo]').forEach((root) => {
      try {
        initWidget(root);
      } catch (e) {
        // Si le markup est modifié par un builder, on veut un message clair.
        // eslint-disable-next-line no-console
        console.error('[BPCAB Ajax Demo] Init error:', e);
      }
    });
  });
})();

3) CSS (optionnel, juste pour rendre lisible)

Créez le fichier : wp-content/plugins/bpcab-ajax-demo/assets/bpcab-ajax-demo.css

.bpcab-ajax-demo {
  border: 1px solid #e5e5e5;
  padding: 12px;
  border-radius: 6px;
  max-width: 520px;
}

.bpcab-ajax-demo__label span {
  font-weight: 600;
}

.bpcab-ajax-demo__input {
  width: 100%;
  padding: 10px;
  margin-top: 6px;
  box-sizing: border-box;
}

.bpcab-ajax-demo__button {
  margin-top: 10px;
  padding: 10px 14px;
  cursor: pointer;
}

.bpcab-ajax-demo__status {
  margin-top: 10px;
  color: #444;
  font-size: 14px;
}

.bpcab-ajax-demo__results {
  margin-top: 10px;
}

Explication du code

Pourquoi un plugin et pas functions.php

Le problème vient rarement d’Ajax lui-même. Il vient du cycle de vie du thème : changement de thème, thème parent mis à jour, snippet cassé dans un thème enfant, etc. Un plugin isole votre logique métier, se versionne, et se désactive proprement.

Les hooks wp_ajax_ / wp_ajax_nopriv_

Quand vous appelez admin-ajax.php avec le paramètre action=bpcab_demo_search, WordPress cherche un hook nommé :

  • wp_ajax_bpcab_demo_search si l’utilisateur est connecté
  • wp_ajax_nopriv_bpcab_demo_search sinon

Si aucun hook n’est enregistré, WordPress renvoie souvent 0. C’est le symptôme le plus trompeur, parce que vous n’avez pas d’erreur explicite.

Référence officielle : AJAX in Plugins.

Nonce: check_ajax_referer()

check_ajax_referer() protège contre le CSRF. Concrètement, sans nonce, un site tiers peut forcer un navigateur connecté à appeler votre action Ajax (par exemple “supprimer un contenu”, “changer un email”…). Ici, on utilise :

  • wp_create_nonce('bpcab_demo_search') côté PHP
  • check_ajax_referer('bpcab_demo_search', 'nonce') côté handler

Oui, un nonce WordPress n’est pas un jeton cryptographiquement “parfait” au sens strict, mais c’est le mécanisme standard dans WordPress pour ce cas. Référence : wp_create_nonce().

Sanitization, validation, puis query

On fait trois étapes distinctes :

  • Sanitization : rendre l’entrée “propre” (sanitize_text_field, cast int, wp_unslash).
  • Validation : règles métier (min 2 caractères, limite max 10).
  • Traitement : WP_Query avec options de perf (no_found_rows, caches désactivés si inutiles).

Ça évite l’anti-pattern “je sanitize dans la requête” ou “je valide après avoir tapé la DB”.

Réponses JSON: wp_send_json_success / wp_send_json_error

Ces fonctions :

  • envoient les bons headers JSON
  • encodent proprement
  • terminent l’exécution (pas besoin de wp_die() derrière)

Référence : wp_send_json_error().

Pourquoi Fetch en x-www-form-urlencoded

admin-ajax.php fonctionne très bien avec application/x-www-form-urlencoded (héritage jQuery), et ça évite des surprises de parsing. Vous pouvez aussi envoyer du JSON, mais vous devrez parser php://input côté PHP (ce que WordPress ne fait pas automatiquement pour admin-ajax).

Variantes et cas d’usage

Variante 1 — Réserver l’action aux membres (capabilities)

Si votre action modifie des données (préférences, meta, commandes…), ajoutez une vérification de capacité. Typiquement :

<?php
if (!is_user_logged_in()) {
    wp_send_json_error(['message' => 'Connexion requise.'], 401);
}

if (!current_user_can('read')) {
    wp_send_json_error(['message' => 'Permission refusée.'], 403);
}

J’ai souvent vu des actions “nopriv” oubliées sur un endpoint qui écrit en base. Ça finit en spam de DB ou en escalade de privilèges si l’action est mal pensée.

Variante 2 — Retourner du HTML (quand vous devez réutiliser un template)

Parfois, vous voulez renvoyer un fragment HTML déjà échappé via un template PHP (ex : une carte produit). C’est valide, mais vous devez contrôler les sorties (pas de HTML brut non échappé issu d’entrées utilisateur).

<?php
ob_start();
?>
<div class="card">
  <strong><?php echo esc_html(get_the_title($post)); ?></strong>
</div>
<?php
$html = (string) ob_get_clean();

wp_send_json_success([
    'html' => $html,
]);

Alternative souvent meilleure : renvoyer des données et laisser le front rendre (plus flexible, moins couplé).

Variante 3 — Mise en cache applicative (transients) pour éviter de requêter à chaque frappe

Si votre Ajax sert à alimenter un filtre très utilisé, admin-ajax.php peut devenir un point chaud. Une solution simple : cache par requête via transient (TTL court).

<?php
$cache_key = 'bpcab_search_' . md5($q . '|' . $limit);
$cached = get_transient($cache_key);

if (is_array($cached)) {
    wp_send_json_success($cached);
}

// ... construire $payload ...

set_transient($cache_key, $payload, 60); // 60 secondes
wp_send_json_success($payload);

Attention : ne mettez pas en cache des données personnalisées par utilisateur sans intégrer l’ID utilisateur dans la clé.

Compatibilité Divi 5 / Elementor / Avada

Le code ci-dessus ne dépend d’aucun builder. C’est volontaire. Dans la vraie vie, le point de friction vient plutôt de l’injection du shortcode, du cache, ou d’un JS non chargé.

Divi 5

  • Vous pouvez insérer [bpcab_ajax_search] dans un module “Code” ou “Texte”.
  • Si Divi minifie/concatène, testez d’abord sans optimisation. J’ai déjà vu des scripts chargés deux fois à cause d’une option de performance, ce qui déclenche deux listeners.
  • Si vous transformez ça en module custom Divi, gardez le même principe : enqueue script via WordPress, pas via un <script> inline dans le builder.

Elementor

  • Utilisez un widget “Shortcode” pour insérer [bpcab_ajax_search].
  • Si vous utilisez l’optimisation “Improved Asset Loading”, vérifiez que le script est bien enqueued sur la page (onglet Network).
  • Si Elementor charge la page via un preview/iframe, assurez-vous que vous testez sur l’URL publique finale (pas uniquement dans l’éditeur).

Avada (Fusion Builder)

  • Le shortcode fonctionne dans un élément “Code Block” ou “Text”.
  • Avada a souvent un cache/compilation CSS/JS. Après ajout du plugin, purge Avada Cache + cache navigateur.
  • Sur certains sites, admin-ajax.php est bloqué par une règle sécurité (WAF). Vérifiez dans “Si ça ne marche pas”.

Vérifications après mise en place

Pour savoir si tout fonctionne, je fais toujours les mêmes contrôles (2 minutes, et vous évitez 80% des faux diagnostics).

Checklist rapide

  • Le plugin est activé, et le shortcode est bien présent sur la page.
  • Dans DevTools > Network, vous voyez un appel vers admin-ajax.php.
  • La requête contient action=bpcab_demo_search et un nonce.
  • La réponse est du JSON avec {"success":true,"data":...}.
  • En cas d’erreur, vous voyez un message clair dans la zone “status”.

Tableau de diagnostic (symptômes fréquents)

Symptôme Cause probable Vérification Solution
Réponse = 0 Hook Ajax non enregistré (action mal nommée, plugin non chargé) Network: payload contient action=... ? Hooks présents ? Corriger le nom wp_ajax_/wp_ajax_nopriv_, activer le plugin, vérifier le fichier
HTTP 400/403 et message nonce Nonce invalide/expiré Inspecter nonce envoyé, tester en navigation privée Régénérer le nonce (recharger la page), vérifier check_ajax_referer() et le nom du champ
Réponse non-JSON Warning PHP, espace avant <?php, plugin qui “echo” Network: regarder le texte brut de réponse Activer logs, corriger warnings, supprimer sorties parasites
Le JS ne se charge pas Mauvais enqueue, cache/minification, shortcode absent Network: fichier bpcab-ajax-demo.js présent ? Vérifier has_shortcode(), purger caches, vérifier le chemin du fichier
Ça marche connecté, pas déconnecté Oubli de wp_ajax_nopriv_ Tester en navigation privée Ajouter le hook wp_ajax_nopriv_...

Si ça ne marche pas

Procédure de dépannage que j’applique sur site client, dans cet ordre.

1) Vérifier l’appel réseau

  • DevTools > Network > filtrez “ajax” ou “admin-ajax”.
  • Confirmez que l’URL est bien /wp-admin/admin-ajax.php.
  • Confirmez que le payload contient action et nonce.

2) Si vous obtenez “0”

  • Le nom de l’action est-il exactement le même côté JS et côté hooks ? (typo ultra fréquente)
  • Le plugin est-il actif ?
  • Vous testez connecté ou non ? Si non, il faut wp_ajax_nopriv_*.

3) Si vous obtenez du HTML au lieu du JSON

  • Un plugin de sécurité/WAF peut injecter une page de challenge.
  • Une notice PHP peut polluer la sortie.
  • Un cache peut servir une page au lieu de la réponse Ajax (rare, mais déjà vu avec des règles agressives).

Activez le log : Debugging in WordPress. Ensuite, inspectez wp-content/debug.log.

4) Vérifier les conflits de cache/minification

  • Purger cache plugin (WP Rocket / LiteSpeed / Autoptimize), cache serveur, CDN, cache navigateur.
  • Désactiver temporairement la minification JS.
  • Tester sur une page simple sans builder pour isoler.

5) Vérifier la couche serveur

  • ModSecurity / WAF qui bloque admin-ajax.php ou certains paramètres.
  • Règles Nginx/Apache qui bloquent /wp-admin/ pour les visiteurs (ça casse Ajax nopriv).

Pièges et erreurs courantes

Erreur Cause Solution
Copier le code dans le mauvais fichier Ajout dans un fichier non chargé, ou snippet cassé par le thème Mettre dans un plugin dédié, vérifier activation, éviter functions.php en prod
Oublier wp_ajax_nopriv_ Test en tant qu’admin uniquement Ajouter le hook nopriv ou forcer la connexion selon le besoin
Réponse = -1 / 403 Nonce invalide (mauvais nom, mauvais champ, page en cache) Vérifier check_ajax_referer(), recharger la page, exclure la page du cache si nécessaire
Erreur JS “Unexpected token <” Le serveur renvoie du HTML (fatal PHP, page d’erreur, WAF) Lire la réponse brute Network, corriger l’erreur PHP, vérifier sécurité/caches
JS non chargé Mauvais wp_enqueue_script, condition trop stricte, chemin faux Contrôler Network, vérifier plugin_dir_url(__FILE__), vérifier has_shortcode()
“Call to undefined function …” Code exécuté trop tôt ou fichier chargé hors WordPress Ne pas appeler des fonctions WP hors hooks, vérifier que le fichier n’est pas accessible directement
Conflit avec un plugin de snippets Snippet tronqué, parenthèse manquante, ordre de chargement Passer à un plugin, ajouter une CI minimale, relire les logs, tester en local
Erreur PHP “Parse error” Point-virgule manquant, accolade mal fermée Éditeur avec linting, activer affichage d’erreurs en staging, relire la dernière modification
Tutoriel ancien incompatible Exemples jQuery obsolètes, mauvais patterns de sécurité Adapter à WP 6.9.4+ (nonce, wp_send_json_*, enqueue propre)

Conseils sécurité, performance et maintenance

Sécurité

  • Nonce systématique dès que l’action change quelque chose (écriture DB, email, suppression, etc.).
  • Capabilities dès que l’action touche à des données sensibles (current_user_can()).
  • Sanitization + validation : ne confondez pas les deux. Sanitizer n’implique pas que la valeur est acceptable.
  • Limiter les abus : borne de limit, longueur minimale, éventuellement rate limiting (par IP/user) si endpoint public.

Pour les bonnes pratiques générales de sécurité plugin : Plugin Security.

Performance

  • N’enqueuez pas partout. La condition has_shortcode() est une optimisation simple.
  • Optimisez WP_Query : no_found_rows, pas de caches meta/terms si inutiles.
  • Évitez Ajax “à chaque frappe” sans debounce/throttle. Sinon vous transformez votre serveur en moteur d’autocomplete coûteux.
  • Cache applicatif si le trafic est fort (transients, object cache).

Maintenance

  • Nommez vos actions avec un préfixe unique (ici bpcab_) pour éviter les collisions.
  • Centralisez les constantes (action, nonce) pour éviter les typos.
  • Logguez proprement en staging si vous déboguez, puis retirez les logs bavards.
  • Documentez le contrat JSON (champs attendus) : côté JS, vous serez content dans 6 mois.

Ressources

FAQ

Pourquoi WordPress renvoie parfois juste “0” ?

Le plus courant : l’action envoyée (action=...) ne correspond à aucun hook enregistré (wp_ajax_{action} ou wp_ajax_nopriv_{action}). Deuxième cause fréquente : un die() prématuré ou une sortie parasite qui casse le flux.

Je dois utiliser GET ou POST ?

Pour admin-ajax.php, je privilégie POST par défaut. Pour des lectures cacheables, la REST API en GET est souvent un meilleur choix. Si vous utilisez GET sur admin-ajax, surveillez les caches et les URLs qui s’allongent.

Est-ce que wp_ajax_ est “déconseillé” en 2026 ?

Non. Ce n’est pas la solution universelle, mais elle reste supportée et largement utilisée. Pour des endpoints structurés et publics, la REST API est généralement plus adaptée. Pour des actions internes, admin-ajax reste pratique.

Comment envoyer un tableau complexe (JSON) à admin-ajax.php ?

Soit vous l’envoyez sous forme de champs multiples (foo[bar]=...), soit vous envoyez une string JSON et vous la décodez côté PHP. WordPress ne parse pas automatiquement un body JSON pour admin-ajax, donc vous devrez gérer php://input si vous partez sur Content-Type: application/json.

Pourquoi utiliser wp_send_json_* au lieu de echo json_encode ?

wp_send_json_success() gère les headers, l’encodage, et stoppe l’exécution proprement. Avec echo, vous finissez souvent avec des espaces, notices, ou un buffer qui pollue la réponse, puis un JSON invalide.

Mon nonce “expire” trop vite, c’est normal ?

Un nonce WordPress est lié à une fenêtre temporelle et au contexte utilisateur. Si votre page est servie depuis un cache pendant longtemps, vous pouvez vous retrouver avec un nonce obsolète. Solution : exclure la page du cache, ou régénérer le nonce via un mécanisme dédié (REST/endpoint léger), selon votre architecture.

Comment déboguer rapidement côté serveur ?

Activez WP_DEBUG_LOG en staging et logguez temporairement les entrées (sans données sensibles). Ensuite, regardez debug.log. Référence : Debugging in WordPress.

Est-ce que je peux appeler admin-ajax.php depuis un autre domaine ?

Ce n’est pas prévu pour être une API cross-domain. Vous allez vous heurter à CORS, aux cookies, et aux nonces. Pour du cross-domain, utilisez plutôt la REST API avec une stratégie d’authentification adaptée (Application Passwords, OAuth, JWT selon contexte).

Pourquoi mon JS marche en local mais pas en prod ?

Les causes réelles que je rencontre : cache agressif (page servie avec un nonce périmé), WAF qui bloque admin-ajax.php, minification qui casse le script, ou une différence de règles serveur sur /wp-admin/ pour les visiteurs.

Comment éviter de surcharger le serveur avec un Ajax de recherche ?

Ajoutez un debounce côté JS, imposez une longueur minimale, bornez la limite, et mettez un cache court (transient) si nécessaire. Si le besoin devient “moteur de recherche”, passez à une solution dédiée (indexation, ElasticPress, etc.).