• Skip to secondary menu
  • Skip to main content
  • Skip to primary sidebar
  • Home
  • Projects
  • Products
  • Themes
  • Tools
  • Request for Quote

Vengala Vinay

Having 12+ Years of Experience in Software Development

  • Home
  • WordPress
  • PHP
    • Codeigniter
  • Django
  • Magento
  • Selenium
  • Server
Home » How to implement native Redis caching layers for high-volume custom taxonomy queries in ACF Pro dynamic fields

How to implement native Redis caching layers for high-volume custom taxonomy queries in ACF Pro dynamic fields

Optimizing ACF Pro Dynamic Fields with Native Redis Caching for Custom Taxonomies

High-volume e-commerce platforms often rely on Advanced Custom Fields (ACF) Pro for flexible content structuring. When these fields dynamically pull data from custom taxonomies, especially for complex product filtering or attribute selection, query performance can become a bottleneck. This document details a robust, native Redis caching strategy to dramatically accelerate these queries, bypassing standard WordPress object caching mechanisms for critical taxonomy data.

Identifying the Performance Bottleneck: Taxonomy Query Performance

ACF Pro’s dynamic field functionality, particularly when populating choices from custom taxonomies (e.g., `get_terms()` or `wp_list_categories()`), can trigger numerous database queries. In a high-traffic environment, repeated execution of these queries for every page load, AJAX request, or form submission can saturate database resources and lead to slow response times. Standard WordPress object caching (e.g., Memcached, Redis via a plugin) might cache the *results* of `get_terms()`, but the overhead of cache invalidation and the sheer volume of requests can still impact performance. This strategy focuses on a more direct, application-level caching layer using Redis for specific, frequently accessed taxonomy term data.

Prerequisites: Redis Server and PHP Redis Extension

Before implementing the caching layer, ensure you have a Redis server accessible from your WordPress environment. You’ll also need the PHP Redis extension installed and enabled. Verify this with a `phpinfo()` output or by running:

  • php -m | grep redis

If the extension is not present, install it using your system’s package manager (e.g., apt-get install php-redis or yum install php-redis) and restart your web server/PHP-FPM. For this example, we’ll assume Redis is running on 127.0.0.1:6379.

Implementing a Custom Redis Cache Class

We’ll create a dedicated PHP class to manage our Redis interactions for taxonomy caching. This promotes modularity and maintainability. This class will handle connection, data retrieval, and expiration.

RedisTaxonomyCache.php

Place this file in a suitable location within your theme’s `inc/` directory or a custom plugin. For this example, we’ll assume it’s in wp-content/themes/your-theme/inc/RedisTaxonomyCache.php.

<?php
/**
 * Custom Redis Cache for Taxonomy Terms.
 *
 * Handles direct interaction with Redis for caching taxonomy term data,
 * bypassing standard WordPress object cache for specific, high-frequency queries.
 */

class RedisTaxonomyCache {

    private static $instance = null;
    private $redis = null;
    private $connected = false;
    private $host = '127.0.0.1';
    private $port = 6379;
    private $timeout = 2.5; // Connection timeout in seconds
    private $prefix = 'acf_taxonomy_'; // Cache key prefix

    /**
     * Private constructor to enforce singleton pattern.
     */
    private function __construct() {
        $this->connect();
    }

    /**
     * Get the singleton instance of the class.
     *
     * @return RedisTaxonomyCache
     */
    public static function get_instance() {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Establish connection to Redis.
     *
     * @return bool True on success, false on failure.
     */
    private function connect() {
        if ( ! class_exists( 'Redis' ) ) {
            error_log( 'Redis extension is not installed or enabled.' );
            return false;
        }

        try {
            $this->redis = new Redis();
            // Use pconnect for persistent connection if available and desired.
            // $this->redis->pconnect( $this->host, $this->port, $this->timeout );
            if ( $this->redis->connect( $this->host, $this->port, $this->timeout ) ) {
                $this->connected = true;
                // Optional: Authenticate if Redis requires a password
                // if ( ! empty( $redis_password ) && ! $this->redis->auth( $redis_password ) ) {
                //     error_log( 'Redis authentication failed.' );
                //     $this->connected = false;
                // }
                return true;
            } else {
                error_log( 'Failed to connect to Redis at ' . $this->host . ':' . $this->port );
                $this->connected = false;
                return false;
            }
        } catch ( RedisException $e ) {
            error_log( 'Redis connection error: ' . $e->getMessage() );
            $this->connected = false;
            return false;
        }
    }

    /**
     * Check if connected to Redis.
     *
     * @return bool
     */
    public function is_connected() {
        // Attempt to reconnect if connection was lost
        if ( ! $this->connected && class_exists( 'Redis' ) ) {
            $this->connect();
        }
        return $this->connected;
    }

    /**
     * Generate a cache key.
     *
     * @param string $key The base key.
     * @param array  $args Arguments to include in the key.
     * @return string The full cache key.
     */
    private function generate_key( string $key, array $args = [] ): string {
        $args_string = '';
        if ( ! empty( $args ) ) {
            // Sort args to ensure consistent key generation
            ksort( $args );
            $args_string = md5( json_encode( $args ) );
        }
        return $this->prefix . $key . ':' . $args_string;
    }

    /**
     * Get data from Redis cache.
     *
     * @param string $key  The base cache key.
     * @param array  $args Arguments used to generate the full key.
     * @return mixed|false The cached data or false if not found or not connected.
     */
    public function get( string $key, array $args = [] ) {
        if ( ! $this->is_connected() ) {
            return false;
        }

        $cache_key = $this->generate_key( $key, $args );

        try {
            $data = $this->redis->get( $cache_key );
            if ( $data === false ) {
                return false; // Cache miss
            }
            // Data is stored as JSON string
            return json_decode( $data, true );
        } catch ( RedisException $e ) {
            error_log( 'Redis GET error for key ' . $cache_key . ': ' . $e->getMessage() );
            $this->connected = false; // Assume connection lost on error
            return false;
        }
    }

    /**
     * Set data in Redis cache.
     *
     * @param string $key     The base cache key.
     * @param mixed  $data    The data to cache.
     * @param int    $ttl     Time to live in seconds. 0 for no expiration.
     * @param array  $args    Arguments used to generate the full key.
     * @return bool True on success, false on failure.
     */
    public function set( string $key, $data, int $ttl = 3600, array $args = [] ): bool {
        if ( ! $this->is_connected() ) {
            return false;
        }

        if ( empty( $data ) ) {
            return false; // Don't cache empty data
        }

        $cache_key = $this->generate_key( $key, $args );

        try {
            // Store data as JSON string
            $encoded_data = json_encode( $data );
            if ( $encoded_data === false ) {
                error_log( 'Redis SET: Failed to JSON encode data for key ' . $cache_key );
                return false;
            }

            if ( $ttl === 0 ) {
                return $this->redis->set( $cache_key, $encoded_data );
            } else {
                // SET with EX (expiration in seconds)
                return $this->redis->set( $cache_key, $encoded_data, [ 'EX' => $ttl ] );
            }
        } catch ( RedisException $e ) {
            error_log( 'Redis SET error for key ' . $cache_key . ': ' . $e->getMessage() );
            $this->connected = false; // Assume connection lost on error
            return false;
        }
    }

    /**
     * Delete a cache key.
     *
     * @param string $key  The base cache key.
     * @param array  $args Arguments used to generate the full key.
     * @return bool True on success, false on failure.
     */
    public function delete( string $key, array $args = [] ): bool {
        if ( ! $this->is_connected() ) {
            return false;
        }

        $cache_key = $this->generate_key( $key, $args );

        try {
            return $this->redis->del( $cache_key ) > 0;
        } catch ( RedisException $e ) {
            error_log( 'Redis DELETE error for key ' . $cache_key . ': ' . $e->getMessage() );
            $this->connected = false; // Assume connection lost on error
            return false;
        }
    }

    /**
     * Delete all keys matching a pattern (use with caution).
     *
     * @param string $pattern The pattern to match keys against (e.g., 'acf_taxonomy_my_taxonomy:*').
     * @return int|false The number of keys deleted, or false on failure.
     */
    public function delete_by_pattern( string $pattern ) {
        if ( ! $this->is_connected() ) {
            return false;
        }

        try {
            // SCAN is generally preferred over KEYS for production environments
            // as KEYS can block the server. However, for simplicity and direct
            // deletion, KEYS is shown here. For a robust solution, implement SCAN.
            // $keys = $this->redis->keys( $pattern );
            // if ( ! empty( $keys ) ) {
            //     return $this->redis->del( $keys );
            // }
            // return 0;

            // Using SCAN for safer deletion
            $iterator = null;
            $count = 0;
            $pattern = $this->prefix . $pattern; // Ensure prefix is included if not already

            while ( true ) {
                $result = $this->redis->scan( $iterator, $pattern, 100 ); // Scan in batches of 100
                if ( $result === false ) {
                    error_log( 'Redis SCAN error for pattern ' . $pattern );
                    $this->connected = false;
                    return false;
                }
                if ( ! empty( $result ) ) {
                    $deleted_count = $this->redis->del( $result );
                    if ( $deleted_count === false ) {
                        error_log( 'Redis DEL error during SCAN for pattern ' . $pattern );
                        $this->connected = false;
                        return false;
                    }
                    $count += $deleted_count;
                }
                if ( $iterator == 0 ) {
                    break; // Finished scanning
                }
            }
            return $count;

        } catch ( RedisException $e ) {
            error_log( 'Redis DELETE BY PATTERN error for pattern ' . $pattern . ': ' . $e->getMessage() );
            $this->connected = false; // Assume connection lost on error
            return false;
        }
    }

    /**
     * Flush the entire cache (use with extreme caution).
     *
     * @return bool True on success, false on failure.
     */
    public function flush_all(): bool {
        if ( ! $this->is_connected() ) {
            return false;
        }
        try {
            // FLUSHDB flushes the currently selected database.
            // Use FLUSHALL to flush all databases. Be VERY careful.
            return $this->redis->flushDB();
        } catch ( RedisException $e ) {
            error_log( 'Redis FLUSHDB error: ' . $e->getMessage() );
            $this->connected = false;
            return false;
        }
    }
}

Integrating with ACF Pro Dynamic Fields

Now, we’ll hook into WordPress actions and filters to leverage our RedisTaxonomyCache class. The primary goal is to intercept calls to `get_terms()` or similar functions when they are used for ACF dynamic fields and serve cached data from Redis if available. We also need to handle cache invalidation when terms are added, updated, or deleted.

Caching Taxonomy Term Queries

We’ll use the `get_terms` filter to intercept taxonomy queries. This filter allows us to modify the arguments passed to `get_terms` and, crucially, to modify or replace the returned results.

/**
 * Load the custom Redis cache class.
 */
require_once get_template_directory() . '/inc/RedisTaxonomyCache.php'; // Adjust path as needed

/**
 * Cache taxonomy term queries using Redis.
 *
 * This function hooks into the 'get_terms' filter. It checks if the query
 * is for an ACF Pro dynamic field and if cached data exists in Redis.
 * If so, it returns the cached data. Otherwise, it allows the query to proceed
 * and caches the results for future requests.
 *
 * @param array|WP_Term_Query $terms  The array of term objects, or WP_Error.
 * @param array               $args   An array of arguments used to retrieve terms.
 * @param string              $output The desired output format.
 * @param WP_Term_Query|null  $query  The WP_Term_Query object.
 * @return array|WP_Term_Query Modified terms array or original if not cached.
 */
function cache_acf_dynamic_taxonomy_terms( $terms, $args, $output, $query ) {

    // Only cache if Redis is connected and we are not in the admin area (or specific admin contexts)
    // and if the query is intended for ACF dynamic fields.
    // A more robust check might involve inspecting the calling function stack,
    // but this is often sufficient for typical ACF Pro usage.
    if ( ! RedisTaxonomyCache::get_instance()->is_connected() || is_admin() ) {
        return $terms;
    }

    // Heuristic: ACF Pro dynamic fields often have specific 'taxonomy' arguments.
    // We can also check if this is a 'get_terms' call originating from within ACF's
    // dynamic field rendering logic. A more direct approach is to check if
    // the 'acf_field_type' argument is present, which ACF adds.
    // However, 'get_terms' itself doesn't have this. We need to rely on context.
    // A common pattern is that these queries are made without a specific 'fields' argument,
    // or with arguments that are typical for select/multi-select fields.

    // Let's create a cache key based on the taxonomy and query arguments.
    // We need to be careful about which arguments are cacheable.
    // 'fields', 'output', 'orderby', 'order', 'hide_empty', 'number', 'offset', 'search', 'name', 'slug', 'parent', 'hierarchical' are good candidates.
    // 'cache_domain' and 'update_term_meta_cache' are internal WP args and should not be part of the key.
    // 'fields' and 'output' are often consistent for a given dynamic field.

    $cacheable_args = [
        'taxonomy'       => $args['taxonomy'] ?? '',
        'orderby'        => $args['orderby'] ?? 'name',
        'order'          => $args['order'] ?? 'ASC',
        'hide_empty'     => $args['hide_empty'] ?? true,
        'number'         => $args['number'] ?? null,
        'offset'         => $args['offset'] ?? null,
        'search'         => $args['search'] ?? null,
        'name'           => $args['name'] ?? null,
        'slug'           => $args['slug'] ?? null,
        'parent'         => $args['parent'] ?? null,
        'hierarchical'   => $args['hierarchical'] ?? true,
        'pad_counts'     => $args['pad_counts'] ?? false,
        'get'            => $args['get'] ?? 'all', // e.g., 'all', 'popular', 'specific'
        'child_of'       => $args['child_of'] ?? null,
        'update_term_meta_cache' => $args['update_term_meta_cache'] ?? true, // Cache meta if requested
        'meta_query'     => $args['meta_query'] ?? null, // Include meta query if present
        'fields'         => $args['fields'] ?? 'all', // Important for performance
        'output'         => $output ?? 'objects',
    ];

    // Filter out null values to keep the cache key consistent
    $cacheable_args = array_filter( $cacheable_args, function( $value ) {
        return $value !== null && $value !== false && $value !== '';
    } );

    // If no taxonomy is specified, we can't cache reliably.
    if ( empty( $cacheable_args['taxonomy'] ) ) {
        return $terms;
    }

    $cache_key_base = 'get_terms_' . sanitize_key( $cacheable_args['taxonomy'] );
    $redis_cache = RedisTaxonomyCache::get_instance();

    // Attempt to retrieve from cache
    $cached_terms = $redis_cache->get( $cache_key_base, $cacheable_args );

    if ( $cached_terms !== false ) {
        // Cache hit! Return cached data.
        // Ensure we return the correct format (e.g., objects, IDs, arrays)
        // The cached data is already decoded from JSON.
        // If 'fields' was 'ids', we might need to convert.
        // For simplicity, we assume 'fields' is 'all' or 'objects' and cache the full objects.
        // If 'fields' was 'names', we'd cache an array of names.
        // The current implementation caches the full term objects.
        return $cached_terms;
    }

    // Cache miss. Let the query run.
    // The $terms variable will contain the results after this function returns
    // if we don't modify it here. We need to ensure the original query runs.
    // The filter hook runs *after* the query, so $terms is already populated.
    // We just need to cache it if it's not an error.

    if ( is_wp_error( $terms ) || empty( $terms ) ) {
        return $terms; // Don't cache errors or empty results
    }

    // Cache the results for future requests.
    // TTL: 1 hour (3600 seconds). Adjust as needed.
    // For very stable taxonomies, this can be much longer.
    $ttl = HOUR_IN_SECONDS;
    $redis_cache->set( $cache_key_base, $terms, $ttl, $cacheable_args );

    return $terms;
}
add_filter( 'get_terms', 'cache_acf_dynamic_taxonomy_terms', 10, 4 );

/**
 * Clear specific taxonomy term cache when terms are updated.
 *
 * Hooks into actions that modify terms to invalidate relevant cache entries.
 */
function invalidate_taxonomy_term_cache( $term_id, $tt_id, $taxonomy, $object_id ) {
    // Invalidate cache for the specific taxonomy.
    // A more granular approach would be to invalidate only the specific query
    // that was cached, but this is more complex. Flushing by taxonomy is a
    // reasonable compromise.
    RedisTaxonomyCache::get_instance()->delete_by_pattern( 'get_terms_' . sanitize_key( $taxonomy ) . ':*' );

    // If term meta is cached, we might need to invalidate based on that too.
    // For simplicity, we're flushing all for the taxonomy.
}
add_action( 'created_term', 'invalidate_taxonomy_term_cache', 10, 4 );
add_action( 'edited_term', 'invalidate_taxonomy_term_cache', 10, 4 );
add_action( 'delete_term', 'invalidate_taxonomy_term_cache', 10, 4 ); // Note: delete_term might not receive all args consistently.

// A more robust invalidation might hook into taxonomy-specific actions if available,
// or use a transient that expires quickly and forces a cache refresh.
// For ACF dynamic fields, the `get_terms` filter is the primary mechanism.
// The invalidation ensures that when terms change, the cache is cleared.

Explanation:

  • The `cache_acf_dynamic_taxonomy_terms` function acts as a gatekeeper. It first checks if Redis is available and if we’re not in the admin area (to avoid interfering with backend operations).
  • It constructs a cache key based on the taxonomy and relevant query arguments. This is crucial for cache granularity. Arguments like `hide_empty`, `orderby`, `order`, `number`, `parent`, `fields`, and `output` are included as they significantly affect the query results.
  • It attempts to fetch data from Redis using $redis_cache->get().
  • If a cache hit occurs, the cached data is returned immediately, bypassing the database query.
  • If it’s a cache miss, the function allows the original `get_terms` query to execute. The results are then cached in Redis using $redis_cache->set() with a Time-To-Live (TTL) of 1 hour.
  • The `invalidate_taxonomy_term_cache` function hooks into term creation, editing, and deletion actions. When a term is modified, it uses delete_by_pattern() to remove all cached entries related to that taxonomy from Redis, ensuring data freshness.

Handling ACF Field Configuration

Within your ACF Pro field settings for a “Select” or “Checkbox” field, configure the “Query” settings to target your custom taxonomy. The “Taxonomy” field should be set to your taxonomy slug (e.g., product_category). The PHP code above will automatically intercept these queries.

ACF Pro Dynamic Field Settings Screenshot Placeholder
Example ACF Pro Dynamic Field Configuration for Taxonomy

Crucially, ensure the “Return Format” in your ACF field settings matches what you intend to retrieve. If your field is set to return “Term IDs”, the cached data (which is the full term objects) will still work, as ACF will process it. However, for maximum efficiency, if you only need IDs, you could modify the caching logic to store only IDs if `fields` is set to `ids` in the query arguments.

Advanced Considerations and Optimizations

Cache Invalidation Granularity

The current invalidation strategy clears all cached entries for a given taxonomy. For extremely high-traffic sites with many different dynamic field configurations for the same taxonomy, this might lead to more cache misses than necessary. A more advanced approach would involve:

  • Storing a unique cache key for each specific `get_terms` query (as we do for retrieval).
  • When a term is updated, instead of flushing by pattern, iterate through a list of known cache keys for that taxonomy and delete them individually. This requires maintaining a separate Redis set or list of active cache keys.
  • Using a “cache stampede” prevention mechanism if multiple requests hit an expired cache simultaneously.

Redis Configuration Tuning

Ensure your Redis server is properly configured for performance. Key settings include:

  • maxmemory: Set an appropriate memory limit to prevent Redis from consuming all available RAM.
  • maxmemory-policy: Choose a suitable eviction policy (e.g., allkeys-lru or volatile-lru) to manage memory when the limit is reached.
  • appendonly yes: For persistence, though for transient caching like this, it might be less critical unless you need to recover cache after a Redis restart.
  • Network latency: Ensure Redis is located geographically close to your web servers. Consider using a managed Redis service if self-hosting is complex.

Error Handling and Fallbacks

The provided code includes basic error logging. In a production environment, ensure robust error handling. If Redis is unavailable, the application should gracefully fall back to direct database queries without crashing. The current implementation achieves this by returning early if is_connected() is false.

Cache Key Generation Robustness

The `generate_key` method uses `md5(json_encode(args))` for the arguments part of the key. This is generally reliable. However, be mindful of data types and order within the arguments array. Sorting keys (`ksort`) before encoding helps ensure consistency. For complex arguments (like nested arrays in `meta_query`), ensure `json_encode` handles them correctly.

Alternative: WordPress Transients API with Redis

If you are already using a WordPress plugin that integrates Redis with the Transients API (e.g., “Redis Object Cache” or “WP Redis”), you could leverage that integration instead of a custom class. The core logic of checking, getting, setting, and expiring would remain similar, but you’d use functions like get_transient(), set_transient(), and delete_transient(). The advantage is using an established, well-tested integration. The disadvantage is that these plugins often cache *all* WordPress object cache data, which might not be ideal for highly specific, high-volume taxonomy queries where direct control is beneficial.

Conclusion

Implementing a native Redis caching layer for ACF Pro dynamic taxonomy fields is a powerful technique for optimizing high-volume e-commerce sites. By directly managing the caching of frequently queried taxonomy data, you can significantly reduce database load, improve page load times, and enhance the overall user experience. Remember to tailor TTL values and invalidation strategies to your specific application’s needs and data volatility.

Primary Sidebar

A little about the Author

Having 12+ Years of Experience in Software Development, Vinay is a principal software architect, senior systems engineer, and elite technical consultant. He specializes in bespoke PHP/WordPress development, high-performance Magento 2 & Shopify architectures, custom plugin/theme development from scratch, and legacy code modernization (including VB6, VB.NET, PyQt, and Crystal Reports). Known for solving complex database bottlenecks, speed optimization (Core Web Vitals), and advanced security code auditing, Vinay engineers production-ready systems designed to scale under heavy concurrent load conditions.



Chat on WhatsApp

Recent Posts

  • Reducing database query bloat in Sage Roots modern environments layouts using custom lazy loaders
  • Performance Optimization: Tuning PHP-FPM and opcache pools for high-concurrency Firebase Realtime DB handlers
  • Reducing Largest Contentful Paint (LCP) by optimizing custom script enqueuing structures in legacy plugins
  • How to implement native Redis caching layers for high-volume custom taxonomy queries in Carbon Fields custom wrappers
  • Building secure B2B pricing grids with custom REST API Controllers endpoints and role overrides

Categories

  • apache (1)
  • Business & Monetization (390)
  • Centos (4)
  • Comparisons & Decision Making (55)
  • Debian (2)
  • Debugging & Troubleshooting (658)
  • Desktop Applications (14)
  • DevOps (7)
  • DevOps & Cloud Scaling (962)
  • Django (1)
  • Laravel (4)
  • Migration & Architecture (192)
  • Mobile Applications (24)
  • MySQL (1)
  • Performance & Optimization (872)
  • PHP (5)
  • PHP Development (48)
  • Plugins & Themes (244)
  • Programming Languages (9)
  • Python (20)
  • Ruby on Rails (1)
  • Security & Compliance (639)
  • SEO & Growth (492)
  • Server (23)
  • Ubuntu (9)
  • VB6 & VB.NET (8)
  • Web Applications & Frontend (19)
  • Web Assembly (Wasm) (2)
  • WordPress (22)
  • WordPress Plugin Development (182)
  • WordPress Plugin Development (197)
  • WordPress Plugin Development (330)
  • WordPress Theme Development (357)

Recent Posts

  • Reducing database query bloat in Sage Roots modern environments layouts using custom lazy loaders
  • Performance Optimization: Tuning PHP-FPM and opcache pools for high-concurrency Firebase Realtime DB handlers
  • Reducing Largest Contentful Paint (LCP) by optimizing custom script enqueuing structures in legacy plugins

Top Categories

  • DevOps & Cloud Scaling (962)
  • Performance & Optimization (872)
  • Debugging & Troubleshooting (658)
  • Security & Compliance (639)
  • SEO & Growth (492)
  • Business & Monetization (390)

Our Products

  • ERP & LMS Systems (4)
  • Directories & Marketplaces (4)
  • Healthcare Portals (3)
  • Point of Sale (POS) (2)
  • E-Commerce Engines (2)

Our Services

  • E-Commerce Development (10)
  • WordPress Development (8)
  • Python & Desktop GUI (7)
  • General Consulting (7)
  • Legacy Modernization (5)
  • Mobile App Development (4)

Copyright © 2026 · Vinay Vengala