Lazy proxy for php-redis with DX helpers, utilities and a unified API.
Zenstruck\Redis
is a unified proxy for \Redis|\RedisArray|\RedisCluster
. With
a few exceptions and considerations, the API is the same no matter the underlying
client. This allows you to use the same API in development, where you are likely
just using \Redis
, and production, where you could be using \RedisArray
or
\RedisCluster
.
The proxy is lazy in that, if created via a DSN, doesn't instantiate the underlying client until a command is executed.
This library integrates well with Symfony and a recipe is available.
composer require zenstruck/redis
Creating a Redis client instance is done via a DSN string. The DSN must use the following format:
redis[s]://[pass@][ip|host|socket[:port]][/db-index][?{option}={value}...]
It is recommended to use the proxy whenever possible. It has the following benefits over using the real client:
- Lazy: a connection is not established until a Redis command is actually called.
- Encapsulated: for the most part, knowledge of the real client is not required. You don't need to change your usage depending on the client used. There are some exceptions to this.
- Developer Experience (DX): use the fluent sequence and transaction api.
Here are some examples creating the proxy from a DSN.
use Zenstruck\Redis;
$proxy = Redis::create('redis://localhost'); // Zenstruck\Redis<\Redis>
$proxy = Redis::create('redis://localhost?redis_sentinel=sentinel_service'); // Zenstruck\Redis<\Redis> (using Redis Sentinel)
$proxy = Redis::create('redis:?host[host1]&host[host2]'); // Zenstruck\Redis<\RedisArray>
$proxy = Redis::create('redis:?host[host1]&host[host2]&redis_cluster=1'); // Zenstruck\Redis<\RedisCluster>
You can also create a Proxy from an exising instance of \Redis|\RedisArray|\RedisCluster
:
use Zenstruck\Redis;
/** @var \Redis|\RedisArray|\RedisCluster $client */
$proxy = Redis::wrap($client)
An instance of \Redis|\RedisArray|\RedisCluster
can be created directly:
use Zenstruck\Redis;
$client = Redis::createClient('redis://localhost'); // \Redis
$client = Redis::createClient('redis://localhost?redis_sentinel=sentinel_service'); // \Redis (using Redis Sentinel)
$client = Redis::createClient('redis:?host[host1]&host[host2]'); // \RedisArray
$client = Redis::createClient('redis:?host[host1]&host[host2]&redis_cluster=1'); // \RedisCluster
Certain Redis options can be set via your DSN's query parameters or passed
as an array to the second parameter of Zenstruck\Redis::create/createClient()
.
You can set a prefix for all keys:
use Zenstruck\Redis;
$proxy = Redis::create('redis://localhost?prefix=app:');
$proxy = Redis::create('redis://localhost', ['prefix' => 'app:']); // equivalent to above
By default, Redis stores all scalar/null values as strings and objects/arrays as "Array"/"Object". In order to store properly typed values and objects/arrays, you must configure a Redis serializer:
use Zenstruck\Redis;
// PHP: serialize/unserialize values
$proxy = Redis::create('redis://localhost?serializer=php');
$proxy = Redis::create('redis://localhost', ['serializer' => \Redis::SERIALIZER_PHP]); // equivalent to above
// JSON: json_encode/json_decode values (doesn't work for objects)
$proxy = Redis::create('redis://localhost?serializer=json');
$proxy = Redis::create('redis://localhost', ['serializer' => \Redis::SERIALIZER_JSON]); // equivalent to above
NOTE: There is a performance trade off when using Redis serialization. Consider creating a separate client for operations/logic that requires serialization.
/** @var Zenstruck\Redis $proxy */
// call any \Redis|\RedisArray|\RedisCluster method
$proxy->set('mykey', 'value');
$proxy->get('mykey'); // "value"
// get the "real" client
$proxy->realClient(); // \Redis|\RedisArray|\RedisCluster
The proxy has a fluent, auto-completable API for Redis pipelines and transactions:
/** @var Zenstruck\Redis $proxy */
// use \Redis::multi()
$results = $proxy->transaction()
->set('x', '42')
->incr('x')
->get('x')->as('value') // alias the result of this command
->del('x')
->execute() // the results of the above transaction as an array (keyed by index of command or alias if set)
;
$results['value']; // "43" (result of ->get())
$results[3]; // true (result of ->del())
// use \Redis::pipeline() - see note below about \RedisCluster
$proxy->sequence()
->set('x', '42')
->incr('x')
->get('x')->as('value') // alias the result of this command
->del('x')
->execute() // the results of the above sequence as an array (keyed by index of command of alias if set)
;
$results['value']; // "43" (result of ->get())
$results[3]; // true (result of ->del())
NOTE: When using sequence()
with \RedisCluster
, the commands are executed
atomically as pipelines are not supported.
NOTE: When using sequence()
/transaction()
with a \RedisArray
instance, the
first command in the sequence/transaction must be a "key-based command"
(ie get()
/set()
). This is to choose the node the transaction is run on.
Zenstruck\Redis
is countable and iterable. There are some differences when
counting/iterating depending on the underlying client:
\Redis
: count is always 1 and iterates over itself once\RedisArray
: count is the number of hosts and iterates over each host wrapped in a proxy.\RedisCluser
: count is the number of masters and iterates over each master with node parameters pre-set. This enables running node commands on each master without passing node parameters to these commands (when iterating)
/** @var Zenstruck\Redis $proxy */
$proxy->count(); // 1 if \Redis, # hosts if \RedisArray, # "masters" if \RedisCluster
foreach ($proxy as $node) {
$proxy->flushAll(); // this is permitted even for \RedisCluster (which typically requires a $nodeParams argument)
}
NOTE: If running commands that require being run on each host/master it is recommended
to iterate and run even if using \Redis
. This allows a seamless transition to
\RedisArray
/\RedisCluster
later.
Zenstruck\Redis\Utility\ExpiringSet
encapsulates the concept of a Redis expiring
set: a set (unordered list with no duplicates) whose members expire after a time.
Each read/write operation on the set prunes expired members.
/** @var Zenstruck\Redis $client */
$set = $client->expiringSet('my-set'); // redis key to store the set
$set->add('member1', 600); // set add "member1" that expires in 10 minutes
$set->add('member1', new \DateInterval::createFromDateString('5 minutes')); // can use \DateInterval for the TTL
$set->add('member1', new \DateTime('+5 minutes')); // use \DateTimeInterface to set specific expiry timestamp
$set->remove('member1'); // explicitly remove a member
$set->all(); // array - all unexpired members
$set->contains('member'); // true/false
$set->clear(); // clear all items
$set->prune(); // explicitly "prune" the set (remove expired members)
count($set); // int - number of unexpired members
foreach ($set as $member) {
// iterate over unexpired members
}
// fluent
$set
->add('member1', 600)
->add('member2', 600)
->remove('member1')
->remove('member2')
->prune()
->clear()
;
NOTE: In order to use complex types (arrays/objects) as members, your redis client must be configured with a serializer.
Below is a pseudocode example using this object for tracking active users on a website. When authenticated users login or request a page, their username is added to the set with a 5-minute idle time-to-live (TTL). A user is considered active within this time. On logout, they are removed from the set. If a user has not made a request within their last TTL, they are removed from the set.
/** @var Zenstruck\Redis $client */
$set = $client->expiringSet('active-users');
$ttl = \DateInterval::createFromDateString('5 minutes');
// LOGIN EVENT:
$set->add($event->getUsername(), $ttl);
// LOGOUT EVENT:
$set->remove($event->getUsername());
// REQUEST EVENT:
$set->add($event->getUsername(), $ttl);
// ADMIN MONITORING DASHBOARD WIDGET
$activeUserCount = count($set);
$activeUsernames = $set->all(); // [user1, user2, ...]
// ADMIN USER CRUD LISTING
foreach ($users as $user) {
$isActive = $set->contains($user->getUsername()); // bool
// ...
}
Add a supported Redis DSN environment variable:
# .env
REDIS_DSN=redis://localhost
Configure services:
# config/packages/zenstruck_redis.yaml
services:
# Proxy that is autowireable
Zenstruck\Redis:
factory: ['Zenstruck\Redis', 'create']
arguments: ['%env(REDIS_DSN)%']
# Separate proxy's that have different prefixes
redis1:
class: Zenstruck\Redis
factory: ['Zenstruck\Redis', 'create']
arguments: ['%env(REDIS_DSN)%', { prefix: 'prefix1:' }]
redis2:
class: Zenstruck\Redis
factory: ['Zenstruck\Redis', 'create']
arguments: ['%env(REDIS_DSN)%', { prefix: 'prefix2:' }]
# Separate proxy that uses PHP serialization
serialization_redis:
class: Zenstruck\Redis
factory: ['Zenstruck\Redis', 'create']
arguments: ['%env(REDIS_DSN)%', { serializer: php }]
# expiring set service
active_users:
class: Zenstruck\Redis\Utility\ExpiringSet
factory: ['@Zenstruck\Redis', 'expiringSet']
arguments:
- active_users # redis key
# Specific clients that are autowireable
Redis:
class: Redis
factory: ['Zenstruck\Redis', 'createClient']
arguments: ['%env(REDIS_DSN)%'] # note REDIS_DSN must be for \Redis client
RedisArray:
class: RedisArray
factory: ['Zenstruck\Redis', 'createClient']
arguments: ['%env(REDIS_DSN)%'] # note REDIS_DSN must be for \RedisArray client
RedisCluster:
class: RedisCluster
factory: ['Zenstruck\Redis', 'createClient']
arguments: ['%env(REDIS_DSN)%'] # note REDIS_DSN must be for \RedisCluster client
Use Zenstruck\Redis
for session storage (see Symfony Docs
for more details/options):
# config/services.yaml
# Assumes "Zenstruck\Redis" is available as a service and symfony/expression-language is installed
services:
redis_session_handler:
class: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler
arguments:
- "@=service('Zenstruck\\\\Redis').realClient()"
# config/packages/framework.yaml
framework:
# ...
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler
Running the test suite:
composer install
docker compose up -d # setup redis, redis-cluster, redis-sentinel
vendor/bin/phpunit -c phpunit.docker.xml
Much of the code to create php-redis clients from a DSN has been taken and modified from the Symfony Framework.