Redis #
Redis (Remote Dictionary Server) is an incredibly versatile in-memory data store — it can serve as a cache, session storage, message broker, rate limiter, real-time leaderboard, job queue, and much more. What makes Redis unique compared to a simple cache is its support for various data structures: String, Hash, List, Set, Sorted Set, and others — each optimized for different use cases. PHP accesses Redis through two main libraries: Predis (pure PHP, easy to install via Composer) and PhpRedis (a C extension that’s far faster, recommended for production). This article covers all of Redis’s data structures with real-world usage patterns in PHP.
Predis vs PhpRedis #
| Aspect | Predis | PhpRedis |
|---|---|---|
| Installation | composer require predis/predis | pecl install redis + phpenmod redis |
| Performance | Slower (pure PHP) | Much faster (C extension) |
| Portability | ✓ No extension needed | ✗ Needs an extension |
| Features | Complete | Complete + more |
| Best for | Development, prototyping | Production |
This article uses PhpRedis for the code examples because it’s more common in production, but almost all the syntax is identical in Predis.
Installation #
# PhpRedis (recommended for production)
sudo apt install php8.3-redis
# or via PECL:
sudo pecl install redis
echo "extension=redis.so" | sudo tee /etc/php/8.3/mods-available/redis.ini
sudo phpenmod redis
# Predis (pure PHP alternative)
composer require predis/predis
# Verify PhpRedis
php -m | grep redis
Connecting #
<?php
// PhpRedis
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('password-if-any'); // optional
$redis->select(0); // select the database (0-15, default 0)
// With timeout and retry options
$redis->connect('127.0.0.1', 6379, timeout: 2.0, retry_interval: 100);
// Persistent connection (reuse connections)
$redis->pconnect('127.0.0.1', 6379);
// Redis Cluster
$redis = new RedisCluster(null, [
'127.0.0.1:7000',
'127.0.0.1:7001',
'127.0.0.1:7002',
]);
// Redis Sentinel (high availability)
$sentinel = new RedisSentinel('127.0.0.1', 26379);
$master = $sentinel->getMasterAddrByName('mymaster');
$redis->connect($master[0], $master[1]);
// Predis
use Predis\Client;
$redis = new Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
'password' => 'password-if-any',
'database' => 0,
]);
// Connecting to Redis Cloud / Upstash
$redis = new Client('rediss://username:password@hostname:6380');
Strings — The Most Basic Data Type #
A Redis String isn’t just text — it can store integers, floats, JSON, or binary data up to 512MB:
<?php
// Set and Get
$redis->set('key', 'value');
$value = $redis->get('key'); // "value"
// Set with TTL (Time To Live) — expires automatically
$redis->setex('session:user:42', 3600, json_encode(['id' => 42, 'name' => 'Budi'])); // expires in 1 hour
$redis->set('token:abc123', 'user-id-42', ['ex' => 86400]); // alternative syntax
// NX — only set if it doesn't exist (distributed lock)
$success = $redis->set('lock:order:42', '1', ['nx' => true, 'ex' => 30]);
if (!$success) {
throw new \RuntimeException("Order 42 is being processed by another process");
}
// XX — only set if it already exists
$redis->set('counter', '100', ['xx' => true]);
// Increment / Decrement — atomic operations
$redis->set('views:article:1', 0);
$redis->incr('views:article:1'); // +1
$redis->incrBy('views:article:1', 5); // +5
$redis->decrBy('views:article:1', 2); // -2
$views = $redis->get('views:article:1'); // string "4"
// Float increment
$redis->set('balance', '1000.50');
$redis->incrByFloat('balance', 500.25); // 1500.75
// GetSet — get the old value, set a new value (atomic)
$oldValue = $redis->getset('status', 'active'); // atomic get+set
// MSET / MGET — multiple keys at once
$redis->mset(['a' => '1', 'b' => '2', 'c' => '3']);
$values = $redis->mget(['a', 'b', 'c', 'missing']); // ['1', '2', '3', false]
// String operations
$redis->set('name', 'Budi');
$redis->append('name', ' Santoso'); // "Budi Santoso"
echo $redis->strlen('name'); // 12
echo $redis->getrange('name', 0, 3); // "Budi"
Hashes — Maps Inside Redis #
Hashes store a set of field-value pairs under one key — ideal for objects or records:
<?php
// HSET / HGET / HGETALL
$redis->hset('user:42', 'name', 'Budi Santoso');
$redis->hset('user:42', 'email', '[email protected]');
$redis->hset('user:42', 'role', 'admin');
// Or set everything at once
$redis->hmset('user:42', [
'name' => 'Budi Santoso',
'email' => '[email protected]',
'role' => 'admin',
'login_count' => 0,
'created_at' => time(),
]);
echo $redis->hget('user:42', 'name'); // Budi Santoso
$allFields = $redis->hgetall('user:42');
// ['name' => 'Budi Santoso', 'email' => '...', ...]
// HMGET — get several fields
$fields = $redis->hmget('user:42', ['name', 'email', 'missing']);
// ['Budi Santoso', '[email protected]', false]
// HINCRBY — increment a field inside a hash
$redis->hincrby('user:42', 'login_count', 1);
echo $redis->hget('user:42', 'login_count'); // 1
// Check field existence
var_dump($redis->hexists('user:42', 'name')); // bool(true)
var_dump($redis->hexists('user:42', 'phone')); // bool(false)
// List fields and values
$fields = $redis->hkeys('user:42'); // ['name', 'email', 'role', ...]
$values = $redis->hvals('user:42'); // ['Budi Santoso', '...', ...]
echo $redis->hlen('user:42'); // 5
// Delete fields
$redis->hdel('user:42', 'role');
// TTL for hashes — set on the whole key, not per field
$redis->expire('user:42', 1800); // expires in 30 minutes
Lists — Queues and Stacks #
A List is a doubly linked list — items can be added or taken from both ends:
<?php
// LPUSH / RPUSH — add to the left (head) / right (tail)
$redis->rpush('email:queue', json_encode(['to' => '[email protected]', 'subject' => 'Hello']));
$redis->rpush('email:queue', json_encode(['to' => '[email protected]', 'subject' => 'Welcome']));
$redis->lpush('error:log', "Error 500: Internal server error");
// RPOP / LPOP — take from the right / left (non-blocking)
$task = $redis->lpop('email:queue'); // take from the front of the queue (FIFO)
$data = json_decode($task, true);
// BLPOP / BRPOP — blocking pop (waits until an item exists)
// Very useful for worker queues
$item = $redis->blpop(['email:queue', 'sms:queue'], timeout: 5); // 5-second timeout
// $item = ['email:queue', 'json...'] or null on timeout
// Worker implementation
while (true) {
$item = $redis->blpop(['jobs:queue'], timeout: 0); // timeout 0 = wait forever
if ($item !== null) {
[$queue, $payload] = $item;
$job = json_decode($payload, true);
processJob($job);
}
}
// LRANGE — get a range of elements (0 = first, -1 = last)
$recentErrors = $redis->lrange('error:log', 0, 9); // 10 most recent errors
// LLEN — list length
echo $redis->llen('email:queue'); // number of emails in the queue
// LTRIM — keep only part of the list (delete the rest)
// Useful for activity logs that only keep the N most recent items
$redis->lpush('activity:user:42', json_encode(['action' => 'login', 'time' => time()]));
$redis->ltrim('activity:user:42', 0, 99); // keep only the 100 most recent
Sets — Unique Collections #
Sets store a collection of unique values without order — great for tags, followers, online users:
<?php
// SADD / SMEMBERS
$redis->sadd('tags:article:1', 'php', 'redis', 'cache', 'backend');
$redis->sadd('tags:article:2', 'php', 'mysql', 'database');
$tags = $redis->smembers('tags:article:1'); // ['php', 'redis', 'cache', 'backend']
echo $redis->scard('tags:article:1'); // 4 (member count)
// Membership checks
var_dump($redis->sismember('tags:article:1', 'php')); // bool(true)
var_dump($redis->sismember('tags:article:1', 'java')); // bool(false)
// Set operations
$intersection = $redis->sinter('tags:article:1', 'tags:article:2'); // ['php']
$union = $redis->sunion('tags:article:1', 'tags:article:2'); // ['php','redis','cache','backend','mysql','database']
$difference = $redis->sdiff('tags:article:1', 'tags:article:2'); // ['redis','cache','backend']
// Store the result into a new key
$redis->sinterstore('tags:common', 'tags:article:1', 'tags:article:2');
// Online users — add/remove
$redis->sadd('users:online', 'user:42');
$redis->sadd('users:online', 'user:99');
echo $redis->scard('users:online') . " users online\n";
// Remove
$redis->srem('users:online', 'user:42');
// Random member (useful for sampling or random prizes)
$randomUser = $redis->srandmember('users:online');
$selected = $redis->spop('users:online'); // take and remove a random member
Sorted Sets — Leaderboards and Rate Limiting #
A Sorted Set is like a Set, but every member has a float score — ordered by score:
<?php
// ZADD — add with a score
$redis->zadd('game:leaderboard', ['BudiSantoso' => 9500]);
$redis->zadd('game:leaderboard', ['SitiRahayu' => 11200]);
$redis->zadd('game:leaderboard', ['DaniPratama' => 8750]);
$redis->zadd('game:leaderboard', ['RinaAmelia' => 12000]);
// ZRANGE — ascending order (lowest score first)
$all = $redis->zrange('game:leaderboard', 0, -1, ['withscores' => true]);
// ZREVRANGE — descending order (highest score first) — TOP N
$top3 = $redis->zrevrange('game:leaderboard', 0, 2, ['withscores' => true]);
// ['RinaAmelia' => 12000, 'SitiRahayu' => 11200, 'BudiSantoso' => 9500]
// ZRANK / ZREVRANK — position in the ranking (0-based)
echo $redis->zrevrank('game:leaderboard', 'BudiSantoso'); // 2 (rank 3)
// ZINCRBY — add to a score
$redis->zincrby('game:leaderboard', 500, 'BudiSantoso'); // score becomes 10000
// ZSCORE — get a score
echo $redis->zscore('game:leaderboard', 'RinaAmelia'); // 12000
// Rate Limiting with Sorted Sets — sliding window
function checkRateLimit(Redis $redis, string $userId, int $maxRequests = 10, int $windowSeconds = 60): bool
{
$now = microtime(true) * 1000; // milliseconds
$limit = $now - ($windowSeconds * 1000);
$key = "rate_limit:$userId";
// Remove requests older than the window
$redis->zremrangebyscore($key, '-inf', $limit);
// Count requests within the window
$count = $redis->zcard($key);
if ($count >= $maxRequests) {
return false; // rate limit exceeded
}
// Add this request
$redis->zadd($key, [$now => $now]);
$redis->expire($key, $windowSeconds + 1); // TTL so the key cleans itself
return true;
}
// Usage
if (!checkRateLimit($redis, 'user:42', maxRequests: 100, windowSeconds: 60)) {
http_response_code(429);
header('Retry-After: 60');
die(json_encode(['error' => 'Too many requests. Try again in 1 minute.']));
}
Pub/Sub — Inter-Process Communication #
<?php
// Publisher — send a message to a channel
$redis->publish('order:notifications', json_encode([
'event' => 'order_completed',
'order_id' => 42,
'user_id' => 99,
]));
// Subscriber — listen on a channel (separate process)
// WARNING: after subscribing, the connection can only be used for pub/sub
$redisSubscriber = new Redis();
$redisSubscriber->connect('127.0.0.1', 6379);
$redisSubscriber->subscribe(['order:notifications', 'stock:notifications'], function($redis, $channel, $message) {
$data = json_decode($message, true);
echo "Channel: $channel\n";
echo "Event: {$data['event']}\n";
match($channel) {
'order:notifications' => processOrderNotif($data),
'stock:notifications' => processStockAlert($data),
};
});
// Pattern subscribe — listen on channels matching a pattern
$redisSubscriber->psubscribe(['notifications:*'], function($redis, $pattern, $channel, $message) {
echo "Pattern: $pattern, Channel: $channel\n";
echo "Message: $message\n";
});
Pipelines and Transactions #
<?php
// Pipeline — send many commands at once without waiting for responses
// Drastically reduces network round-trips
$redis->pipeline(function($pipe) {
for ($i = 0; $i < 1000; $i++) {
$pipe->set("key:$i", "value-$i");
$pipe->expire("key:$i", 3600);
}
});
// 2000 commands sent in one batch — far faster than 2000 round-trips
// Transactions — MULTI/EXEC (all commands succeed or none do)
$redis->multi(); // start the transaction
$redis->set('balance:from', 500);
$redis->set('balance:to', 1500);
$redis->exec(); // commit
// Rollback
$redis->multi();
$redis->set('key', 'value');
$redis->discard(); // cancel the transaction
// WATCH — optimistic locking
$redis->watch('balance:user:42');
$balance = (float) $redis->get('balance:user:42');
if ($balance < 100000) {
$redis->unwatch();
throw new \DomainException("Insufficient balance");
}
$redis->multi();
$redis->decrby('balance:user:42', 100000);
$redis->incrby('balance:merchant:99', 100000);
$result = $redis->exec();
if ($result === false) {
// Data changed since WATCH — transaction aborted
throw new \RuntimeException("Transaction failed because data changed concurrently");
}
Common Caching Patterns #
<?php
class CacheService
{
public function __construct(private Redis $redis) {}
// Cache-aside pattern — check the cache first, fetch from the DB on a miss
public function remember(string $key, int $ttl, callable $callback): mixed
{
$cached = $this->redis->get($key);
if ($cached !== false) {
return json_decode($cached, true);
}
$data = $callback();
$this->redis->setex($key, $ttl, json_encode($data));
return $data;
}
// Invalidate the cache after an update
public function forget(string $key): void
{
$this->redis->del($key);
}
public function forgetPattern(string $pattern): void
{
$keys = $this->redis->keys($pattern); // WARNING: KEYS can be slow in production
if (!empty($keys)) {
$this->redis->del($keys);
}
}
}
// Usage
$cache = new CacheService($redis);
$product = $cache->remember(
key: "product:detail:42",
ttl: 3600, // 1 hour
callback: fn() => $db->query("SELECT * FROM products WHERE id = 42")->fetch(),
);
// After updating the database, invalidate the cache
$db->prepare("UPDATE products SET price = ? WHERE id = 42")->execute([14000000]);
$cache->forget("product:detail:42");
Summary #
- PhpRedis (C extension) for production — far faster than Predis (pure PHP). Predis for development or environments where you can’t control extension installation.
- Choose the right data structure: Strings for single values/counters, Hashes for objects/records, Lists for queues/stacks, Sets for unique collections, Sorted Sets for leaderboards and sliding-window rate limiting.
- TTL is mandatory for caches — set
EXPIREorSETEXon all cache keys so Redis doesn’t run out of memory from keys that are never removed.- BLPOP for worker queues — blocking pop waits until an item exists without busy-waiting. Far more efficient than polling LPOP in a loop.
- Pipelines for batch operations — send many commands in one round-trip. For 1000 operations, a pipeline can be 100x faster than executing them one by one.
- MULTI/EXEC for atomicity — all commands in a transaction succeed or none are applied. Use WATCH for optimistic locking.
- Rate limiting with Sorted Sets — a sliding window using timestamps as scores gives an accurate limit compared to a fixed-window counter.
- Pub/Sub for events — after
subscribe(), a Redis connection can only be used for pub/sub. Use a separate connection for regular data operations.