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.
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.
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.phptowp-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:
cd wp-content/plugins/pfc-object-cache
composer require phpfastcache/phpfastcache:^9.0 --no-dev --optimize-autoloader5 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_Cacheclass simply delegates toCacheEnginestatic methods, keeping the hot path minimal. - Config file — Settings are written to
pfc-config.phpso the drop-in never callsget_option()(which would cause infinite recursion). - PSR-4 Namespaces — All classes use the
PFC\ObjectCachenamespace, loaded via Composer’s classmap autoloader.
The
object-cache.php drop-in is loaded before any plugins, so it cannotrely 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
/**
* 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
// ============================================================
// 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
All function and method signatures follow WPCS naming conventions. The class name
WP_Object_Cache is intentionally un-prefixed as WordPress core requires this exactname — 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
/**
* 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;
}
}
}
Every cache item is tagged with its group name via
$item->addTag( $group ). This enablesefficient 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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;
}
}
}
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.
8.1 includes/class-pfc-admin-page.php
The admin page now uses static methods, a namespace, and includes nginx cache settings management:
<?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
/**
* 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/):
/* =============================================================
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() |
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 pluginstores settings in
wp_options, which is readable by any administrator — treat itaccordingly.
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
/**
* 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:
# 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 flush11 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) |
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\ObjectCachenamespace and Composer classmap autoloading - Static
CacheEngineclass 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()andwp_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.
The complete source code for this plugin, including
composer.json, exampleconfiguration, 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)
Join the conversation. to leave a comment.