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 #

AspectPredisPhpRedis
Installationcomposer require predis/predispecl install redis + phpenmod redis
PerformanceSlower (pure PHP)Much faster (C extension)
Portability✓ No extension needed✗ Needs an extension
FeaturesCompleteComplete + more
Best forDevelopment, prototypingProduction

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 EXPIRE or SETEX on 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.

← Previous: Elasticsearch   Next: Memcached →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact