idct / php-rapid-cache-client
High-performance Redis/Valkey-backed PSR-16 cache client for PHP, extended with tag-based invalidation, queues, sets, sorted sets, and atomic counters.
Requires
- php: ^8.2
- ext-igbinary: *
- ext-redis: *
- psr/simple-cache: ^3.0
Requires (Dev)
- behat/behat: ^3.31
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.32
- php-mock/php-mock: ^2.7
- php-mock/php-mock-phpunit: ^2.15
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.5
Provides
README
IDCT Rapid Cache Client is a high-performance Redis-backed caching library
for PHP. At its core it is a clean PSR-16 (SimpleCache)
implementation, so it drops straight into any framework or library that speaks
the standard cache contract. On top of that baseline it adds the features real
applications keep reaching for but PSR-16 leaves out: tag-based grouping and
invalidation, FIFO queues, sets, sorted sets, and atomic counters - all
exposed through the CacheServiceInterface contract.
Speed comes from two deliberate choices: it talks to Redis (or any
Redis-compatible server such as Valkey) through the
native ext-redis C extension, and it serializes values with the compact
binary ext-igbinary serializer so arbitrary PHP values - objects, nested
arrays, DateTime, … - round-trip losslessly and cheaply. Bulk operations are
pipelined and chunked, connections are established lazily and re-established
transparently, and every Redis-level error is translated into a PSR-16
exception so your calling code stays backend-agnostic.
Quick example
<?php use IDCT\Cache\RapidCacheClient; // host, port, optional key prefix $cache = new RapidCacheClient('localhost', 6379, 'myapp:'); // Store any serializable value, optionally with a TTL (seconds or DateInterval) $cache->set('user.123', ['name' => 'John Doe', 'roles' => ['admin']], 3600); // Read it back (returns the supplied default on a miss) $user = $cache->get('user.123', $default = null); // Group entries under a tag, then invalidate the whole group at once $cache->setTagged('user.123', $user, 'active-users'); $cache->clearByTag('active-users');
Features
- PSR-16 SimpleCache - drop-in compatible with any PSR-16 consumer
(implements
Psr\SimpleCache\CacheInterface). - Core cache operations -
get,set,delete,clear,has, plus the multi-key variantsgetMultiple,setMultiple,deleteMultiple. - Flexible TTLs -
intseconds or\DateInterval; a non-positive TTL deletes the entry, per the spec. - Lossless serialization - igbinary handles objects, nested arrays,
DateTime, etc., automatically. - Tagging system - associate keys with tags (
setTagged,tag,untag) and read or invalidate them in bulk (getTagged,clearByTag,getTagCardinality). - Queues - Redis lists used as FIFO queues:
enqueue,pop,peek,getQueue,getQueueLength. - Sets - unique collections:
createSet,addToSet,removeFromSet,getSet,getCardinality. - Sorted sets - ordered, value-resolving iteration via
getSorted. - Atomic counters -
increase/decreasebacked by RedisINCRBY/DECRBY. - Key namespacing - an optional prefix isolates your keys on a shared
Redis instance;
clear()is prefix-scoped and never touches other apps' data. - Resilient connections - lazy connect, transparent reconnect, optional retry-once on transient errors, and PSR-16 exception translation.
- Tunable performance - configurable pipeline batch size, persistent
connections, and finite connect/read timeouts via
RedisConnectionConfig.
Sponsorship ❤️
This project is maintained on the side and looking for sponsors to keep the modernization moving forward. If your team relies on it, please consider chipping in - every contribution helps keep this library alive:
Thank you to everyone who already supports the project! 🙏
Contents
- Quick example
- Features
- Sponsorship ❤️
- Requirements
- Benchmarks
- Installation
- Testing
- Usage
- Contributing
- License
- Thank you
Requirements
- PHP 8.2 or higher
ext-redis(phpredis) - built with igbinary supportext-igbinary- A Redis server (6.0+) or any Redis-compatible server such as Valkey (the test suite runs against Valkey 7.2)
Benchmarks
RapidCacheClient ships with a self-benchmark that measures the throughput of
its own operations, grouped into Core, Tagging and Counters. It is
not a comparison against another library - it answers "how many of each
operation does RapidCache sustain per second on this host". The chart is
regenerated by CI and published to the assets branch:
Each bar is one operation, in operations per second (higher is better), on a scale local to its category. The useful signal is the shape, not the absolute height:
- Single-key operations (
set,get,setTagged,increase, …) each cost one network round-trip, so they cluster together - that floor is the round-trip latency to your Redis, not RapidCache's own overhead. - Pipelined / bulk operations are far faster because they amortise
round-trips:
setMultiple/getMultiplechunk many keys per call, andgetTagged/clearByTagresolve an entire tag withSMEMBERS+ a singleMGETor batched delete. This is the throughput path - prefer the multi-key and tag-bulk APIs in hot loops.
Absolute numbers depend entirely on the runner hardware and the network path to Redis - treat the chart as the relative cost of single round-trips vs. pipelined work, not a guaranteed throughput figure.
Reproduce locally from the benchmark/ directory (see its
README for all options):
cd benchmark composer install make benchmark-quick # 10k items, writes report.html + benchmark.svg
Installation
Install the package with Composer:
composer require idct/php-rapid-cache-client
Make sure the required PHP extensions are present:
# Ubuntu/Debian sudo apt-get install php-redis php-igbinary # CentOS/RHEL sudo yum install php-redis php-igbinary # Or via PECL (answer "yes" to enable igbinary support when building redis) pecl install redis igbinary
Verify they are loaded:
php -m | grep -E '(redis|igbinary)' php --ri redis | grep -i igbinary # should report igbinary support => enabled
Testing
The library ships with a PHPUnit unit suite (100% line/method coverage) and a Behat functional suite that runs against a real Valkey container. The Composer scripts start and stop the container for you via Docker Compose, so you only need Docker installed - not a local Redis.
composer install composer test # full gate: install + unit + functional composer test:unit # PHPUnit unit tests (with coverage) composer test:bdd # Behat functional tests composer test:unit-no-coverage # faster unit run while iterating
Quality tooling:
composer analyse # PHPStan (level 8) composer fix # php-cs-fixer (PSR-12 + Symfony rules) composer test:mutation # Infection mutation testing
Managing the Valkey container by hand (exposed on host port 6380):
composer redis:start # docker compose up -d valkey composer redis:stop # docker compose down composer test:connection composer clean # down -v + docker system prune
Developing the library itself? See HUMANS.md for a full contributor guide and AGENTS.md for the rules AI agents follow.
Usage
Connecting & configuration
The simplest form takes a host, port, and optional key prefix:
use IDCT\Cache\RapidCacheClient; $cache = new RapidCacheClient('localhost', 6379); // A prefix is strongly recommended on a shared Redis instance - it namespaces // every key and scopes clear() so it never deletes other apps' data. $cache = new RapidCacheClient('localhost', 6379, 'myapp:');
For full control (authentication, database selection, timeouts, persistent
connections, retry behavior, pipeline batch size) pass a
RedisConnectionConfig. It is an immutable value object whose defaults are
tuned for safe production behavior (a finite 1s connect timeout, non-persistent
connections):
use IDCT\Cache\RapidCacheClient; use IDCT\Cache\RedisConnectionConfig; $config = new RedisConnectionConfig( host: 'cache.internal', port: 6379, prefix: 'myapp:', password: 's3cr3t', // null/empty → no AUTH database: 1, // SELECT this DB (0 = none) connectTimeout: 1.0, // seconds; phpredis default of 0 means "wait forever" readTimeout: 1.0, // seconds; only applied when > 0 persistent: true, // reuse the connection across requests (pconnect) persistentId: 'pool1', // pool id for persistent connections retryOnce: true, // retry a failed op once on a transient RedisException pipelineBatchSize: 1000, // max commands per pipelined/multi-key batch ); $cache = new RapidCacheClient($config);
No socket is opened during construction - the connection is established lazily on the first cache operation and re-established automatically if it drops.
Basic cache operations (PSR-16)
// Store a value (returns bool) $cache->set('user.123', ['name' => 'John Doe', 'email' => 'john@example.com']); // Retrieve a value ($default is returned only on a true miss) $user = $cache->get('user.123'); $user = $cache->get('user.123', $defaultValue); // Store with a TTL - int seconds or a DateInterval $cache->set('session.abc', $sessionData, 3600); $cache->set('session.abc', $sessionData, new DateInterval('PT1H')); // Check existence (a fast probe - not a guarantee against a concurrent expiry) if ($cache->has('user.123')) { // ... } // Delete a single key / clear the (prefix-scoped) cache $cache->delete('user.123'); $cache->clear();
PSR-16 key rules - keys must be non-empty strings; the characters
{}()/\@:are reserved and rejected with aPsr\SimpleCache\InvalidArgumentException. A TTL of0(or negative) deletes the entry immediately, per the spec.
Bulk operations
Multi-key operations are pipelined and automatically chunked by the configured
pipelineBatchSize, keeping request size bounded on large inputs:
// Store many at once (single MSET, or a pipelined SETEX batch when a TTL is given) $cache->setMultiple(['k1' => 'v1', 'k2' => 'v2'], 60); // Read many at once (single MGET); missing keys map to the default $values = $cache->getMultiple(['k1', 'k2', 'k3'], 'fallback'); // Delete many at once (with tag cleanup per key) $cache->deleteMultiple(['k1', 'k2']);
Tagging
Tags group related entries so you can read or invalidate them as a unit. Since
PSR-16 set() does not take a tag, use setTagged() or call tag() after a
plain set():
// Store and tag in one atomic call (optionally with a TTL) $cache->setTagged('user.123', $userData, 'active-users'); $cache->setTagged('user.456', $otherUser, 'active-users', 3600); // Or set first, tag later (the key must already exist) $cache->set('post.789', $postData); $cache->tag('post.789', 'posts'); // Iterate every value currently under a tag (key => value) foreach ($cache->getTagged('active-users') as $key => $value) { echo "$key => " . json_encode($value) . PHP_EOL; } // Remove a single tag association (the value itself is kept) $cache->untag('user.123', 'active-users'); // Count, then bulk-invalidate everything under a tag $count = $cache->getTagCardinality('active-users'); $cache->clearByTag('active-users');
Queues
Redis lists used as FIFO queues:
// Append items to the tail $cache->enqueue('email-queue', ['to' => 'user@example.com', 'subject' => 'Welcome']); $cache->enqueue('email-queue', ['to' => 'admin@example.com', 'subject' => 'New user']); // Pop items from the head (FIFO). Returns null when empty. while ($email = $cache->pop('email-queue')) { echo 'Sending to: ' . $email['to'] . PHP_EOL; } // Pop several at once $batch = $cache->pop('email-queue', 10); // array of up to 10 items, or null // Inspect without removing $next = $cache->peek('email-queue'); // head item, or null $firstFive = $cache->peek('email-queue', 5); // Length, or the full contents (head-first; O(N)) $length = $cache->getQueueLength('email-queue'); $all = $cache->getQueue('email-queue');
enqueue()rejectsnullvalues: phpredis cannot tell a storednullapart from "queue is empty" on pop.
Sets
Unique, unordered collections:
// Replace the whole set with an exact membership (DEL + SADD) $cache->createSet('user-roles:123', ['admin', 'editor', 'viewer']); // Incremental changes $cache->addToSet('user-roles:123', 'moderator'); $cache->removeFromSet('user-roles:123', 'viewer'); // Read all members (null when the set does not exist) $roles = $cache->getSet('user-roles:123'); // Member count $count = $cache->getCardinality('user-roles:123');
Sorted sets
getSorted() treats a Redis sorted set as an ordered index of cache keys: it
reads a window of members and resolves each member's cached value, pruning any
member whose underlying key has expired. Use reversed: true for
highest-score-first ordering.
// Top 10 of a leaderboard (highest score first), as member => cachedValue foreach ($cache->getSorted('leaderboard', count: 10, offset: 0, reversed: true) as $member => $value) { echo "$member => " . json_encode($value) . PHP_EOL; } // Count of a sorted set (pass true so ZCARD is used instead of SCARD) $players = $cache->getCardinality('leaderboard', sortedSet: true);
Atomic counters
Backed by Redis INCRBY / DECRBY (the key is auto-created at 0):
$cache->set('page-views', 0); $cache->increase('page-views', 1); $cache->decrease('page-views', 1); $views = $cache->get('page-views');
Error handling
Argument-validation problems are thrown as
IDCT\Cache\Exception\InvalidArgumentException, and storage/transport failures
as IDCT\Cache\Exception\CacheException. Both implement the matching PSR-16
marker interfaces, so PSR-16 consumers can catch them through the standard
contract and stay backend-agnostic:
use Psr\SimpleCache\InvalidArgumentException; use Psr\SimpleCache\CacheException; try { $cache->get('illegal:key'); // reserved character → InvalidArgumentException } catch (InvalidArgumentException $e) { // bad input on our side } catch (CacheException $e) { // the cache backend failed (the original RedisException is the chained cause) }
Behavior notes
clear()is prefix-scoped. With a prefix configured it usesSCAN+UNLINKto remove only keys under that prefix; with no prefix it falls back toFLUSHDB(current database only). It never callsFLUSHALL, so it will not destroy unrelated data on a shared Redis instance.get()distinguishes a storedfalsefrom a miss. Because the igbinary serializer makes a stored literalfalseindistinguishable from a missing key at the protocol level,get()adds anEXISTSprobe: it returns the storedfalsewhen the key exists, and the$defaultonly on a true miss.getMultiple()does not disambiguate. For throughput it issues a singleMGETwith no per-keyEXISTSprobe, so both a missing key and a storedfalsemap to the default. Useget()per key when that distinction matters.- Tags read the current value. Tagged-key membership is stored in a Redis
SETatTAG:<tag>; member values are resolved at read time viaMGET, sogetTagged()always returns each key's current value - an overwrite viaset()aftersetTagged()is reflected immediately. - Self-healing reads.
getTagged()andgetSorted()prune entries whose underlying key has expired as a side effect of iteration. Breaking out of the generator early leaves the un-inspected entries for the next call.
Contributing
Contributions are welcome! In short:
- Fork the repository and create a feature branch
(
git checkout -b feature/your-feature). - Make your change with tests and documentation. The project keeps 100% unit-test coverage, passes PHPStan level 8, follows PSR-12 + strict types, and is mutation-tested - new branches need matching tests.
- Run the full gate before opening a PR:
composer test # unit + functional composer analyse # PHPStan composer fix # php-cs-fixer
- Open a Pull Request against
mainwith a clear description, and call out any breaking changes explicitly.
When reporting an issue, please include your PHP version, Redis/Valkey version, the library version, a minimal reproduction, and the full error and stack trace.
Full contributor documentation lives in HUMANS.md; the conventions AI agents must follow are in AGENTS.md.
- Issues: GitHub Issues
- Discussions: GitHub Discussions
License
This project is licensed under the MIT License. See the LICENSE file for details.
Thank you
Thank you for using and supporting IDCT Rapid Cache Client - whether by filing issues, opening pull requests, spreading the word, or sponsoring the project. Every bit of help keeps the modernization moving and the library alive. 🙏