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.
The package ships two clients that share the same PSR-16 + tagging surface but differ in how values land in Redis:
RapidCacheClient— the general-purpose client. Stores any serializable PHP value (objects, nested arrays,DateTime, …) as a single Redis STRING using the compact binaryext-igbinaryserializer. Adds queues, sets, sorted sets, and whole-value counters on top.HashRapidCacheClient— purpose-built for flat associative arrays of scalars. Each value is stored as a native Redis HASH so individual fields can be read, written, or atomically incremented without round-tripping the whole record (HGET/HSET/HINCRBY). No serializer — the wire format is plain Redis hash fields.
Speed comes from two deliberate choices: both clients talk to Redis (or any
Redis-compatible server such as Valkey) through the
native ext-redis C extension, and the string client serializes values with
ext-igbinary so arbitrary PHP values 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');
…and the same surface as a Redis HASH when the value is a flat record and you want per-field reads / writes / atomic counters:
use IDCT\Cache\HashRapidCacheClient; $hash = new HashRapidCacheClient('localhost', 6379, 'myapp:'); // Whole hash in one round-trip $hash->set('user.123', ['name' => 'John Doe', 'visits' => 0, 'plan' => 'pro'], 3600); // Single-field operations - no need to fetch the whole record $plan = $hash->getField('user.123', 'plan'); // HGET $hash->setField('user.123', 'last_seen', '2026-05-28'); // HSET $visits = $hash->incrementField('user.123', 'visits', 1); // HINCRBY - atomic
Features
Shared by both clients:
- 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. - Tagging system - associate keys with tags (
setTagged,tag,untag) and read or invalidate them in bulk (getTagged,clearByTag,getTagCardinality). Each client uses its own tag-index namespace so the two can coexist on the same Redis instance without colliding. - 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.
Specific to RapidCacheClient:
- Lossless serialization - igbinary handles objects, nested arrays,
DateTime, etc., automatically. - 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.
Specific to HashRapidCacheClient:
- Native Redis HASH storage - each value is a flat associative array of scalars stored field-for-field as a Redis HASH; no serializer, no whole-value round-trips for a single-field read.
- Single- and multi-field operations -
getField/setField/hasField/deleteField(HGET/HSET/HEXISTS/HDEL) and the bulk formsgetFields/setFields/deleteFields(HMGET/HMSET/HDEL). - Atomic field counters -
incrementField/decrementFieldbacked by RedisHINCRBY/HINCRBYFLOAToperate in place on individual fields.
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
The package ships with a self-benchmark that measures the throughput of both clients' operations against the same Valkey/Redis instance. There is no cross-library comparison - it answers "how many of each operation does each client sustain per second on this host". Results are grouped into six categories:
RapidCacheClient→ Core, Tagging, CountersHashRapidCacheClient→ Hash Core, Hash Fields, Hash Tagging, Hash Counters
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,getField, …) 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. - The hash client's field-level operations (
getField,setField,incrementField, …) cost the same single round-trip as a whole-valueget/setbut move only the affected field over the wire - the win is bandwidth and contention on big records, not raw ops/sec.
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');
Hash storage (HashRapidCacheClient)
Use HashRapidCacheClient when each cached value is a flat associative
array of scalars (a record) and you want to read, write, or atomically
modify individual fields without ever round-tripping the whole record. Each
value lives in Redis as a native HASH, indexed by field name — there is no
serializer involved, so wire payloads are plain Redis hash fields.
use IDCT\Cache\HashRapidCacheClient; $hash = new HashRapidCacheClient('localhost', 6379, 'myapp:'); // PSR-16 surface works as expected; values must be a non-empty flat // associative array of string/int/float scalars. $hash->set('user.123', [ 'name' => 'John Doe', 'plan' => 'pro', 'score' => 1000, ], 3600); // Whole-record read returns the hash as a flat array $user = $hash->get('user.123'); // ['name' => 'John Doe', …]
Single-field operations don't move the rest of the record across the wire:
$plan = $hash->getField('user.123', 'plan'); // HGET $hash->setField('user.123', 'last_seen', '2026-05-28'); // HSET $hash->deleteField('user.123', 'score'); // HDEL if ($hash->hasField('user.123', 'plan')) { … } // HEXISTS
Multi-field reads/writes:
// Read several fields at once $slice = $hash->getFields('user.123', ['name', 'plan']); // HMGET // ['name' => 'John Doe', 'plan' => 'pro'] // Merge fields into the existing record (does NOT clear unrelated fields) $hash->setFields('user.123', ['score' => 2000, 'plan' => 'enterprise']); // Remove several fields at once $hash->deleteFields('user.123', ['score', 'last_seen']); // HDEL …
Atomic counters operate in place on a single field, so multiple processes can hit the same counter without a read-modify-write cycle:
$views = $hash->incrementField('page.views', 'count', 1); // HINCRBY $saved = $hash->incrementField('account.42', 'balance', 9.99); // HINCRBYFLOAT $hash->decrementField('inventory.sku-7', 'stock', 1);
Tagging mirrors the string client and uses a separate index namespace
(H_TAG: / H_TAGS:) so both clients can share the same Redis prefix
without colliding:
$hash->setTagged('user.123', $userRecord, 'active-users', 3600); foreach ($hash->getTagged('active-users') as $key => $record) { … } $hash->clearByTag('active-users');
What you give up vs. RapidCacheClient:
- No type fidelity beyond scalars.
bool,null, nested arrays, objects, resources are rejected atset()withPsr\SimpleCache\InvalidArgumentException. UseRapidCacheClient(with igbinary) when the cached value isn't a flat record. - No per-field TTL. The whole hash expires as a unit via
PEXPIRE. Set the TTL onset(); subsequentsetField()calls leave the existing TTL in place. - Queues / sets / sorted sets are not exposed — those live on
RapidCacheClientand can be used side-by-side on the same connection.
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. 🙏