Si vous avez déjà vu une page admin qui met 800 ms à charger alors que la requête SQL n’en prend que 40, vous avez probablement un problème de recalcul inutile. Dans WordPress 6.9.4, l’API de cache objet (wp_cache_*) reste l’outil le plus direct pour éviter ces recomputations dans un plugin, à condition de l’utiliser avec une stratégie d’invalidation sérieuse.

Le problème / Le besoin

Vous développez un plugin qui calcule des données “chères” : agrégations (top articles), règles métier (droits), rendu de fragments HTML, ou appels HTTP vers une API. Sans cache, ces calculs se répètent à chaque chargement de page, parfois plusieurs fois par requête si votre code est appelé depuis plusieurs hooks.

Le cache objet WordPress sert à mémoriser des valeurs en mémoire (et parfois dans un backend persistant comme Redis/Memcached via un object-cache.php). Le point délicat n’est pas d’appeler wp_cache_set(). Le point délicat, c’est de définir :

  • des clés stables et non ambiguës,
  • un groupe de cache cohérent,
  • une durée de vie (TTL) réaliste,
  • et surtout une invalidation fiable (sinon vous servez des données périmées).

À la fin, vous saurez implémenter dans un plugin une couche de cache objet avec “versioning” par groupe, invalidation sur événements (save_post, deleted_term, update_option…), et une instrumentation minimale pour vérifier que ça marche en production.

Résumé rapide

  • On code un plugin qui expose un service Cache basé sur wp_cache_get()/wp_cache_set()/wp_cache_delete().
  • On évite l’invalidation “au cas par cas” fragile en ajoutant un token de version par groupe, stocké dans le cache (et re-généré si besoin).
  • On met en place des hooks d’invalidation sur les événements WordPress pertinents (posts, termes, options).
  • On ajoute une option de debug (log) pour mesurer les hit/miss sans profiler externe.
  • Tout est compatible WordPress 6.9.4+ et PHP 8.1+ (types, strictness raisonnable, pas d’API obsolète).

Quand utiliser cette solution

  • Calculs répétitifs dans une même requête (ex: même fonction appelée par plusieurs blocs Gutenberg, ou plusieurs widgets).
  • Pages à fort trafic où les mêmes données sont demandées en boucle (ex: sidebar “articles populaires”).
  • Admin lent à cause d’agrégations (ex: metabox qui calcule des stats).
  • Appels HTTP à des services externes, où vous voulez réduire la fréquence des requêtes.
  • Sites avec cache persistant (Redis/Memcached) : vous gagnez au-delà d’une requête, pas seulement “in-request”.

Dans mon expérience, c’est particulièrement rentable sur des sites Elementor/Divi/Avada qui empilent beaucoup de composants : le même “provider” de données est souvent invoqué plusieurs fois pendant le rendu.

Quand ne PAS utiliser cette solution

  • Données ultra-volatiles qui doivent être exactes à la seconde (ex: compteur temps réel). Préférez un stockage dédié ou du temps réel côté client.
  • Données par utilisateur très personnalisées (ex: panier, messages privés) : le risque de fuite via une clé mal conçue est réel. Préférez le cache utilisateur avec des clés incluant l’ID utilisateur, ou évitez.
  • Cache de page / HTML complet : l’objet cache n’est pas un substitut à un cache de page (Nginx, Varnish, plugin de cache). Ici on vise des fragments/données.
  • Vous ne pouvez pas invalider proprement : si vous ne savez pas “quand” effacer, vous allez servir du périmé. Dans ce cas, préférez un TTL très court, ou une source de vérité plus simple.
  • Vous confondez transients et object cache : les transients sont stockés en base si aucun cache persistant n’est présent. Pour des objets volumineux, ça peut empirer les performances.

Prérequis / avant de commencer

  • WordPress 6.9.4 (avril 2026) et PHP 8.1+.
  • Un environnement de test (local ou staging). Évitez de tester ce genre de refactor “cache” directement en production.
  • Un plugin de logs (facultatif) ou accès aux logs PHP/serveur.
  • Si vous visez un cache persistant : Redis/Memcached + drop-in object-cache.php. Exemples populaires : Redis Object Cache (plugin) ou solutions d’hébergement gérées.

Sources officielles utiles :

Côté PHP, gardez sous la main :

  • hash() (pour des clés robustes si besoin)

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

Le code que je vois le plus souvent dans des plugins “maison” :

<?php
// Exemple volontairement naïf : ne copiez pas tel quel.
function myplugin_get_popular_posts() {
    $cached = wp_cache_get( 'popular_posts' ); // Pas de groupe, clé trop générique.
    if ( false !== $cached ) {
        return $cached;
    }

    global $wpdb;
    $rows = $wpdb->get_results( "SELECT ID FROM {$wpdb->posts} WHERE post_status='publish' ORDER BY comment_count DESC LIMIT 10" );
    wp_cache_set( 'popular_posts', $rows ); // Pas de TTL, pas d'invalidation.
    return $rows;
}

Les problèmes :

  • Clé trop générique : collision possible avec un autre plugin, ou avec un autre contexte (langue, site multisite, post_type…).
  • Pas de groupe : vous perdez un levier majeur d’organisation et d’invalidation.
  • Pas de TTL : selon le backend, l’objet peut rester “éternellement” (ou jusqu’à eviction). Si vous ne l’invalidez pas, vous servez du périmé.
  • Pas d’invalidation : les commentaires changent, les posts changent, mais le cache reste.
  • Pas de stratégie multisite : la même clé sur plusieurs sites peut créer de la confusion selon l’implémentation du cache persistant.

Le résultat typique : “ça marche” en dev, puis en prod vous avez des données incohérentes, impossibles à diagnostiquer, et un support qui vous déteste.

La bonne approche — tutoriel pas à pas

Étape 1 — Définir votre contrat de cache

Vous voulez un cache “données” (pas “HTML complet”), avec :

  • un groupe dédié à votre plugin (ex: myplugin),
  • des clés qui incluent le contexte (site, langue, user si nécessaire),
  • un TTL par défaut (ex: 300s),
  • une invalidation robuste via un token de version par “namespace logique”.

Le token de version est une technique simple : au lieu de supprimer 50 clés une par une, vous changez un “version id” et toutes les anciennes clés deviennent inaccessibles (elles expirent naturellement ensuite). C’est le pattern que j’utilise quand l’invalidation fine devient trop coûteuse.

Étape 2 — Créer une classe Cache (service) et un mini container

Je pars sur une architecture plugin orientée services (sans framework). Ça évite le spaghetti de fonctions globales et ça vous laisse évoluer vers du test unitaire.

Arborescence proposée :

  • myplugin-object-cache/
  • myplugin-object-cache.php (bootstrap)
  • src/Plugin.php
  • src/Container.php
  • src/Cache/Cache.php
  • src/Cache/Invalidator.php
  • src/PopularPosts/Repository.php (exemple concret)

Étape 3 — Concevoir des clés stables (et courtes)

Les backends de cache ont parfois des limites de longueur de clé. Évitez les clés “humaines” de 300 caractères. Construisez une clé lisible, puis hash si nécessaire.

  • Incluez blog_id (multisite) via get_current_blog_id().
  • Incluez locale si vos résultats dépendent de la langue (Polylang/WPML).
  • Incluez les paramètres métier (limit, post_type, période, etc.).

Étape 4 — Ajouter l’invalidation sur hooks WordPress

Pour un exemple “popular posts”, je déclenche une invalidation quand :

  • un commentaire est ajouté/supprimé (ou son statut change),
  • un post est publié/mis à jour/supprimé,
  • un réglage du plugin change.

Vous n’êtes pas obligé d’être parfait au millimètre. Le but est d’éviter le pire (données figées pendant des jours).

Étape 5 — Instrumenter hit/miss pour vérifier

Sans mesure, vous ne savez pas si votre cache sert réellement. Je mets souvent un compteur simple (statique) + log conditionnel quand WP_DEBUG est actif.

Code complet

Copiez-collez ce plugin dans wp-content/plugins/myplugin-object-cache/ puis activez-le. Le code est volontairement compact mais complet, et montre un cas réel (top posts par commentaires) avec cache et invalidation.

<?php
/**
 * Plugin Name: MyPlugin - Object Cache Example (wp_cache_*)
 * Description: Exemple avancé d'implémentation du cache objet WordPress (WP 6.9.4+, PHP 8.1+), avec versioning et invalidation.
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 * Author: Votre Nom
 */

declare(strict_types=1);

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

require_once __DIR__ . '/src/Container.php';
require_once __DIR__ . '/src/Plugin.php';
require_once __DIR__ . '/src/Cache/Cache.php';
require_once __DIR__ . '/src/Cache/Invalidator.php';
require_once __DIR__ . '/src/PopularPosts/Repository.php';

add_action('plugins_loaded', static function (): void {
    $container = new MyPluginOCContainer();

    $plugin = new MyPluginOCPlugin($container);
    $plugin->register();
});
<?php
// Fichier: src/Container.php

declare(strict_types=1);

namespace MyPluginOC;

final class Container {
    /**
     * @var array<string, callable>
     */
    private array $factories = [];

    /**
     * @var array<string, mixed>
     */
    private array $instances = [];

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

    public function get(string $id): mixed {
        if (array_key_exists($id, $this->instances)) {
            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];
    }
}
<?php
// Fichier: src/Plugin.php

declare(strict_types=1);

namespace MyPluginOC;

use MyPluginOCCacheCache;
use MyPluginOCCacheInvalidator;
use MyPluginOCPopularPostsRepository;

final class Plugin {
    public const CACHE_GROUP = 'myplugin_oc';

    public function __construct(private Container $container) {}

    public function register(): void {
        $this->register_services();

        // Invalidation sur événements clés.
        $invalidator = $this->container->get('cache.invalidator');
        add_action('save_post', [$invalidator, 'on_save_post'], 10, 3);
        add_action('deleted_post', [$invalidator, 'on_deleted_post'], 10, 1);

        add_action('wp_insert_comment', [$invalidator, 'on_comment_change'], 10, 2);
        add_action('edit_comment', [$invalidator, 'on_comment_change'], 10, 2);
        add_action('deleted_comment', [$invalidator, 'on_comment_deleted'], 10, 1);
        add_action('transition_comment_status', [$invalidator, 'on_transition_comment_status'], 10, 3);

        // Exemple: si votre plugin a des options.
        add_action('update_option_myplugin_oc_settings', [$invalidator, 'on_plugin_settings_change'], 10, 3);

        // Démo: shortcode pour afficher les posts populaires.
        add_shortcode('myplugin_oc_popular', [$this, 'render_shortcode']);

        // Debug: afficher des stats en footer (admin uniquement).
        add_action('admin_footer', [$this, 'maybe_print_cache_stats']);
    }

    private function register_services(): void {
        $this->container->set('cache', function (): Cache {
            return new Cache(self::CACHE_GROUP, 300);
        });

        $this->container->set('cache.invalidator', function (Container $c): Invalidator {
            return new Invalidator($c->get('cache'));
        });

        $this->container->set('popular.repository', function (Container $c): Repository {
            return new Repository($c->get('cache'));
        });
    }

    public function render_shortcode(array $atts = []): string {
        $atts = shortcode_atts(
            [
                'limit' => 5,
                'post_type' => 'post',
            ],
            $atts,
            'myplugin_oc_popular'
        );

        $limit = max(1, (int) $atts['limit']);
        $post_type = sanitize_key((string) $atts['post_type']);

        /** @var Repository $repo */
        $repo = $this->container->get('popular.repository');
        $posts = $repo->get_popular_posts($post_type, $limit);

        if (empty($posts)) {
            return '';
        }

        $out = '<div class="myplugin-oc-popular"><ul>';
        foreach ($posts as $post) {
            $url = esc_url(get_permalink($post->ID));
            $title = esc_html(get_the_title($post->ID));
            $out .= '<li><a href="' . $url . '">' . $title . '</a></li>';
        }
        $out .= '</ul></div>';

        return $out;
    }

    public function maybe_print_cache_stats(): void {
        if (!defined('WP_DEBUG') || !WP_DEBUG) {
            return;
        }
        if (!current_user_can('manage_options')) {
            return;
        }

        /** @var Cache $cache */
        $cache = $this->container->get('cache');
        $stats = $cache->get_stats();

        echo '<div style="padding:8px 12px;margin:12px 0;border:1px solid #ccd0d4;background:#fff">';
        echo '<strong>MyPluginOC cache stats</strong><br>';
        echo 'Hits: ' . (int) $stats['hits'] . ' | Miss: ' . (int) $stats['miss'] . ' | Sets: ' . (int) $stats['sets'];
        echo '</div>';
    }
}
<?php
// Fichier: src/Cache/Cache.php

declare(strict_types=1);

namespace MyPluginOCCache;

final class Cache {
    private int $hits = 0;
    private int $miss = 0;
    private int $sets = 0;

    public function __construct(
        private string $group,
        private int $default_ttl = 300
    ) {}

    /**
     * Retourne une valeur du cache ou null si absente.
     * Note: wp_cache_get() retourne false si absent, mais false peut être une valeur valide.
     * On utilise donc le paramètre $found.
     */
    public function get(string $key, ?string $namespace = null): mixed {
        $cache_key = $this->build_key($key, $namespace);
        $found = false;

        $value = wp_cache_get($cache_key, $this->group, false, $found);

        if ($found) {
            $this->hits++;
            return $value;
        }

        $this->miss++;
        return null;
    }

    /**
     * Stocke une valeur.
     * @param int|null $ttl Durée de vie en secondes. null = TTL par défaut.
     */
    public function set(string $key, mixed $value, ?int $ttl = null, ?string $namespace = null): bool {
        $cache_key = $this->build_key($key, $namespace);
        $ttl = $ttl ?? $this->default_ttl;

        $ok = wp_cache_set($cache_key, $value, $this->group, $ttl);
        if ($ok) {
            $this->sets++;
        }
        return (bool) $ok;
    }

    /**
     * Supprime une entrée précise (rarement nécessaire si vous versionnez).
     */
    public function delete(string $key, ?string $namespace = null): bool {
        $cache_key = $this->build_key($key, $namespace);
        return (bool) wp_cache_delete($cache_key, $this->group);
    }

    /**
     * Invalidation “large”: on bump la version d'un namespace.
     * Toutes les clés de ce namespace changent automatiquement.
     */
    public function bump_namespace_version(string $namespace): void {
        $ver_key = $this->namespace_version_key($namespace);
        $new = (string) microtime(true);

        // TTL long: le token doit survivre, mais pas forcément éternellement.
        // Si le token expire, il sera recréé au prochain get.
        wp_cache_set($ver_key, $new, $this->group, 86400);
    }

    /**
     * Stats simples pour debug.
     * Ne vous en servez pas comme métrique “prod” officielle.
     */
    public function get_stats(): array {
        return [
            'hits' => $this->hits,
            'miss' => $this->miss,
            'sets' => $this->sets,
        ];
    }

    private function build_key(string $key, ?string $namespace): string {
        $blog_id = (string) get_current_blog_id();

        // Si vous avez une dépendance à la langue, vous pouvez injecter un resolver.
        $locale = (string) determine_locale();

        $namespace = $namespace ? $namespace : 'default';
        $ver = $this->get_namespace_version($namespace);

        // Clé lisible + compacte. Hash si vous avez des paramètres longs.
        $raw = $blog_id . '|' . $locale . '|' . $namespace . '|' . $ver . '|' . $key;

        // Certains backends aiment les clés courtes.
        // 40 chars SHA1 + prefix = stable, sans collision réaliste pour ce cas.
        return 'k:' . sha1($raw);
    }

    private function get_namespace_version(string $namespace): string {
        $ver_key = $this->namespace_version_key($namespace);
        $found = false;

        $ver = wp_cache_get($ver_key, $this->group, false, $found);
        if ($found && is_string($ver) && $ver !== '') {
            return $ver;
        }

        // Première utilisation: on initialise.
        $ver = (string) microtime(true);
        wp_cache_set($ver_key, $ver, $this->group, 86400);

        return $ver;
    }

    private function namespace_version_key(string $namespace): string {
        $blog_id = (string) get_current_blog_id();
        return 'nsver:' . $blog_id . ':' . $namespace;
    }
}
<?php
// Fichier: src/Cache/Invalidator.php

declare(strict_types=1);

namespace MyPluginOCCache;

final class Invalidator {
    public function __construct(private Cache $cache) {}

    /**
     * Invalidation sur sauvegarde de post.
     * @param int $post_id
     * @param WP_Post $post
     * @param bool $update
     */
    public function on_save_post(int $post_id, WP_Post $post, bool $update): void {
        // Autosave / révisions : évitez de flusher pour rien.
        if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
            return;
        }

        // Si votre cache dépend du statut, invalidez aussi sur transitions.
        $this->cache->bump_namespace_version('popular_posts');
    }

    public function on_deleted_post(int $post_id): void {
        $this->cache->bump_namespace_version('popular_posts');
    }

    /**
     * Commentaires ajoutés/édités: invalider les "popular posts".
     * @param int $comment_id
     * @param array|string $comment_data
     */
    public function on_comment_change(int $comment_id, $comment_data): void {
        $this->cache->bump_namespace_version('popular_posts');
    }

    public function on_comment_deleted(int $comment_id): void {
        $this->cache->bump_namespace_version('popular_posts');
    }

    /**
     * Transition de statut (ex: 'hold' -> 'approve').
     * @param string $new_status
     * @param string $old_status
     * @param WP_Comment $comment
     */
    public function on_transition_comment_status(string $new_status, string $old_status, WP_Comment $comment): void {
        if ($new_status === $old_status) {
            return;
        }
        $this->cache->bump_namespace_version('popular_posts');
    }

    public function on_plugin_settings_change(mixed $old_value, mixed $value, string $option): void {
        $this->cache->bump_namespace_version('popular_posts');
    }
}
<?php
// Fichier: src/PopularPosts/Repository.php

declare(strict_types=1);

namespace MyPluginOCPopularPosts;

use MyPluginOCCacheCache;

final class Repository {
    public function __construct(private Cache $cache) {}

    /**
     * Retourne une liste de WP_Post (objets) ordonnée par comment_count.
     * Cache: 5 minutes par défaut, invalidation par namespace.
     *
     * @return WP_Post[]
     */
    public function get_popular_posts(string $post_type, int $limit): array {
        $post_type = sanitize_key($post_type);
        $limit = max(1, $limit);

        $cache_key = 'popular:' . $post_type . ':' . $limit;

        $cached = $this->cache->get($cache_key, 'popular_posts');
        if (is_array($cached)) {
            // Petite validation: éviter les retours bizarres si un autre code a pollué la clé.
            $all_posts = true;
            foreach ($cached as $p) {
                if (!$p instanceof WP_Post) {
                    $all_posts = false;
                    break;
                }
            }
            if ($all_posts) {
                return $cached;
            }
        }

        // Requête: utilisez WP_Query pour respecter les filtres / caches internes.
        $q = new WP_Query([
            'post_type' => $post_type,
            'post_status' => 'publish',
            'posts_per_page' => $limit,
            'orderby' => 'comment_count',
            'order' => 'DESC',
            'no_found_rows' => true,
            'ignore_sticky_posts' => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ]);

        $posts = $q->posts;

        // Vous pouvez ajuster le TTL selon la nature des données.
        $this->cache->set($cache_key, $posts, 300, 'popular_posts');

        return $posts;
    }
}

Explication du code

Logique générale (simple)

Le repository calcule une liste de posts populaires. Avant de lancer WP_Query, il tente de récupérer le résultat depuis le cache objet. Si trouvé, on renvoie immédiatement.

Quand un événement susceptible de changer le classement arrive (post/comment), on ne supprime pas chaque clé. On bump une “version” de namespace (popular_posts). Toutes les clés de ce namespace changent automatiquement, donc l’ancien cache devient obsolète.

Pourquoi utiliser $found avec wp_cache_get()

wp_cache_get() retourne false si la clé n’existe pas. Sauf que false peut être une valeur légitime (ex: “aucun résultat”). Le paramètre $found permet de distinguer “absent” de “présent mais false”.

Référence : wp_cache_get().

Groupes de cache

On utilise un groupe dédié myplugin_oc. C’est un cloisonnement logique. Beaucoup d’implémentations de cache persistant segmentent aussi par groupe, ce qui aide à éviter les collisions.

Namespace + versioning (invalidation robuste)

Le namespace (popular_posts) représente une famille de clés. Le token de version (stocké sous nsver:blog_id:namespace) est concaténé dans la clé finale (après hash). Quand on bump_namespace_version(), on change le token, donc toutes les clés changent.

Edge case réel : si votre backend évince la clé de version (pression mémoire), le code la recrée automatiquement. Vous perdez temporairement l’invalidation “globale”, mais vous ne cassez pas le site.

Pourquoi sha1() pour la clé

Je préfère des clés courtes et stables. Certains backends (ou proxys) se comportent mal avec des clés très longues. Ici sha1() suffit pour éviter collisions pratiques. Si vous stockez des secrets dans la clé (évitez), sachez que le hash ne les “protège” pas vraiment : c’est seulement une compaction.

Hooks d’invalidation

On invalide sur :

  • save_post et deleted_post (contenu),
  • wp_insert_comment, edit_comment, deleted_comment, transition_comment_status (comment_count),
  • update_option_myplugin_oc_settings (config plugin).

Ce choix est pragmatique. J’ai souvent vu des plugins invalider sur init “par sécurité” : c’est l’équivalent de ne pas avoir de cache.

Sécurité (sanitization/escaping) dans le shortcode

Le shortcode accepte limit et post_type. On :

  • cast limit en int + max(1, ...),
  • nettoie post_type via sanitize_key(),
  • escape la sortie HTML avec esc_url() et esc_html().

Ce n’est pas “parce que c’est un shortcode” que vous pouvez ignorer la sécurité. Les shortcodes finissent souvent dans des builders et peuvent être manipulés par des rôles non-admin selon votre politique.

Variantes et cas d’usage

Variante 1 — Cache “négatif” (aucun résultat)

Si votre calcul renvoie souvent “rien”, vous voulez le cacher aussi. Avec notre wrapper, vous pouvez stocker un tableau vide et le distinguer d’un miss.

<?php
$cache_key = 'myquery:' . $param;
$cached = $cache->get($cache_key, 'my_ns');
if (is_array($cached)) {
    return $cached; // Peut être [].
}

$results = expensive_query(); // Peut retourner [].
$cache->set($cache_key, $results, 120, 'my_ns');
return $results;

Variante 2 — “Cache stampede” (anti-effet troupeau) avec lock léger

Sur un site à fort trafic, si le cache expire, 200 requêtes peuvent recalculer en même temps. Vous pouvez ajouter un verrou court (lock key) dans le cache. Ce n’est pas parfait (race conditions possibles), mais ça réduit fortement la charge.

<?php
function get_with_lock(MyPluginOCCacheCache $cache, string $key, string $ns, callable $compute, int $ttl = 300): mixed {
    $cached = $cache->get($key, $ns);
    if (null !== $cached) {
        return $cached;
    }

    // Lock 15s.
    $lock_key = 'lock:' . $key;
    $got_lock = $cache->set($lock_key, 1, 15, $ns);

    if (!$got_lock) {
        // Quelqu'un d'autre calcule. Petite attente, puis re-lecture.
        usleep(150000); // 150ms
        $cached = $cache->get($key, $ns);
        if (null !== $cached) {
            return $cached;
        }
        // Fallback: on calcule quand même (sinon vous risquez de servir vide).
    }

    $value = $compute();
    $cache->set($key, $value, $ttl, $ns);
    $cache->delete($lock_key, $ns);

    return $value;
}

Note : wp_cache_add() existe et peut être plus adapté pour un lock atomique selon le backend. Si votre backend le supporte correctement, utilisez-le. Référence : wp_cache_add().

Variante 3 — Cache par utilisateur (sans fuite)

Si la donnée dépend de l’utilisateur, incluez explicitement get_current_user_id() dans la clé. Et réfléchissez aux rôles/capabilities si la donnée est sensible.

<?php
$user_id = get_current_user_id();
$cache_key = 'dashboard:' . $user_id . ':' . $widget_id;

$data = $cache->get($cache_key, 'user_widgets');
if (null === $data) {
    $data = compute_user_widget($user_id);
    $cache->set($cache_key, $data, 60, 'user_widgets');
}

Compatibilité Divi 5 / Elementor / Avada

Le cache objet se situe côté serveur : il est agnostique du builder. Les problèmes viennent plutôt du contexte (prévisualisation, rendu AJAX, variations par device/langue) et de la façon dont les builders appellent vos shortcodes/widgets.

Divi 5

  • Divi peut rendre des modules en preview avec des requêtes supplémentaires. Votre cache va amortir ces appels.
  • Si vous encapsulez votre logique dans un shortcode (comme ici), Divi l’insère facilement via un module “Code” ou “Texte”.
  • Attention aux variations par page : si votre sortie dépend de l’ID de page, incluez-le dans la clé (sinon vous verrez des “mauvais” contenus réutilisés).

Elementor

  • Elementor déclenche souvent des rendus multiples pendant l’édition. Un cache avec TTL court (60–300s) évite de recalculer à chaque interaction.
  • Si vous créez un widget Elementor custom, mettez le cache dans le layer “data provider”, pas dans render() directement. Vous évitez de mélanger HTML et données.

Avada (Fusion Builder)

  • Avada utilise fréquemment des shortcodes Fusion. Votre shortcode [myplugin_oc_popular] s’intègre sans friction.
  • Si Avada minifie/concatène, ça ne change rien au cache objet. En revanche, si vous testez côté front, videz aussi le cache de page (plugin/serveur) sinon vous ne verrez pas vos changements.

Vérifications après mise en place

  • Activez WP_DEBUG sur staging et vérifiez l’encart “MyPluginOC cache stats” en bas des pages admin : vous devez voir des hits augmenter après le premier chargement.
  • Testez le shortcode sur une page : ajoutez [myplugin_oc_popular limit="5" post_type="post"].
  • Ajoutez un commentaire sur un des posts populaires, rechargez : vous devriez observer un miss puis des hits (car invalidation).

Tableau de diagnostic rapide

Symptôme Cause probable Vérification Solution
Les résultats ne changent jamais Invalidation non déclenchée Ajoutez un commentaire, observez hits/miss, vérifiez hooks Corrigez les hooks, ajoutez transition_post_status si nécessaire
Le cache “ne sert à rien” (miss tout le temps) Clé instable (inclut un paramètre variable) Loggez la clé “logique” avant hash Retirez les éléments volatiles (timestamp, nonce…)
Données incohérentes entre langues Locale non prise en compte Changez de langue et comparez Inclure determine_locale() (déjà fait ici) ou un resolver WPML/Polylang
Amélioration uniquement sur une requête Pas de cache persistant Vérifiez présence de object-cache.php Installer/configurer Redis/Memcached ou accepter “in-request only”
Erreur 500 après ajout du code Syntaxe / PHP trop ancien / fichier au mauvais endroit Consultez logs PHP, activez WP_DEBUG_LOG Corrigez la syntaxe, assurez PHP 8.1+, placez les fichiers correctement

Si ça ne marche pas

  1. Vérifiez que le plugin est activé et que les fichiers src/ existent au bon endroit. J’ai vu ce bug des dizaines de fois : un zip qui ajoute un dossier intermédiaire.
  2. Regardez les logs : activez temporairement WP_DEBUG et WP_DEBUG_LOG dans wp-config.php. Une parenthèse oubliée ou un namespace mal orthographié se voit tout de suite.
  3. Vérifiez le hook : si vous mettez votre initialisation sur init mais que vous utilisez des APIs avant, vous pouvez créer un “call before loaded”. Ici on utilise plugins_loaded, ce qui est généralement sûr pour un plugin autonome.
  4. Désactivez les plugins de snippets si vous avez collé une partie du code dedans. Ces plugins tronquent parfois des fichiers, ou mélangent des balises PHP.
  5. Testez sans cache de page : si vous avez un cache de page agressif (serveur ou plugin), vous pouvez croire que votre invalidation ne marche pas alors que vous servez une page HTML déjà cachée.
  6. Multisite : vérifiez que vous testez sur le bon site du réseau. Les clés incluent blog_id, donc chaque site a son cache.

Pièges et erreurs courantes

Erreur Cause Solution
Code collé dans functions.php au lieu d’un plugin Mauvais endroit, chargement dépendant du thème, risque de crash à la mise à jour Créez un plugin dédié (mu-plugin si nécessaire)
Parse error: syntax error, unexpected token Point-virgule/parenthèse manquant, ou fichier encodé bizarrement Validez avec un IDE, vérifiez les logs, évitez de mélanger <?php et du HTML brut
Cache jamais hit Clé inclut un élément variable (nonce, microtime, URL complète avec paramètres) Stabilisez la clé (paramètres métier uniquement), loggez la clé “avant hash” en debug
Données périmées TTL trop long ou invalidation manquante Ajoutez des hooks, ou réduisez le TTL, ou versionnez par namespace (comme ici)
Invalider sur init “pour être sûr” Réflexe courant, annule le cache Invalidez uniquement sur événements de mutation (save_post, updated_option…)
Conflit avec un autre plugin de cache Utilisation d’un groupe générique (default) ou clés trop communes Utilisez un groupe unique et préfixez vos namespaces
Test sur production sans sauvegarde Refactor cache = risque de logique erronée Staging + rollback, déploiement progressif, logs activés temporairement
“Ça marchait dans un vieux tuto” Code ancien, pas de $found, confusion transients/object cache Adaptez à WP 6.9.4+ : utilisez $found, wrapper, invalidation propre

Conseils sécurité, performance et maintenance

  • Ne cachez pas des objets énormes (ex: milliers de posts avec meta). Vous allez saturer Redis/Memcached et provoquer de l’eviction. Cachez des IDs, ou des agrégats compacts.
  • Évitez de mettre des données sensibles dans un cache partagé sans scoping (user_id, capability, site). Une clé mal conçue peut devenir une fuite.
  • Choisissez TTL + invalidation : l’un sans l’autre est fragile. TTL seul = périmé possible. Invalidation seule = risque si elle rate un événement.
  • Préférez versioning de namespace quand vous avez beaucoup de combinaisons de clés. Supprimer individuellement devient vite ingérable.
  • Surveillez l’impact SEO indirect : un cache objet bien fait accélère TTFB, ce qui aide souvent. Un cache périmé peut afficher de mauvais contenus (ex: titres), ce qui peut nuire (CTR, confiance).
  • Compat future : restez sur l’API wp_cache_* (stable depuis longtemps) plutôt que d’appeler directement WP_Object_Cache (implémentation variable selon drop-in).

Si vous devez aller plus loin (observabilité), branchez-vous sur des métriques applicatives plutôt que de surcharger admin_footer. Le snippet de stats est volontairement “debug-only”.

Ressources

FAQ

1) Est-ce que wp_cache_set() écrit en base de données ?

Non. Par défaut, c’est un cache mémoire “non persistant” (durée de la requête). Avec un drop-in object-cache.php (Redis/Memcached), ça devient persistant selon la configuration.

2) Dois-je utiliser les transients à la place ?

Si vous avez besoin d’une persistance “portable” sans Redis/Memcached, les transients peuvent être plus adaptés (avec le coût potentiel d’écriture en base). Pour des calculs très fréquents et des données volumineuses, l’objet cache persistant est souvent meilleur.

3) Pourquoi ne pas faire wp_cache_flush() ?

Parce que vous allez purger tout le cache du site, y compris celui d’autres plugins et potentiellement du core. C’est une bombe nucléaire. Préférez un groupe dédié + invalidation ciblée (ou versioning).

4) Comment savoir si un cache persistant est actif ?

Vérifiez la présence de wp-content/object-cache.php. Vous pouvez aussi inspecter votre plugin Redis/Memcached. Sur beaucoup d’hébergements, c’est géré automatiquement.

5) Est-ce que le “versioning” laisse des anciennes clés traîner ?

Oui, jusqu’à expiration (TTL) ou eviction. C’est un compromis classique : on évite de parcourir/supprimer N clés. Ajustez TTL pour limiter l’empreinte.

6) Que se passe-t-il si deux requêtes bumpent la version en même temps ?

Rien de grave : la dernière écriture gagne. Les clés produites avec l’ancienne version deviennent obsolètes immédiatement. C’est précisément l’effet recherché.

7) Puis-je cacher du HTML complet ?

Oui, techniquement. Mais attention aux variations (utilisateur, rôle, langue, device, AB test). Sur des builders, c’est une source fréquente de “mauvais contenu servi”. Je préfère cacher des données et reconstruire le HTML.

8) Mon cache ne fait que des “miss” en admin, c’est normal ?

Ça arrive si vos clés incluent un contexte qui change en admin (ex: paramètres de preview). Vérifiez que votre clé n’intègre pas l’URL complète ou un nonce.

9) Est-ce que je dois sérialiser moi-même ?

Non. WordPress sérialise/désérialise si nécessaire selon le backend. Évitez par contre de stocker des ressources non sérialisables (handles, closures).

10) Comment tester proprement l’invalidation ?

Sur staging : chargez la page (miss), rechargez (hit), modifiez un post/ajoutez un commentaire (bump), rechargez (miss), puis rechargez (hit). Si ce cycle est stable, vous êtes déjà loin devant la plupart des implémentations “cache rapide”.