Building Your Own Object Cache Drop-In with PHPFastCache Plugin

Table of Contents

A hands-on, production-ready guide to replacing WordPress’s default in-memory object cache with a
persistent, high-performance drop-in powered by PHPFastCache. We’ll build a complete
WordPress admin plugin with a standalone menu, statistics dashboard, one-click cache management,
and nginx FastCGI cache purging
all following WordPress Coding Standards (WPCS) and security best practices.

🐘 PHP 8.1+  required
⚙️ WordPress 6.4+

1 What is the WordPress Object Cache?

WordPress ships with a built-in object cache — a key-value store that reduces repeated
database queries within a single page request. By default, this cache lives entirely in PHP memory and
is discarded at the end of every request. That means the next visitor triggers the
same queries all over again.

To make the object cache persistent across requests, WordPress introduced the concept of a
drop-in: a file named object-cache.php placed in the
wp-content/ directory. When WordPress detects this file, it loads it instead of its own
internal WP_Object_Cache class, giving you complete control over how objects are stored
and retrieved.

💡

Key Fact
Popular persistent object cache solutions like Redis Object Cache and
Memcached Object Cache
work precisely this way — they provide a
object-cache.php drop-in that hooks into WordPress at the lowest possible level,
before any plugin or theme code runs.

The WordPress object cache API exposes these primary functions that your drop-in must implement:

Function Purpose Returns
wp_cache_get() Retrieve a value by key and group mixed | false
wp_cache_set() Store a value with optional TTL bool
wp_cache_add() Store only if key doesn’t already exist bool
wp_cache_delete() Remove a single cache entry bool
wp_cache_flush() Flush all cache entries bool
wp_cache_replace() Replace an existing entry bool
wp_cache_incr() Increment a numeric value int | false
wp_cache_decr() Decrement a numeric value int | false
wp_cache_init() Called by WordPress to initialise the cache object void
wp_cache_close() Called on shutdown to persist/close connections bool

2 What is PHPFastCache?

PHPFastCache is a mature, battle-tested PHP caching library available via Composer.
It provides a unified API across dozens of storage backends — from the filesystem to Redis, Memcached,
APCu, Couchbase, DynamoDB, and more — with zero configuration changes to your application code when
you switch drivers.

Multiple Drivers

Redis, Memcached, APCu, Files, SQLite3, MongoDB, DynamoDB, Couchbase and many more — all behind a single interface.

🔒

PSR-6 / PSR-16 Compliant

Implements PHP-FIG’s standard caching interfaces so your code stays interoperable and testable.

🏷️

Cache Tagging

Tag cache items and invalidate entire groups at once — perfect for post-type or per-user cache groups.

📊

Built-in Statistics

Hit/miss ratios, item counts, and memory usage are surfaced out of the box without extra instrumentation.

Driver Use Case Persistence Speed
Files Shared hosting, no extensions needed ✅ Disk ⚡⚡
Redis VPS / managed hosting with Redis ✅ RAM + optional AOF ⚡⚡⚡⚡
Memcached VPS / managed hosting with Memcached ❌ RAM only ⚡⚡⚡⚡
APCu Single-server setups, PHP CLI friendly ❌ RAM only ⚡⚡⚡⚡⚡
SQLite3 Low-traffic sites needing zero infrastructure ✅ Disk ⚡⚡
MongoDB High-volume, distributed environments ✅ Disk ⚡⚡⚡

3 Why Build a Custom Drop-In?

Off-the-shelf solutions like Redis Object Cache are excellent, but building your own teaches
you exactly how the WordPress cache API works, gives you full control over serialisation, TTL
strategies, and group handling, and lets you swap storage backends without touching application code.

It also means you can ship a single Composer-powered plugin that handles:

  • 1The drop-in installer — copies object-cache.php to wp-content/ on activation and removes it cleanly on deactivation.
  • 2Driver configuration — lets admins choose their backend (Files, Redis, APCu…) from a polished settings page rather than editing PHP constants.
  • 3Cache management — provides flush-all, flush-group, and per-key delete actions secured behind proper capability checks.
  • 4Diagnostics — surfaces hit/miss ratios, cache sizes, and connection health so you know your cache is actually working.
  • 5Nginx cache purging — automatically purges the nginx FastCGI/proxy cache when content changes, keeping page caches in sync with object cache flushes.

4 Requirements & Setup

Requirement Minimum Version Notes
PHP 8.1 Required by PHPFastCache 9.x
WordPress 6.4 For modern hook signatures
Composer 2.x Dependency management
PHPFastCache 9.x phpfastcache/phpfastcache
File permissions wp-content/ must be writable by PHP for the Files driver and drop-in installer

Install PHPFastCache via Composer inside your plugin directory:

Shell
cd wp-content/plugins/pfc-object-cache
composer require phpfastcache/phpfastcache:^9.0 --no-dev --optimize-autoloader

5 Plugin File Structure

The plugin uses a layered architecture with three distinct areas of responsibility:


wp-content/plugins/pfc-object-cache/
├── pfc-object-cache.php       ← main plugin bootstrap (activation, admin setup)
├── composer.json
├── composer.lock
├── vendor/                    ← Composer autoloader + PHPFastCache
├── classes/
│   ├── class-cache-engine.php ← static CacheEngine (all cache logic)
│   └── class-nginx-cache-purger.php ← nginx cache invalidation
├── includes/
│   ├── class-pfc-admin-page.php
│   ├── class-pfc-cache-manager.php
│   └── class-pfc-drop-in-installer.php
├── drop-in/
│   └── object-cache.php       ← WP drop-in template (thin wrapper)
├── templates/
│   └── admin-page.php         ← admin UI template
└── css/
    └── admin.css              ← admin stylesheet

wp-content/
├── object-cache.php           ← copied here on plugin activation
└── uploads/pfc-object-cache/
    ├── pfc-config.php         ← auto-generated config file
    └── cache/                 ← Files driver cache directory

💡

Architecture Highlights

  • Static CacheEngine — All cache operations live in a single static class that can be called from both the drop-in and the admin plugin without instantiation overhead.
  • Thin WP_Object_Cache wrapper — The drop-in’s WP_Object_Cache class simply delegates to CacheEngine static methods, keeping the hot path minimal.
  • Config file — Settings are written to pfc-config.php so the drop-in never calls get_option() (which would cause infinite recursion).
  • PSR-4 Namespaces — All classes use the PFC\ObjectCache namespace, loaded via Composer’s classmap autoloader.

⚠️

Critical Note
The object-cache.php drop-in is loaded before any plugins, so it cannot
rely on WordPress functions or the plugin’s own autoloader until after requiring the vendor autoload.
It must be entirely self-contained or reference the plugin’s vendor directory via an absolute path derived from
WP_CONTENT_DIR.

6 The object-cache.php Drop-In

This is the heart of the solution. WordPress will include this file on every request — both frontend
and admin — before plugins load. The drop-in defines the global wp_cache_*() wrapper functions
and a thin WP_Object_Cache class that delegates all actual cache operations to the static
CacheEngine class.

6.1  drop-in/object-cache.php — Global Functions

The drop-in starts by loading the Composer autoloader (which registers both PHPFastCache and our own
PFC\ObjectCache namespace), then defines the WordPress-required global functions:

PHP
<?php
/**
 * PHPFastCache WordPress Object Cache Drop-In.
 *
 * This file is copied to wp-content/object-cache.php by the
 * PFC Object Cache plugin on activation. Do not edit directly.
 *
 * The heavy lifting is handled by PFC\ObjectCache\CacheEngine (static).
 * This file provides the global wp_cache_*() functions and a thin
 * WP_Object_Cache wrapper that WordPress core expects.
 *
 * @package PFC_Object_Cache
 * @version 1.1.0
 */

declare(strict_types=1);

defined( 'ABSPATH' ) || exit;

/**
 * PHPFastCache vendor autoloader.
 * Also registers the PFC\ObjectCache namespace classes via classmap.
 */
$pfc_autoloader = WP_CONTENT_DIR . '/plugins/pfc-object-cache/vendor/autoload.php';

if ( ! file_exists( $pfc_autoloader ) ) {
    // Fall back to WordPress core in-memory cache — do not fatal.
    return;
}

require_once $pfc_autoloader;

use PFC\ObjectCache\CacheEngine;
use PFC\ObjectCache\NginxCachePurger;

// ============================================================
// Global wrapper functions required by WordPress core.
// ============================================================

/**
 * Initialise the global cache object.
 *
 * WordPress calls wp_cache_init() from wp-settings.php.
 */
function wp_cache_init(): void {
    global $wp_object_cache;
    $wp_object_cache = new WP_Object_Cache();
}

/**
 * Closes the cache.
 *
 * Called on 'shutdown' by WordPress.
 */
function wp_cache_close(): bool {
    return true;
}

/**
 * Adds data to the cache if the cache key does not already exist.
 *
 * @param int|string $key    The cache key.
 * @param mixed      $data   The data to add.
 * @param string     $group  Optional. Cache group. Default 'default'.
 * @param int        $expire Optional. TTL in seconds. 0 = no expiration.
 */
function wp_cache_add(
    int|string $key,
    mixed $data,
    string $group = 'default',
    int $expire = 0
): bool {
    global $wp_object_cache;
    return $wp_object_cache->add( $key, $data, $group, $expire );
}

/**
 * Retrieves the cache contents by key and group.
 *
 * @param int|string $key    The cache key.
 * @param string     $group  Optional. Cache group. Default 'default'.
 * @param bool       $force  Optional. Force cache update. Default false.
 * @param bool|null  $found  Optional. Whether the key was found in the cache.
 */
function wp_cache_get(
    int|string $key,
    string $group = 'default',
    bool $force = false,
    ?bool &$found = null
): mixed {
    global $wp_object_cache;
    return $wp_object_cache->get( $key, $group, $force, $found );
}

/**
 * Saves data to the cache.
 */
function wp_cache_set(
    int|string $key,
    mixed $data,
    string $group = 'default',
    int $expire = 0
): bool {
    global $wp_object_cache;
    return $wp_object_cache->set( $key, $data, $group, $expire );
}

/**
 * Replaces the contents of the cache with new data.
 */
function wp_cache_replace(
    int|string $key,
    mixed $data,
    string $group = 'default',
    int $expire = 0
): bool {
    global $wp_object_cache;
    return $wp_object_cache->replace( $key, $data, $group, $expire );
}

/**
 * Removes the cache contents matching key and group.
 */
function wp_cache_delete( int|string $key, string $group = 'default' ): bool {
    global $wp_object_cache;
    return $wp_object_cache->delete( $key, $group );
}

/**
 * Removes all cache items.
 */
function wp_cache_flush(): bool {
    global $wp_object_cache;
    return $wp_object_cache->flush();
}

/**
 * Removes all cache items in a group.
 */
function wp_cache_flush_group( string $group ): bool {
    global $wp_object_cache;
    return $wp_object_cache->flush_group( $group );
}

/**
 * Clears only the in-memory (runtime) cache.
 */
function wp_cache_flush_runtime(): bool {
    global $wp_object_cache;
    return $wp_object_cache->flush_runtime();
}

/**
 * Increments numeric cache item's value.
 */
function wp_cache_incr(
    int|string $key,
    int $offset = 1,
    string $group = 'default'
): int|false {
    global $wp_object_cache;
    return $wp_object_cache->incr( $key, $offset, $group );
}

/**
 * Decrements numeric cache item's value.
 */
function wp_cache_decr(
    int|string $key,
    int $offset = 1,
    string $group = 'default'
): int|false {
    global $wp_object_cache;
    return $wp_object_cache->decr( $key, $offset, $group );
}

/**
 * Adds multiple values to the cache in one call.
 */
function wp_cache_add_multiple(
    array $data,
    string $group = 'default',
    int $expire = 0
): array {
    global $wp_object_cache;
    return $wp_object_cache->add_multiple( $data, $group, $expire );
}

/**
 * Sets multiple values to the cache in one call.
 */
function wp_cache_set_multiple(
    array $data,
    string $group = 'default',
    int $expire = 0
): array {
    global $wp_object_cache;
    return $wp_object_cache->set_multiple( $data, $group, $expire );
}

/**
 * Retrieves multiple values from the cache by their keys.
 */
function wp_cache_get_multiple(
    array $keys,
    string $group = 'default',
    bool $force = false
): array {
    global $wp_object_cache;
    return $wp_object_cache->get_multiple( $keys, $group, $force );
}

/**
 * Deletes multiple values from the cache in one call.
 */
function wp_cache_delete_multiple( array $keys, string $group = 'default' ): array {
    global $wp_object_cache;
    return $wp_object_cache->delete_multiple( $keys, $group );
}

/**
 * Adds a group or set of groups to the list of global groups.
 */
function wp_cache_add_global_groups( string|array $groups ): void {
    global $wp_object_cache;
    $wp_object_cache->add_global_groups( $groups );
}

/**
 * Adds a group or set of groups to the list of non-persistent groups.
 */
function wp_cache_add_non_persistent_groups( string|array $groups ): void {
    global $wp_object_cache;
    $wp_object_cache->add_non_persistent_groups( $groups );
}

/**
 * Switches the internal blog ID (Multisite).
 */
function wp_cache_switch_to_blog( int $blog_id ): void {
    global $wp_object_cache;
    $wp_object_cache->switch_to_blog( $blog_id );
}

/**
 * Determines whether the object cache implementation supports a particular feature.
 */
function wp_cache_supports( string $feature ): bool {
    return match ( $feature ) {
        'add_multiple',
        'set_multiple',
        'get_multiple',
        'delete_multiple',
        'flush_runtime',
        'flush_group' => true,
        default       => false,
    };
}

6.2  drop-in/object-cache.php — WP_Object_Cache Class

The WP_Object_Cache class is intentionally thin — it simply delegates every operation
to the static CacheEngine class and keeps hit/miss counters in sync:

PHP
<?php
// ============================================================
// WP_Object_Cache class — thin wrapper around CacheEngine.
// ============================================================

if ( ! class_exists( 'WP_Object_Cache' ) ) {

    /**
     * WordPress object cache backed by PFC\ObjectCache\CacheEngine.
     *
     * All persistent cache logic lives in CacheEngine (static).
     * This class fulfils the contract WordPress expects from the
     * global $wp_object_cache instance.
     *
     * @package PFC_Object_Cache
     */
    class WP_Object_Cache {
     // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound

        /**
         * Cache hit counter (kept in sync with CacheEngine).
         *
         * @var int
         */
        public int $cache_hits = 0;

        /**
         * Cache miss counter (kept in sync with CacheEngine).
         *
         * @var int
         */
        public int $cache_misses = 0;

        /**
         * Constructor — bootstraps the static CacheEngine.
         */
        public function __construct() {
            CacheEngine::init();
            NginxCachePurger::init( CacheEngine::get_config() );
        }

        /**
         * Copy hit/miss counters from the static engine.
         */
        private function sync_stats(): void {
            $this->cache_hits   = CacheEngine::$cache_hits;
            $this->cache_misses = CacheEngine::$cache_misses;
        }

        // ----------------------------------------------------------
        // CRUD — delegates to CacheEngine
        // ----------------------------------------------------------

        public function add( int|string $key, mixed $data, string $group = 'default', int $expire = 0 ): bool {
            $result = CacheEngine::add( $key, $data, $group, $expire );
            $this->sync_stats();
            return $result;
        }

        public function set( int|string $key, mixed $data, string $group = 'default', int $expire = 0 ): bool {
            $result = CacheEngine::set( $key, $data, $group, $expire );
            $this->sync_stats();
            return $result;
        }

        public function get( int|string $key, string $group = 'default', bool $force = false, ?bool &$found = null ): mixed {
            $result = CacheEngine::get( $key, $group, $force, $found );
            $this->sync_stats();
            return $result;
        }

        public function replace( int|string $key, mixed $data, string $group = 'default', int $expire = 0 ): bool {
            $result = CacheEngine::replace( $key, $data, $group, $expire );
            $this->sync_stats();
            return $result;
        }

        public function delete( int|string $key, string $group = 'default' ): bool {
            $result = CacheEngine::delete( $key, $group );
            $this->sync_stats();
            return $result;
        }

        // ----------------------------------------------------------
        // Batch operations
        // ----------------------------------------------------------

        public function add_multiple( array $data, string $group = 'default', int $expire = 0 ): array {
            $result = CacheEngine::add_multiple( $data, $group, $expire );
            $this->sync_stats();
            return $result;
        }

        public function set_multiple( array $data, string $group = 'default', int $expire = 0 ): array {
            $result = CacheEngine::set_multiple( $data, $group, $expire );
            $this->sync_stats();
            return $result;
        }

        public function get_multiple( array $keys, string $group = 'default', bool $force = false ): array {
            $result = CacheEngine::get_multiple( $keys, $group, $force );
            $this->sync_stats();
            return $result;
        }

        public function delete_multiple( array $keys, string $group = 'default' ): array {
            $result = CacheEngine::delete_multiple( $keys, $group );
            $this->sync_stats();
            return $result;
        }

        // ----------------------------------------------------------
        // Flush
        // ----------------------------------------------------------

        public function flush(): bool {
            return CacheEngine::flush();
        }

        public function flush_group( string $group ): bool {
            return CacheEngine::flush_group( $group );
        }

        public function flush_runtime(): bool {
            return CacheEngine::flush_runtime();
        }

        // ----------------------------------------------------------
        // Increment / Decrement
        // ----------------------------------------------------------

        public function incr( int|string $key, int $offset = 1, string $group = 'default' ): int|false {
            $result = CacheEngine::incr( $key, $offset, $group );
            $this->sync_stats();
            return $result;
        }

        public function decr( int|string $key, int $offset = 1, string $group = 'default' ): int|false {
            $result = CacheEngine::decr( $key, $offset, $group );
            $this->sync_stats();
            return $result;
        }

        // ----------------------------------------------------------
        // Group management
        // ----------------------------------------------------------

        public function add_global_groups( string|array $groups ): void {
            CacheEngine::add_global_groups( $groups );
        }

        public function add_non_persistent_groups( string|array $groups ): void {
            CacheEngine::add_non_persistent_groups( $groups );
        }

        public function switch_to_blog( int $blog_id ): void {
            CacheEngine::switch_to_blog( $blog_id );
        }

        // ----------------------------------------------------------
        // Statistics
        // ----------------------------------------------------------

        /**
         * Return statistics array for the admin dashboard.
         *
         * @return array{hits: int, misses: int, ratio: float, runtime_items: int}
         */
        public function get_stats(): array {
            return CacheEngine::get_stats();
        }
    }

} // end class_exists check

WPCS Compliance
All function and method signatures follow WPCS naming conventions. The class name
WP_Object_Cache is intentionally un-prefixed as WordPress core requires this exact
name — a phpcs:ignore inline comment suppresses the WPCS prefix warning on that line only.

6.5 The CacheEngine Static Class

The CacheEngine class in classes/class-cache-engine.php contains all the actual
cache logic. It’s implemented as a static class for minimal overhead on the hot path — no object instantiation
required. The engine reads its configuration from a PHP file (not get_option()) to avoid
recursion issues when the cache is used by WordPress’s options system.

6.5.1  classes/class-cache-engine.php — Core Implementation

PHP
<?php
/**
 * Static cache engine backed by PHPFastCache.
 *
 * All heavy lifting lives here. The WP_Object_Cache class in the
 * drop-in is a thin wrapper that delegates to these static methods.
 *
 * @package PFC_Object_Cache
 */

declare(strict_types=1);

namespace PFC\ObjectCache;

defined( 'ABSPATH' ) || exit;

use Phpfastcache\CacheManager;
use Phpfastcache\Config\ConfigurationOption;
use Phpfastcache\Exceptions\PhpfastcacheDriverException;

if ( ! class_exists( CacheEngine::class ) ) {

    /**
     * Static cache engine.
     */
    class CacheEngine {

        /**
         * Whether init() has already run.
         */
        private static bool $initialized = false;

        /**
         * PHPFastCache pool instance.
         */
        private static ?\Phpfastcache\Core\Pool\ExtendedCacheItemPoolInterface $driver = null;

        /**
         * In-memory (runtime) cache.
         *
         * @var array<string, mixed>
         */
        private static array $cache = array();

        /**
         * Groups that should not be persisted.
         *
         * @var array<string, true>
         */
        private static array $non_persistent_groups = array();

        /**
         * Groups shared across Multisite sites.
         *
         * @var array<string, true>
         */
        private static array $global_groups = array();

        /**
         * Blog prefix for Multisite key scoping.
         */
        private static int $blog_prefix = 1;

        /**
         * Cache hit counter.
         */
        public static int $cache_hits = 0;

        /**
         * Cache miss counter.
         */
        public static int $cache_misses = 0;

        /**
         * Loaded configuration array.
         */
        private static array $config = array();

        // ----------------------------------------------------------
        // Initialization
        // ----------------------------------------------------------

        /**
         * Initialize the engine. Idempotent.
         */
        public static function init(): void {
            if ( self::$initialized ) {
                return;
            }

            self::$blog_prefix = is_multisite() ? get_current_blog_id() : 1;
            self::$driver      = self::boot_driver();
            self::$initialized = true;
        }

        // ----------------------------------------------------------
        // Driver bootstrap
        // ----------------------------------------------------------

        /**
         * Boot the PHPFastCache driver from the static config file.
         *
         * Reads a PHP config file written by the admin page so we
         * never call get_option() (which would recurse).
         */
        protected static function boot_driver(): \Phpfastcache\Core\Pool\ExtendedCacheItemPoolInterface {
            $config_dir  = defined( 'PFC_CONFIG_DIR' ) ? PFC_CONFIG_DIR : WP_CONTENT_DIR . '/uploads/pfc-object-cache';
            $config_file = $config_dir . '/pfc-config.php';
            $options     = file_exists( $config_file ) ? (array) include $config_file : array();

            self::$config = $options;

            $allowed = array(
                'files'     => 'Files',
                'redis'     => 'Redis',
                'memcached' => 'Memcached',
                'apcu'      => 'Apcu',
                'sqlite3'   => 'Sqlite3',
            );
            $raw     = strtolower( trim( (string) ( $options['driver'] ?? 'Files' ) ) );
            $driver  = $allowed[ $raw ] ?? 'Files';

            $config_array = self::build_driver_config( $driver, $options );

            try {
                CacheManager::setDefaultConfig( new ConfigurationOption( $config_array ) );
                return CacheManager::getInstance( $driver );
            } catch ( PhpfastcacheDriverException $e ) {
                CacheManager::setDefaultConfig(
                    new ConfigurationOption( array( 'path' => self::get_cache_path() ) )
                );
                return CacheManager::getInstance( 'Files' );
            }
        }

        /**
         * Build driver-specific configuration array.
         */
        protected static function build_driver_config( string $driver, array $options ): array {
            $base = array();

            switch ( $driver ) {
                case 'Redis':
                    $base = array(
                        'host'     => (string) ( $options['redis_host'] ?? '127.0.0.1' ),
                        'port'     => (int) ( $options['redis_port'] ?? 6379 ),
                        'password' => (string) ( $options['redis_password'] ?? '' ),
                        'database' => (int) ( $options['redis_database'] ?? 0 ),
                        'timeout'  => (int) ( $options['redis_timeout'] ?? 5 ),
                    );
                    break;

                case 'Memcached':
                    $base = array(
                        'host' => (string) ( $options['memcached_host'] ?? '127.0.0.1' ),
                        'port' => (int) ( $options['memcached_port'] ?? 11211 ),
                    );
                    break;

                case 'Sqlite3':
                case 'Files':
                default:
                    $base = array( 'path' => self::get_cache_path() );
                    break;
            }

            return $base;
        }

        /**
         * Return the filesystem path used for file-based drivers.
         */
        public static function get_cache_path(): string {
            $path = self::$config['cache_path']
                ?? WP_CONTENT_DIR . '/uploads/pfc-object-cache/cache';

            if ( ! is_dir( $path ) ) {
                \wp_mkdir_p( $path );
            }

            return $path;
        }

        // ----------------------------------------------------------
        // Key helpers
        // ----------------------------------------------------------

        /**
         * Build a namespaced cache key.
         *
         * Multisite prefixes non-global groups with the blog ID to prevent
         * cross-site data leakage.
         */
        protected static function build_key( int|string $key, string $group ): string {
            if ( empty( $group ) ) {
                $group = 'default';
            }

            $prefix = isset( self::$global_groups[ $group ] )
                ? 'global'
                : (string) self::$blog_prefix;

            $safe_key   = preg_replace( '/[^a-zA-Z0-9_\-]/', '_', (string) $key );
            $safe_group = preg_replace( '/[^a-zA-Z0-9_\-]/', '_', $group );

            return "{$prefix}_{$safe_group}_{$safe_key}";
        }

        // ----------------------------------------------------------
        // CRUD operations (excerpt)
        // ----------------------------------------------------------

        /**
         * Save data to the cache.
         */
        public static function set( int|string $key, mixed $data, string $group = 'default', int $expire = 0 ): bool {
            $built                 = self::build_key( $key, $group );
            self::$cache[ $built ] = $data;

            if ( self::is_non_persistent( $group ) || null === self::$driver ) {
                return true;
            }

            $item = self::$driver->getItem( $built );
            $item->set( $data );
            $item->addTag( $group ); // Tag for group-based invalidation

            if ( $expire > 0 ) {
                $item->expiresAfter( $expire );
            }

            return self::$driver->save( $item );
        }

        /**
         * Retrieve the cache contents, if it exists.
         */
        public static function get(
            int|string $key,
            string $group = 'default',
            bool $force = false,
            ?bool &$found = null
        ): mixed {
            $built = self::build_key( $key, $group );

            // Check runtime cache first
            if ( ! $force && array_key_exists( $built, self::$cache ) ) {
                $found = true;
                ++self::$cache_hits;
                return self::$cache[ $built ];
            }

            if ( self::is_non_persistent( $group ) || null === self::$driver ) {
                $found = false;
                ++self::$cache_misses;
                return false;
            }

            $item = self::$driver->getItem( $built );

            if ( $item->isHit() ) {
                $found                 = true;
                $value                 = $item->get();
                self::$cache[ $built ] = $value;
                ++self::$cache_hits;
                return $value;
            }

            $found = false;
            ++self::$cache_misses;
            return false;
        }

        /**
         * Clear all data from the cache.
         */
        public static function flush(): bool {
            self::$cache = array();

            $result = null !== self::$driver ? self::$driver->clear() : true;

            // Also purge nginx cache on full flush
            NginxCachePurger::purge_all();

            return $result;
        }

        /**
         * Clear all items in a single group (tag-based invalidation).
         */
        public static function flush_group( string $group ): bool {
            $prefix = self::build_key( '', $group );
            foreach ( array_keys( self::$cache ) as $key ) {
                if ( str_starts_with( $key, $prefix ) ) {
                    unset( self::$cache[ $key ] );
                }
            }

            if ( null === self::$driver ) {
                return true;
            }

            try {
                return self::$driver->deleteItemsByTag( $group );
            } catch ( \Throwable $e ) {
                return false;
            }
        }

        /**
         * Clear only the runtime (in-memory) cache.
         */
        public static function flush_runtime(): bool {
            self::$cache = array();
            return true;
        }

        /**
         * Return statistics array.
         */
        public static function get_stats(): array {
            $total = self::$cache_hits + self::$cache_misses;
            return array(
                'hits'          => self::$cache_hits,
                'misses'        => self::$cache_misses,
                'ratio'         => $total > 0 ? round( ( self::$cache_hits / $total ) * 100, 1 ) : 0.0,
                'runtime_items' => count( self::$cache ),
            );
        }

        /**
         * Return the loaded configuration array.
         */
        public static function get_config(): array {
            return self::$config;
        }
    }
}

🏷️

Tag-based Group Invalidation
Every cache item is tagged with its group name via $item->addTag( $group ). This enables
efficient group-level cache invalidation using PHPFastCache’s native deleteItemsByTag()
method — no need to iterate over keys.

7 The WordPress Admin Plugin

The main plugin file registers the standalone admin menu, handles activation/deactivation, loads
the admin classes, and bootstraps the nginx cache purging hooks. All classes use the
PFC\ObjectCache namespace and are registered via Composer’s classmap autoloader.

7.1  pfc-object-cache.php — Plugin Bootstrap

PHP
<?php
/**
 * Plugin Name:       PFC Object Cache
 * Plugin URI:        https://0-day-analytics.com/
 * Description:       Persistent WordPress object cache drop-in powered by PHPFastCache. Supports Redis, Memcached, APCu, Files and more.
 * Version:           1.0.0
 * Author:            Stoil Dobreff
 * Author URI:        https://0-day-analytics.com
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       pfc-object-cache
 * Domain Path:       /languages
 * Requires PHP:      8.1
 * Requires at least: 6.4
 *
 * @package PFC_Object_Cache
 */

declare(strict_types=1);

defined( 'ABSPATH' ) || exit;

// ── Constants ─────────────────────────────────────────────────────────────────

define( 'PFC_PLUGIN_VERSION', '1.0.0' );
define( 'PFC_PLUGIN_FILE', __FILE__ );
define( 'PFC_PLUGIN_DIR', \plugin_dir_path( __FILE__ ) );
define( 'PFC_PLUGIN_URL', \plugin_dir_url( __FILE__ ) );
define( 'PFC_DROP_IN_SOURCE', PFC_PLUGIN_DIR . 'drop-in/object-cache.php' );
define( 'PFC_DROP_IN_DEST', WP_CONTENT_DIR . '/object-cache.php' );

// ── Autoloader ────────────────────────────────────────────────────────────────

$pfc_autoloader = PFC_PLUGIN_DIR . 'vendor/autoload.php';
if ( ! file_exists( $pfc_autoloader ) ) {
    \add_action(
        'admin_notices',
        static function (): void {
            printf(
                '<div class="notice notice-error">%s
</div>',
                \esc_html__( 'PFC Object Cache: Composer dependencies are missing. Run `composer install` inside the plugin directory.', 'pfc-object-cache' )
            );
        }
    );
    return;
}
require_once $pfc_autoloader;

use PFC\ObjectCache\DropInInstaller;
use PFC\ObjectCache\AdminPage;
use PFC\ObjectCache\NginxCachePurger;

// ── Activation / Deactivation ─────────────────────────────────────────────────

\register_activation_hook(
    PFC_PLUGIN_FILE,
    array( DropInInstaller::class, 'install' )
);

\register_deactivation_hook(
    PFC_PLUGIN_FILE,
    array( DropInInstaller::class, 'uninstall' )
);

// ── Bootstrap ─────────────────────────────────────────────────────────────────

\add_action(
    'plugins_loaded',
    static function (): void {
        if ( \is_admin() ) {
            AdminPage::register();
        }

        // Bootstrap nginx auto-purge hooks (runs on both admin and front-end).
        $settings = (array) \get_option( AdminPage::OPTION_KEY, array() );
        NginxCachePurger::init( $settings );
        NginxCachePurger::register_hooks();
    },
    10
);

7.2  includes/class-pfc-drop-in-installer.php

The installer now also writes the config file so the drop-in can boot without calling get_option():

PHP
<?php
/**
 * Manages copying and removing the object-cache.php drop-in.
 *
 * @package PFC_Object_Cache
 */

declare(strict_types=1);

namespace PFC\ObjectCache;

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( DropInInstaller::class ) ) {

    /**
     * Handles drop-in file lifecycle.
     */
    class DropInInstaller {

        /**
         * Copies the drop-in template to wp-content/object-cache.php.
         *
         * Called on plugin activation. No direct output — uses WP_Filesystem.
         */
        public static function install(): void {
            if ( ! \current_user_can( 'activate_plugins' ) ) {
                return;
            }

            if ( ! function_exists( 'WP_Filesystem' ) ) {
                require_once ABSPATH . 'wp-admin/includes/file.php';
            }

            \WP_Filesystem();
            global $wp_filesystem;

            if ( ! $wp_filesystem ) {
                return;
            }

            // Do not overwrite a drop-in installed by another plugin.
            if ( $wp_filesystem->exists( PFC_DROP_IN_DEST ) ) {
                $existing = $wp_filesystem->get_contents( PFC_DROP_IN_DEST );
                if ( false === strpos( $existing, 'PHPFastCache WordPress Object Cache Drop-In' ) ) {
                    // Foreign drop-in present — bail out safely.
                    \add_action(
                        'admin_notices',
                        static function (): void {
                            printf(
                                '<div class="notice notice-warning">%s
</div>',
                                \esc_html__( 'PFC Object Cache: An existing object-cache.php drop-in was found and left untouched. Deactivate the conflicting plugin first.', 'pfc-object-cache' )
                            );
                        }
                    );
                    return;
                }
            }

            $wp_filesystem->copy( PFC_DROP_IN_SOURCE, PFC_DROP_IN_DEST, true );
            \update_option( 'pfc_drop_in_installed', true, false );

            // Write initial config file so the drop-in can boot without get_option().
            $settings = (array) \get_option( AdminPage::OPTION_KEY, array() );
            AdminPage::write_config_file( $settings );
        }

        /**
         * Removes the drop-in from wp-content/ on plugin deactivation.
         */
        public static function uninstall(): void {
            if ( ! \current_user_can( 'activate_plugins' ) ) {
                return;
            }

            if ( ! function_exists( 'WP_Filesystem' ) ) {
                require_once ABSPATH . 'wp-admin/includes/file.php';
            }

            \WP_Filesystem();
            global $wp_filesystem;

            if ( $wp_filesystem && $wp_filesystem->exists( PFC_DROP_IN_DEST ) ) {
                $contents = $wp_filesystem->get_contents( PFC_DROP_IN_DEST );
                // Only remove the drop-in if it belongs to this plugin.
                if ( false !== strpos( $contents, 'PHPFastCache WordPress Object Cache Drop-In' ) ) {
                    $wp_filesystem->delete( PFC_DROP_IN_DEST );
                }
            }

            \delete_option( 'pfc_drop_in_installed' );

            // Remove the config directory used by the drop-in.
            $config_dir = AdminPage::get_config_dir();
            if ( $wp_filesystem && $wp_filesystem->exists( $config_dir ) ) {
                $wp_filesystem->delete( $config_dir, true );
            }
        }
    }
}

7.3  includes/class-pfc-cache-manager.php

The cache manager now includes nginx purge functionality:

PHP
<?php
/**
 * Provides cache management actions (flush, diagnostics).
 *
 * @package PFC_Object_Cache
 */

declare(strict_types=1);

namespace PFC\ObjectCache;

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( CacheManager::class ) ) {

    /**
     * Cache management utilities consumed by the admin UI.
     */
    class CacheManager {

        /**
         * Flushes the entire object cache.
         *
         * Requires manage_options capability and a valid nonce.
         *
         * @return array{success: bool, message: string}
         */
        public static function flush_all(): array {
            if ( ! \current_user_can( 'manage_options' ) ) {
                return array(
                    'success' => false,
                    'message' => \esc_html__( 'Permission denied.', 'pfc-object-cache' ),
                );
            }

            $flushed = \wp_cache_flush();

            // Also purge nginx cache on full flush.
            NginxCachePurger::purge_all();

            return array(
                'success' => $flushed,
                'message' => $flushed
                    ? \esc_html__( 'Object cache flushed successfully.', 'pfc-object-cache' )
                    : \esc_html__( 'Cache flush failed. Check server logs for details.', 'pfc-object-cache' ),
            );
        }

        /**
         * Flushes all items belonging to a single cache group.
         *
         * @param  string $group Cache group name.
         * @return array{success: bool, message: string}
         */
        public static function flush_group( string $group ): array {
            if ( ! \current_user_can( 'manage_options' ) ) {
                return array(
                    'success' => false,
                    'message' => \esc_html__( 'Permission denied.', 'pfc-object-cache' ),
                );
            }

            $group   = \sanitize_key( $group );
            $flushed = \wp_cache_flush_group( $group );

            return array(
                'success' => $flushed,
                /* translators: %s: cache group name */
                'message' => $flushed
                    ? sprintf( \esc_html__( 'Cache group "%s" flushed.', 'pfc-object-cache' ), $group )
                    : sprintf( \esc_html__( 'Failed to flush group "%s".', 'pfc-object-cache' ), $group ),
            );
        }

        /**
         * Returns runtime statistics from the global cache object.
         *
         * @return array{hits: int, misses: int, ratio: float, runtime_items: int, drop_in_active: bool}
         */
        public static function get_stats(): array {
            global $wp_object_cache;

            $base = array(
                'hits'           => 0,
                'misses'         => 0,
                'ratio'          => 0.0,
                'runtime_items'  => 0,
                'drop_in_active' => false,
            );

            if ( $wp_object_cache instanceof \WP_Object_Cache
            && method_exists( $wp_object_cache, 'get_stats' )
            ) {
                return array_merge(
                    $base,
                    $wp_object_cache->get_stats(),
                    array( 'drop_in_active' => true )
                );
            }

            return $base;
        }

        /**
         * Purges the nginx cache directory.
         *
         * @return array{success: bool, message: string}
         */
        public static function purge_nginx(): array {
            if ( ! \current_user_can( 'manage_options' ) ) {
                return array(
                    'success' => false,
                    'message' => \esc_html__( 'Permission denied.', 'pfc-object-cache' ),
                );
            }

            if ( ! NginxCachePurger::is_enabled() ) {
                return array(
                    'success' => false,
                    'message' => \esc_html__( 'Nginx cache purging is not enabled. Configure a cache path in settings.', 'pfc-object-cache' ),
                );
            }

            $purged = NginxCachePurger::purge_all();

            return array(
                'success' => $purged,
                'message' => $purged
                    ? \esc_html__( 'Nginx cache purged successfully.', 'pfc-object-cache' )
                    : \esc_html__( 'Nginx cache purge failed. Check the cache path and permissions.', 'pfc-object-cache' ),
            );
        }
    }
}

7.5 Nginx FastCGI Cache Purger

One of the most powerful features of the plugin is the ability to automatically purge nginx’s FastCGI
or proxy cache when content changes in WordPress. The NginxCachePurger class handles both
full cache directory purging and targeted per-URL purge requests.

7.5.1  classes/class-nginx-cache-purger.php

PHP
<?php
/**
 * Nginx FastCGI / proxy cache purger.
 *
 * Recursively deletes the contents of the configured nginx cache
 * directory when the object cache is flushed.
 *
 * @package PFC_Object_Cache
 */

declare(strict_types=1);

namespace PFC\ObjectCache;

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( NginxCachePurger::class ) ) {

    /**
     * Handles nginx cache purging.
     */
    class NginxCachePurger {

        /**
         * Configured nginx cache directory.
         */
        private static string $cache_path = '';

        /**
         * Whether purging is enabled.
         */
        private static bool $enabled = false;

        /**
         * Server URL to send PURGE requests to (e.g. http://127.0.0.1:80).
         */
        private static string $purge_server_url = '';

        /**
         * Whether auto-purge on content changes is enabled.
         */
        private static bool $auto_purge = false;

        // ----------------------------------------------------------
        // Setup
        // ----------------------------------------------------------

        /**
         * Initialise from the plugin config array.
         *
         * @param  array $config Plugin config (from CacheEngine::get_config()).
         */
        public static function init( array $config = array() ): void {
            self::$enabled          = ! empty( $config['nginx_purge_enabled'] );
            self::$cache_path       = trim( (string) ( $config['nginx_cache_path'] ?? '' ) );
            self::$purge_server_url = trim( (string) ( $config['nginx_purge_server_url'] ?? '' ) );
            self::$auto_purge       = ! empty( $config['nginx_auto_purge'] );
        }

        /**
         * Whether nginx purging is enabled and the path is configured.
         */
        public static function is_enabled(): bool {
            return self::$enabled && '' !== self::$cache_path;
        }

        /**
         * Whether auto-purge on content changes is active.
         */
        public static function is_auto_purge_enabled(): bool {
            return self::$enabled && self::$auto_purge && '' !== self::$purge_server_url;
        }

        // ----------------------------------------------------------
        // Auto-purge WordPress hooks
        // ----------------------------------------------------------

        /**
         * Registers WordPress hooks for automatic cache purging.
         */
        public static function register_hooks(): void {
            if ( ! self::is_auto_purge_enabled() ) {
                return;
            }

            // Post create / update / delete.
            \add_action( 'save_post', array( __CLASS__, 'on_post_changed' ), 10, 1 );
            \add_action( 'delete_post', array( __CLASS__, 'on_post_changed' ), 10, 1 );
            \add_action( 'trash_post', array( __CLASS__, 'on_post_changed' ), 10, 1 );

            // Comment changes.
            \add_action( 'comment_post', array( __CLASS__, 'on_comment_changed' ), 10, 1 );
            \add_action( 'edit_comment', array( __CLASS__, 'on_comment_changed' ), 10, 1 );
            \add_action( 'delete_comment', array( __CLASS__, 'on_comment_changed' ), 10, 1 );
            \add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_changed' ), 10, 1 );

            // Term (category / tag) changes.
            \add_action( 'edited_term', array( __CLASS__, 'on_term_changed' ), 10, 3 );
            \add_action( 'delete_term', array( __CLASS__, 'on_term_changed' ), 10, 3 );
        }

        /**
         * Callback when a post is created, updated, trashed, or deleted.
         *
         * @param int $post_id Post ID.
         */
        public static function on_post_changed( int $post_id ): void {
            if ( \wp_is_post_revision( $post_id ) || \wp_is_post_autosave( $post_id ) ) {
                return;
            }

            $post = \get_post( $post_id );
            if ( ! $post || ! \is_post_type_viewable( $post->post_type ) ) {
                return;
            }

            $urls = self::get_purge_urls_for_post( $post_id );
            self::purge_urls( $urls );
        }

        /**
         * Collects all URLs that should be purged when a post changes.
         *
         * @param  int $post_id Post ID.
         * @return string[] Array of full URLs to purge.
         */
        private static function get_purge_urls_for_post( int $post_id ): array {
            $urls = array();

            $permalink = \get_permalink( $post_id );
            if ( $permalink ) {
                $urls[] = $permalink;
            }

            // Home / front page.
            $urls[] = \home_url( '/' );

            // Post type archive.
            $post_type = \get_post_type( $post_id );
            if ( $post_type ) {
                $archive_link = \get_post_type_archive_link( $post_type );
                if ( $archive_link ) {
                    $urls[] = $archive_link;
                }
            }

            // Category and tag archives for this post.
            foreach ( array( 'category', 'post_tag' ) as $tax ) {
                $terms = \get_the_terms( $post_id, $tax );
                if ( $terms && ! \is_wp_error( $terms ) ) {
                    foreach ( $terms as $term ) {
                        $term_link = \get_term_link( $term );
                        if ( ! \is_wp_error( $term_link ) ) {
                            $urls[] = $term_link;
                        }
                    }
                }
            }

            // Feed URLs.
            $urls[] = \get_feed_link();

            return array_unique( array_filter( $urls ) );
        }

        /**
         * Send a PURGE request for a public URL via the configured purge server.
         *
         * Uses the nginx fastcgi_cache_purge /purge/ location prefix pattern:
         * a GET request to server/purge/original-path with the original Host
         * header.
         *
         * @param  string $public_url The original public URL to purge.
         * @return bool
         */
        public static function purge_url_via_server( string $public_url ): bool {
            if ( ! self::is_auto_purge_enabled() || empty( $public_url ) ) {
                return false;
            }

            $parsed = \wp_parse_url( $public_url );
            if ( empty( $parsed['host'] ) ) {
                return false;
            }

            $original_host   = $parsed['host'];
            $original_scheme = $parsed['scheme'] ?? 'https';
            $path            = $parsed['path'] ?? '/';
            $query           = ! empty( $parsed['query'] ) ? '?' . $parsed['query'] : '';

            // Extract host + port from the configured server URL.
            $server_parsed = \wp_parse_url( self::$purge_server_url );
            $server_host   = $server_parsed['host'] ?? '127.0.0.1';
            $server_port   = isset( $server_parsed['port'] ) ? ':' . $server_parsed['port'] : '';

            $purge_url = $original_scheme . '://' . $server_host . $server_port . '/purge/' . ltrim( $path, '/' ) . $query;
            $purge_url = \esc_url_raw( $purge_url );

            $response = \wp_remote_get(
                $purge_url,
                array(
                    'timeout'   => 5,
                    'sslverify' => false,
                    'headers'   => array(
                        'Host' => $original_host,
                    ),
                )
            );

            if ( \is_wp_error( $response ) ) {
                return false;
            }

            $code = \wp_remote_retrieve_response_code( $response );
            return $code >= 200 && $code < 300;
        }

        // ----------------------------------------------------------
        // Purge operations
        // ----------------------------------------------------------

        /**
         * Purge the entire nginx cache directory.
         *
         * @return bool True on success, false when disabled or on failure.
         */
        public static function purge_all(): bool {
            if ( ! self::is_enabled() ) {
                return false;
            }

            $path = realpath( self::$cache_path );

            if ( false === $path || ! is_dir( $path ) ) {
                return false;
            }

            // Reject symlinks to prevent symlink-based path traversal.
            if ( is_link( self::$cache_path ) ) {
                return false;
            }

            if ( ! self::is_safe_path( $path ) ) {
                return false;
            }

            return self::recursive_delete_contents( $path );
        }

        /**
         * Safety check to prevent accidental deletion of critical directories.
         *
         * @param  string $path Resolved absolute path.
         * @return bool
         */
        private static function is_safe_path( string $path ): bool {
            $dangerous = array(
                '/', '/etc', '/var', '/usr', '/home', '/root', '/tmp',
                '/bin', '/sbin', '/lib', '/sys', '/proc', '/dev',
                '/boot', '/opt', '/srv', '/run', '/mnt', '/media',
            );

            if ( in_array( $path, $dangerous, true ) ) {
                return false;
            }

            // Reject paths shorter than 8 chars to require meaningful depth.
            if ( strlen( $path ) < 8 ) {
                return false;
            }

            // Must be at least 3 levels deep (e.g. /var/run/cache).
            if ( substr_count( trim( $path, '/' ), '/' ) < 2 ) {
                return false;
            }

            return true;
        }
    }
}

⚠️

Security Considerations
The nginx cache purger includes multiple safety checks:

  • Rejects symlinks to prevent symlink-based path traversal attacks
  • Validates that the path is at least 3 directories deep
  • Blocks dangerous system directories like /, /etc, /var
  • Requires a minimum path length of 8 characters

8 Admin UI & Cache Manager

The admin page registers a top-level menu entry (not a submenu), renders a statistics dashboard,
provides cache-clear buttons, and handles settings — all within a single class that follows WP
best practices for output escaping and nonce verification.

WordPress Admin — PFC Object Cache
98.4%
Hit Ratio
2,841
Cache Hits
46
Misses
Files
Active Driver


8.1  includes/class-pfc-admin-page.php

The admin page now uses static methods, a namespace, and includes nginx cache settings management:

PHP
<?php
/**
 * Standalone WordPress admin page for PFC Object Cache.
 *
 * Registers a top-level menu, handles form submissions with nonce
 * verification, and renders the full settings + diagnostics UI.
 *
 * @package PFC_Object_Cache
 */

declare(strict_types=1);

namespace PFC\ObjectCache;

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( AdminPage::class ) ) {
    /**
     * Admin page controller.
     */
    class AdminPage {

        /**
         * Admin page hook suffix returned by add_menu_page().
         */
        private static string $page_hook = '';

        /**
         * Admin page slug.
         */
        const PAGE_SLUG = 'pfc-object-cache';

        /**
         * Option key for plugin settings.
         */
        const OPTION_KEY = 'pfc_cache_settings';

        /**
         * Nonce action for flush operations.
         */
        const NONCE_FLUSH = 'pfc_flush_cache';

        /**
         * Nonce action for settings save.
         */
        const NONCE_SETTINGS = 'pfc_save_settings';

        /**
         * Register WP hooks.
         */
        public static function register(): void {
            \add_action( 'admin_menu', array( __CLASS__, 'add_menu' ) );
            \add_action( 'admin_init', array( __CLASS__, 'handle_actions' ) );
            \add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_styles' ) );
        }

        // ── Menu ──────────────────────────────────────────────────────────────────

        /**
         * Registers the top-level admin menu item.
         */
        public static function add_menu(): void {
            self::$page_hook = \add_menu_page(
                \esc_html__( 'Object Cache', 'pfc-object-cache' ),
                \esc_html__( 'Object Cache', 'pfc-object-cache' ),
                'manage_options',
                self::PAGE_SLUG,
                array( __CLASS__, 'render_page' ),
                'dashicons-database',
                80
            );
        }

        // ── Styles ────────────────────────────────────────────────────────────────

        /**
         * Enqueues styles for the admin page.
         */
        public static function enqueue_styles( string $hook ): void {
            if ( $hook !== self::$page_hook ) {
                return;
            }

            \wp_enqueue_style(
                'pfc-admin',
                PFC_PLUGIN_URL . 'css/admin.css',
                array(),
                PFC_PLUGIN_VERSION
            );
        }

        // ── Actions ───────────────────────────────────────────────────────────────

        /**
         * Handles POST actions (flush, settings save).
         */
        public static function handle_actions(): void {
            if ( ! \is_admin() || ! \current_user_can( 'manage_options' ) ) {
                return;
            }

            // phpcs:disable WordPress.Security.NonceVerification.Missing
            $action = \sanitize_key( $_POST['pfc_action'] ?? '' );
            // phpcs:enable

            if ( empty( $action ) ) {
                return;
            }

            switch ( $action ) {
                case 'flush_all':
                    \check_admin_referer( self::NONCE_FLUSH );
                    $result = CacheManager::flush_all();
                    self::redirect_with_notice(
                        $result['success'] ? 'flushed' : 'flush_failed',
                        $result['message']
                    );
                    break;

                case 'flush_group':
                    \check_admin_referer( self::NONCE_FLUSH );
                    // phpcs:ignore WordPress.Security.NonceVerification.Missing
                    $group  = \sanitize_key( $_POST['pfc_group'] ?? 'default' );
                    $result = CacheManager::flush_group( $group );
                    self::redirect_with_notice(
                        $result['success'] ? 'flushed' : 'flush_failed',
                        $result['message']
                    );
                    break;

                case 'save_settings':
                    \check_admin_referer( self::NONCE_SETTINGS );
                    self::save_settings();
                    self::redirect_with_notice(
                        'settings_saved',
                        \esc_html__( 'Settings saved.', 'pfc-object-cache' )
                    );
                    break;

                case 'purge_nginx':
                    \check_admin_referer( self::NONCE_FLUSH );
                    $result = CacheManager::purge_nginx();
                    self::redirect_with_notice(
                        $result['success'] ? 'flushed' : 'flush_failed',
                        $result['message']
                    );
                    break;
            }
        }

        /**
         * Persists the settings form to wp_options and writes the config file.
         */
        protected static function save_settings(): void {
            // phpcs:disable WordPress.Security.NonceVerification.Missing
            $raw = (array) ( $_POST['pfc_settings'] ?? array() );
            // phpcs:enable

            $sanitized = array(
                'driver'                 => \sanitize_key( $raw['driver'] ?? 'Files' ),
                'redis_host'             => \sanitize_text_field( $raw['redis_host'] ?? '127.0.0.1' ),
                'redis_port'             => \absint( $raw['redis_port'] ?? 6379 ),
                'redis_password'         => \sanitize_text_field( $raw['redis_password'] ?? '' ),
                'redis_database'         => \absint( $raw['redis_database'] ?? 0 ),
                'redis_timeout'          => \absint( $raw['redis_timeout'] ?? 5 ),
                'memcached_host'         => \sanitize_text_field( $raw['memcached_host'] ?? '127.0.0.1' ),
                'memcached_port'         => \absint( $raw['memcached_port'] ?? 11211 ),
                'nginx_purge_enabled'    => ! empty( $raw['nginx_purge_enabled'] ),
                'nginx_cache_path'       => \sanitize_text_field( $raw['nginx_cache_path'] ?? '' ),
                'nginx_purge_server_url' => \esc_url_raw( $raw['nginx_purge_server_url'] ?? '' ),
                'nginx_auto_purge'       => ! empty( $raw['nginx_auto_purge'] ),
            );

            \update_option( self::OPTION_KEY, $sanitized, false );
            self::write_config_file( $sanitized );
        }

        /**
         * Writes settings to a static PHP config file for the drop-in.
         *
         * @param  array $settings Sanitized settings array.
         * @return bool
         */
        public static function write_config_file( array $settings ): bool {
            $dir  = self::get_config_dir();
            $path = $dir . '/pfc-config.php';

            self::maybe_create_config_dir( $dir );

            // Include cache_path so the drop-in uses the uploads directory.
            $upload_dir             = \wp_upload_dir( null, false );
            $settings['cache_path'] = $upload_dir['basedir'] . '/pfc-object-cache/cache';

            $export = var_export( $settings, true );

            $content  = "<?php\n";
            $content .= "/**\n * PFC Object Cache — auto-generated config.\n * @generated\n */\n";
            $content .= "defined( 'ABSPATH' ) || exit;\n\n";
            $content .= "return {$export};\n";

            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
            return false !== file_put_contents( $path, $content, LOCK_EX );
        }

        /**
         * Returns the directory path used for the config file.
         */
        public static function get_config_dir(): string {
            if ( defined( 'PFC_CONFIG_DIR' ) ) {
                return PFC_CONFIG_DIR;
            }

            $upload_dir = \wp_upload_dir( null, false );
            return $upload_dir['basedir'] . '/pfc-object-cache';
        }
    }
}

8.2  templates/admin-page.php — The UI Template

The admin template includes forms for flushing the object cache, flushing individual groups,
purging the nginx cache, and configuring the cache driver and nginx settings. All output is
escaped with the appropriate WP escaping function.

PHP
<?php
/**
 * Admin page template for PFC Object Cache.
 *
 * Variables provided by AdminPage::render_page():
 *
 * @var array  $stats    Cache statistics.
 * @var array  $settings Saved plugin settings.
 * @var string $status   Redirect status slug.
 * @var string $message  Redirect notice message.
 * @var string $driver   Active driver slug.
 *
 * @package PFC_Object_Cache
 */

declare(strict_types=1);

use PFC\ObjectCache\AdminPage;

defined( 'ABSPATH' ) || exit;
?>
<div class="wrap pfc-admin-wrap">

  <!-- Page Header -->
  <div class="pfc-page-header">
    <div class="pfc-page-header__left">
      <span class="dashicons dashicons-database pfc-header-icon"></span>
      <div>
        <h1 class="pfc-page-title"><?php \esc_html_e( 'PFC Object Cache', 'pfc-object-cache' ); ?></h1>
        
          <?php
          printf(
            \esc_html__( 'Persistent object cache powered by PHPFastCache — Active driver: %s', 'pfc-object-cache' ),
            '<strong class="pfc-driver-badge pfc-driver-badge--' . \esc_attr( strtolower( $driver ) ) . '">'
              . \esc_html( $driver ) . '</strong>'
          );
          ?>
        

      </div>
    </div>
  </div>

  <!-- Stats Cards -->
  <div class="pfc-stats-grid">
    <div class="pfc-stat-card">
      <div class="pfc-stat-card__value pfc-stat-card__value--cyan">
        <?php echo \esc_html( $stats['ratio'] ); ?>%
      </div>
      <div class="pfc-stat-card__label"><?php \esc_html_e( 'Hit Ratio', 'pfc-object-cache' ); ?></div>
    </div>
    <!-- ... more stat cards ... -->
  </div>

  <div class="pfc-two-col">
    <!-- Left: Cache Management -->
    <div class="pfc-panel">
      <h2 class="pfc-panel__title">
        <span class="dashicons dashicons-trash"></span>
        <?php \esc_html_e( 'Cache Management', 'pfc-object-cache' ); ?>
      </h2>

      <!-- Flush All -->
      <form method="post" action="">
        <?php \wp_nonce_field( AdminPage::NONCE_FLUSH ); ?>
        <input type="hidden" name="pfc_action" value="flush_all" />
        <button type="submit" class="button pfc-btn pfc-btn--danger">
          <?php \esc_html_e( 'Flush All Caches', 'pfc-object-cache' ); ?>
        </button>
      </form>

      <hr class="pfc-divider" />

      <!-- Flush Group -->
      <form method="post" action="">
        <?php \wp_nonce_field( AdminPage::NONCE_FLUSH ); ?>
        <input type="hidden" name="pfc_action" value="flush_group" />
        <input type="text" name="pfc_group" class="regular-text pfc-input" />
        <button type="submit" class="button pfc-btn pfc-btn--secondary">
          <?php \esc_html_e( 'Flush Group', 'pfc-object-cache' ); ?>
        </button>
      </form>

      <hr class="pfc-divider" />

      <!-- Purge Nginx Cache -->
      <form method="post" action="">
        <?php \wp_nonce_field( AdminPage::NONCE_FLUSH ); ?>
        <input type="hidden" name="pfc_action" value="purge_nginx" />
        <button type="submit" class="button pfc-btn pfc-btn--secondary">
          <?php \esc_html_e( 'Purge Nginx Cache', 'pfc-object-cache' ); ?>
        </button>
      </form>
    </div>

    <!-- Right: Driver Settings -->
    <div class="pfc-panel">
      <h2 class="pfc-panel__title">
        <?php \esc_html_e( 'Driver Configuration', 'pfc-object-cache' ); ?>
      </h2>

      <form method="post" action="">
        <?php \wp_nonce_field( AdminPage::NONCE_SETTINGS ); ?>
        <input type="hidden" name="pfc_action" value="save_settings" />

        <!-- Driver selector -->
        <select name="pfc_settings[driver]" class="pfc-select">
          <?php foreach ( array( 'Files', 'Redis', 'Memcached', 'Apcu', 'Sqlite3' ) as $d ) : ?>
            <option value="<?php echo \esc_attr( $d ); ?>" <?php \selected( $settings['driver'] ?? 'Files', $d ); ?>>
              <?php echo \esc_html( $d ); ?>
            </option>
          <?php endforeach; ?>
        </select>

        <!-- Redis fields (shown/hidden via JS) -->
        <div class="pfc-driver-fields pfc-driver-fields--redis">
          <input type="text" name="pfc_settings[redis_host]"
            value="<?php echo \esc_attr( $settings['redis_host'] ?? '127.0.0.1' ); ?>" />
          <input type="number" name="pfc_settings[redis_port]"
            value="<?php echo \esc_attr( $settings['redis_port'] ?? '6379' ); ?>" />
          <!-- ... more redis fields ... -->
        </div>

        <!-- Nginx cache purge settings -->
        <div class="pfc-form-group">
          <h3><?php \esc_html_e( 'Nginx Cache Purge', 'pfc-object-cache' ); ?></h3>

          <label>
            <input type="checkbox" name="pfc_settings[nginx_purge_enabled]" value="1"
              <?php \checked( ! empty( $settings['nginx_purge_enabled'] ) ); ?> />
            <?php \esc_html_e( 'Enable automatic nginx cache purge on full flush', 'pfc-object-cache' ); ?>
          </label>

          <label><?php \esc_html_e( 'Nginx Cache Path', 'pfc-object-cache' ); ?></label>
          <input type="text" name="pfc_settings[nginx_cache_path]"
            value="<?php echo \esc_attr( $settings['nginx_cache_path'] ?? '' ); ?>"
            placeholder="/var/run/nginx-cache" />

          <label><?php \esc_html_e( 'Purge Server URL', 'pfc-object-cache' ); ?></label>
          <input type="url" name="pfc_settings[nginx_purge_server_url]"
            value="<?php echo \esc_attr( $settings['nginx_purge_server_url'] ?? '' ); ?>"
            placeholder="http://127.0.0.1:80" />

          <label>
            <input type="checkbox" name="pfc_settings[nginx_auto_purge]" value="1"
              <?php \checked( ! empty( $settings['nginx_auto_purge'] ) ); ?> />
            <?php \esc_html_e( 'Auto-purge nginx cache when content is updated', 'pfc-object-cache' ); ?>
          </label>
        </div>

        <button type="submit" class="button button-primary">
          <?php \esc_html_e( 'Save Settings', 'pfc-object-cache' ); ?>
        </button>
      </form>
    </div>
  </div>
</div>

8.3  css/admin.css — Admin Stylesheet

The stylesheet is located at css/admin.css (not assets/css/):

CSS
/* =============================================================
   PFC Object Cache — Admin Stylesheet
   Static classes only; no dynamic class names.
   ============================================================= */

/* ── Layout ─────────────────────────────────────────────────── */
.pfc-admin-wrap {
  max-width: 1100px;
}

/* ── Page header ─────────────────────────────────────────────── */
.pfc-page-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 20px 0 16px;
  border-bottom: 1px solid #e0e0e0;
  margin-bottom: 24px;
}

.pfc-page-header__left {
  display: flex;
  align-items: center;
  gap: 14px;
}

.pfc-header-icon {
  font-size: 36px !important;
  color: #2271b1;
}

.pfc-page-title {
  font-size: 22px !important;
  font-weight: 700 !important;
  margin: 0 0 4px !important;
}

.pfc-page-subtitle {
  color: #666;
  font-size: 13px;
  margin: 0;
}

.pfc-version-badge {
  background: #2271b1;
  color: #fff;
  padding: 3px 10px;
  border-radius: 20px;
  font-size: 12px;
  font-weight: 600;
}

/* ── Driver badge ────────────────────────────────────────────── */
.pfc-driver-badge {
  display: inline-block;
  padding: 1px 8px;
  border-radius: 3px;
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .04em;
  vertical-align: middle;
}

.pfc-driver-badge--redis     { background: #ff4d6a22; color: #c0392b; border: 1px solid #c0392b44; }
.pfc-driver-badge--files     { background: #00c8e022; color: #2271b1; border: 1px solid #2271b144; }
.pfc-driver-badge--memcached { background: #ff8c4222; color: #d35400; border: 1px solid #d3540044; }
.pfc-driver-badge--apcu      { background: #00e09a22; color: #27ae60; border: 1px solid #27ae6044; }
.pfc-driver-badge--sqlite3   { background: #9b7fe822; color: #8e44ad; border: 1px solid #8e44ad44; }

/* ── Stats grid ──────────────────────────────────────────────── */
.pfc-stats-grid {
  display: grid;
  grid-template-columns: repeat( auto-fill, minmax( 200px, 1fr ) );
  gap: 16px;
  margin-bottom: 28px;
}

.pfc-stat-card {
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 20px 24px;
  text-align: center;
  box-shadow: 0 1px 3px rgba(0,0,0,.06);
  transition: box-shadow .15s;
}

.pfc-stat-card:hover {
  box-shadow: 0 4px 12px rgba(0,0,0,.1);
}

.pfc-stat-card__value {
  font-size: 32px;
  font-weight: 800;
  line-height: 1;
  margin-bottom: 6px;
}

.pfc-stat-card__value--cyan   { color: #0095b3; }
.pfc-stat-card__value--green  { color: #27ae60; }
.pfc-stat-card__value--orange { color: #d35400; }
.pfc-stat-card__value--purple { color: #8e44ad; }

.pfc-stat-card__label {
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: #999;
}

/* ── Two-column panels ───────────────────────────────────────── */
.pfc-two-col {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}

@media ( max-width: 900px ) {
  .pfc-two-col {
    grid-template-columns: 1fr;
  }
}

.pfc-panel {
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 24px;
  box-shadow: 0 1px 3px rgba(0,0,0,.06);
}

.pfc-panel__title {
  font-size: 14px !important;
  font-weight: 700 !important;
  margin: 0 0 18px !important;
  display: flex;
  align-items: center;
  gap: 8px;
  color: #1d2327;
}

.pfc-panel__title .dashicons {
  color: #2271b1;
}

/* ── Forms ───────────────────────────────────────────────────── */
.pfc-divider {
  border: none;
  border-top: 1px solid #f0f0f0;
  margin: 20px 0;
}

.pfc-form-group {
  margin-bottom: 16px;
}

.pfc-form-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 10px;
  margin-bottom: 8px;
}

.pfc-label {
  display: block;
  font-weight: 600;
  font-size: 13px;
  margin-bottom: 5px;
  color: #1d2327;
}

.pfc-input {
  border-radius: 4px !important;
}

.pfc-select {
  border-radius: 4px;
  border: 1px solid #8c8f94;
  padding: 4px 8px;
  min-width: 180px;
}

.pfc-form-description {
  color: #777;
  font-size: 12px;
  margin: 6px 0 0;
  font-style: italic;
}

.pfc-fields-heading {
  font-size: 13px !important;
  font-weight: 600 !important;
  color: #50575e;
  margin: 12px 0 8px !important;
}

.pfc-notice-text {
  display: flex;
  align-items: baseline;
  gap: 6px;
  font-size: 12px;
  color: #666;
  margin-top: 12px;
  background: #f6f7f7;
  border-left: 3px solid #2271b1;
  padding: 8px 12px;
  border-radius: 0 4px 4px 0;
}

/* ── Buttons ─────────────────────────────────────────────────── */
.pfc-btn {
  display: inline-flex !important;
  align-items: center;
  gap: 6px;
}

.pfc-btn--primary {
  background: #2271b1 !important;
  border-color: #2271b1 !important;
  color: #fff !important;
}

.pfc-btn--danger {
  background: #d63638 !important;
  border-color: #d63638 !important;
  color: #fff !important;
}

.pfc-btn--danger:hover {
  background: #b32d2e !important;
  border-color: #b32d2e !important;
}

.pfc-btn--secondary {
  background: #2271b1 !important;
  border-color: #2271b1 !important;
  color: #fff !important;
}

/* ── Notices ─────────────────────────────────────────────────── */
.pfc-notice {
  margin-top: 0;
  margin-bottom: 16px;
}

9 Security & Capability Checks

Because this plugin touches a critical WordPress infrastructure file and can purge server-level caches,
security is non-negotiable. Here is a summary of every security measure implemented:

Threat Vector Mitigation Where
Unauthorised cache flush current_user_can('manage_options') check + check_admin_referer() nonce CacheManager + AdminPage::handle_actions()
Arbitrary file write Drop-in installer uses WP_Filesystem API; only writes to WP_CONTENT_DIR; will not overwrite a foreign drop-in DropInInstaller
Nginx cache path traversal Path validated: rejects symlinks, dangerous directories (/, /etc, /var, etc.), requires minimum depth of 3 directories NginxCachePurger::is_safe_path()
Config file exposure Config directory protected with .htaccess Deny from all and index.php silencer AdminPage::maybe_create_config_dir()
XSS in admin output All output escaped with esc_html(), esc_attr(), esc_url(), esc_js() admin-page.php
Settings injection All $_POST values sanitised with sanitize_key(), sanitize_text_field(), absint(), esc_url_raw() before update_option() AdminPage::save_settings()
Cache key collision Keys namespaced with blog ID prefix; non-ASCII chars replaced with underscores CacheEngine::build_key()
Cross-site data leakage (Multisite) Non-global groups prefixed with blog_prefix; global groups share a “global” prefix CacheEngine::build_key()
Direct file access defined('ABSPATH') || exit; at the top of every PHP file All files
Redis password exposure Password field uses type="password" and autocomplete="new-password" admin-page.php
Object cache recursion Drop-in reads config from PHP file, not get_option(), preventing infinite recursion CacheEngine::boot_driver()

🚨

Important
Never store Redis or Memcached passwords in a public repository. Use environment variables or
wp-config.php constants (outside the webroot) for production credentials. The plugin
stores settings in wp_options, which is readable by any administrator — treat it
accordingly.

10 Testing & Debugging

Verifying that your drop-in is actually being used is straightforward. Add this snippet to a
temporary mu-plugin file and check the output in wp-admin/site-health.php
or via WP-CLI:

PHP
<?php
/**
 * MU-Plugin: PFC Object Cache smoke test.
 * Remove after verification.
 */

add_action(
    'admin_notices',
    static function (): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

        // Write a test value.
        wp_cache_set( 'pfc_test_key', 'pfc_test_value', 'pfc_smoke_test', 30 );

        $found = null;
        $value = wp_cache_get( 'pfc_test_key', 'pfc_smoke_test', false, $found );

        if ( $found && 'pfc_test_value' === $value ) {
            echo '<div class="notice notice-success"><strong>PFC Object Cache:</strong> Drop-in is active and working correctly. ✅
</div>';
        } else {
            echo '<div class="notice notice-error"><strong>PFC Object Cache:</strong> Cache get/set test failed. Check your driver configuration. ❌
</div>';
        }

        wp_cache_delete( 'pfc_test_key', 'pfc_smoke_test' );
    }
);

You can also verify via WP-CLI:

Shell
# Check if the drop-in is registered.
wp cache type

# Expected output if drop-in is active:
# Default (Persistent) Object Cache: PHPFastCache / Files

# Manual flush via CLI.
wp cache flush

11 Cache Driver Comparison

Choosing the right driver depends entirely on your infrastructure. Use this table as a quick
reference when configuring the plugin for your environment:

Driver Best For Persistence Requires TTL Support Multi-server
Files Shared hosting, zero infrastructure ✅ Disk Writable wp-content/cache/ ❌ (local disk)
Redis VPS, cloud, high-traffic sites ✅ Optional AOF/RDB Redis server + phpredis or predis ✅ (shared)
Memcached High-traffic, cache-only (no persistence needed) ❌ RAM only Memcached server + php-memcached ✅ (shared)
APCu Single-server, max speed ❌ RAM only PHP apcu extension ❌ (per-process)
SQLite3 Low-traffic, no Redis/Memcached available ✅ Disk PHP pdo_sqlite extension ❌ (local file)
MongoDB Existing Mongo infrastructure, large datasets ✅ Disk MongoDB + mongodb PHP extension ✅ (replica set)

🏆

Recommendation
For most managed WordPress hosting environments, start with the Files driver
to validate the integration, then graduate to Redis for any site handling more
than a few hundred concurrent users. Redis delivers dramatically lower latency than disk-based
caching and is available on most quality hosting plans.

12 Conclusion & Next Steps

You now have a complete, production-ready WordPress object cache drop-in backed by PHPFastCache,
wrapped inside a well-structured plugin with a polished standalone admin interface. The architecture
is deliberately layered: the drop-in’s thin WP_Object_Cache wrapper handles the hot path
with minimal overhead, delegating to the static CacheEngine class, while the admin plugin
safely manages configuration and cache operations behind WordPress’s own capability and nonce systems.

New in this version:

  • Namespaced architecture with PFC\ObjectCache namespace and Composer classmap autoloading
  • Static CacheEngine class for minimal instantiation overhead on the hot path
  • Nginx FastCGI/proxy cache purging with automatic invalidation on content changes
  • Config file system that eliminates get_option() recursion issues
  • Support for wp_cache_flush_runtime() and wp_cache_add_multiple()
  • Enhanced security with path traversal protection for nginx cache purging
📦

Add Composer Scripts

Add a post-install-cmd script that copies the drop-in automatically after composer install in CI/CD pipelines.

🔗

PHPFastCache Tagging

The plugin now tags each cache item with its group name, enabling efficient group-based invalidation via deleteItemsByTag().

🧪

PHPUnit Integration Tests

Add WP-CLI’s wp-phpunit scaffold and write integration tests that verify get/set/delete/flush across all configured drivers.

📈

Query Monitor Integration

Hook into Query Monitor’s collector API to surface per-request cache hit/miss ratios directly in your developer toolbar.

📖

Source Code
The complete source code for this plugin, including composer.json, example
configuration, and all the features described in this article, is available on GitHub at
github.com/sdobreff/pfc-object-cache.
Contributions and driver-specific improvements are welcome via pull request.

Comments (0)

← Building Your Own WP Security Scanner - Complete Guide Why Out-of-the-Box WordPress Is Completely Insecure →
Share this page
Back to top