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.

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-lruorvolatile-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.