Redis Deep Dive for Laravel Developers

· 8 min read

Most Laravel developers use Redis as a dumb key-value store: Cache::put() and Cache::get(). But Redis is a data structure server — it supports sorted sets, lists, streams, pub/sub, and atomic operations. Understanding these unlocks powerful patterns.

Redis Data Structures You Should Know

Structure Use Case Laravel Feature
Strings Cache values, counters Cache::get/put
Hashes Object storage Redis::hset/hget
Lists Queues, recent items Queue driver
Sets Unique collections, tags Tagging
Sorted Sets Leaderboards, rankings Custom
Streams Event logs, pub/sub Broadcasting
HyperLogLog Unique visitor counting Custom

Beyond Cache::get — Direct Redis Commands

use Illuminate\Support\Facades\Redis;

// String operations
Redis::set('user:1:name', 'John');
Redis::get('user:1:name'); // "John"
Redis::incr('page:views:home'); // Atomic increment
Redis::incrBy('page:views:home', 5);

// Expiration
Redis::setex('temp:data', 3600, 'expires in 1 hour');
Redis::ttl('temp:data'); // 3599

Hashes: Storing Objects

Instead of serializing an entire object:

// Bad: serialize entire user
Cache::put('user:1', json_encode($user), 3600);

// Good: hash fields (update individual fields without re-serializing)
Redis::hset('user:1', 'name', 'John');
Redis::hset('user:1', 'email', 'john@example.com');
Redis::hset('user:1', 'views', 0);

// Get one field
Redis::hget('user:1', 'name'); // "John"

// Get all fields
Redis::hgetall('user:1'); // ['name' => 'John', 'email' => 'john@example.com', 'views' => '0']

// Increment one field atomically
Redis::hincrby('user:1', 'views', 1);

// Set multiple fields at once
Redis::hmset('user:1', [
    'name' => 'John Doe',
    'email' => 'john@example.com',
    'plan' => 'premium',
]);

Use case: Session-like data where you frequently update individual properties.

Sorted Sets: Leaderboards & Rankings

// Add scores
Redis::zadd('leaderboard', 100, 'player:alice');
Redis::zadd('leaderboard', 250, 'player:bob');
Redis::zadd('leaderboard', 175, 'player:charlie');

// Top 10 (highest first)
Redis::zrevrange('leaderboard', 0, 9, 'WITHSCORES');
// ['player:bob' => 250, 'player:charlie' => 175, 'player:alice' => 100]

// Rank of a specific player (0-indexed)
Redis::zrevrank('leaderboard', 'player:alice'); // 2

// Increment score atomically
Redis::zincrby('leaderboard', 50, 'player:alice'); // Now 150
class TrendingPostService
{
    private const KEY = 'trending:posts';
    private const MAX_ITEMS = 50;

    public function recordView(Post $post): void
    {
        // Increment score (view count) atomically
        Redis::zincrby(self::KEY, 1, $post->id);

        // Keep only top N posts
        Redis::zremrangebyrank(self::KEY, 0, -(self::MAX_ITEMS + 1));
    }

    public function getTrending(int $limit = 10): Collection
    {
        $postIds = Redis::zrevrange(self::KEY, 0, $limit - 1);

        if (empty($postIds)) {
            return collect();
        }

        return Post::whereIn('id', $postIds)
            ->get()
            ->sortBy(fn ($post) => array_search($post->id, $postIds));
    }

    public function resetDaily(): void
    {
        // Called by scheduler — decay scores
        $members = Redis::zrangebyscore(self::KEY, '-inf', '+inf', ['withscores' => true]);

        foreach ($members as $member => $score) {
            // Decay by 50% daily
            Redis::zadd(self::KEY, (int) ($score * 0.5), $member);
        }

        // Remove posts with score < 1
        Redis::zremrangebyscore(self::KEY, '-inf', 1);
    }
}

Lists: Recent Activity & Queues

// Push to list (newest first)
Redis::lpush('user:1:recent_views', $postId);

// Keep only last 20 items
Redis::ltrim('user:1:recent_views', 0, 19);

// Get recent views
Redis::lrange('user:1:recent_views', 0, 9); // Last 10

Practical: Activity Feed

class ActivityFeed
{
    public function record(User $user, string $action, array $data): void
    {
        $entry = json_encode([
            'action' => $action,
            'data' => $data,
            'timestamp' => now()->timestamp,
        ]);

        Redis::lpush("feed:{$user->id}", $entry);
        Redis::ltrim("feed:{$user->id}", 0, 99); // Keep last 100
        Redis::expire("feed:{$user->id}", 86400 * 7); // 7 days TTL
    }

    public function get(User $user, int $limit = 20): array
    {
        $entries = Redis::lrange("feed:{$user->id}", 0, $limit - 1);

        return array_map(fn ($entry) => json_decode($entry, true), $entries);
    }
}

Sets: Unique Collections

// Track which users are online
Redis::sadd('online:users', $userId);
Redis::srem('online:users', $userId); // Remove on disconnect

// Check if user is online
Redis::sismember('online:users', $userId); // true/false

// Count online users
Redis::scard('online:users'); // 42

// Get all online users
Redis::smembers('online:users');

Practical: Tag-Based Content Filtering

// Index posts by tag
Redis::sadd('tag:laravel', 1, 2, 5, 8);
Redis::sadd('tag:php', 1, 3, 5, 7);
Redis::sadd('tag:vue', 2, 4, 6);

// Posts tagged with BOTH laravel AND php
Redis::sinter('tag:laravel', 'tag:php'); // [1, 5]

// Posts tagged with laravel OR vue
Redis::sunion('tag:laravel', 'tag:vue'); // [1, 2, 4, 5, 6, 8]

Rate Limiting with Redis

Laravel's built-in rate limiter uses Redis:

use Illuminate\Support\Facades\RateLimiter;

// Check/increment (returns true if limit hit)
$executed = RateLimiter::attempt(
    key: 'send-email:' . $user->id,
    maxAttempts: 5,
    callback: function () use ($user) {
        // Send the email
        $user->notify(new SomeNotification());
    },
    decaySeconds: 60, // Per minute
);

if (!$executed) {
    return response('Too many emails. Please wait.', 429);
}

Custom Sliding Window Rate Limiter

class SlidingWindowRateLimiter
{
    public function attempt(string $key, int $maxAttempts, int $windowSeconds): bool
    {
        $now = microtime(true);
        $windowStart = $now - $windowSeconds;

        $pipe = Redis::pipeline(function ($pipe) use ($key, $now, $windowStart) {
            // Remove old entries outside the window
            $pipe->zremrangebyscore($key, '-inf', $windowStart);
            // Add current request
            $pipe->zadd($key, $now, $now . ':' . uniqid());
            // Count requests in window
            $pipe->zcard($key);
            // Set TTL so the key auto-cleans
            $pipe->expire($key, $windowSeconds);
        });

        $currentCount = $pipe[2];

        return $currentCount <= $maxAttempts;
    }
}

Pub/Sub for Real-Time Events

// Publisher (in your app)
Redis::publish('chat:room:1', json_encode([
    'user' => 'John',
    'message' => 'Hello everyone!',
    'time' => now()->toISOString(),
]));
// Subscriber (in a long-running process like Artisan command)
Redis::subscribe(['chat:room:1'], function (string $message) {
    $data = json_decode($message, true);
    echo "{$data['user']}: {$data['message']}\n";
});

Laravel Reverb and Broadcasting use Redis pub/sub under the hood.

Lua Scripting: Atomic Complex Operations

When you need multiple Redis commands to execute atomically:

// Problem: race condition
$views = Redis::get('post:1:views');
if ($views < 1000) {
    Redis::incr('post:1:views');
}
// Another request could increment between GET and INCR!

// Solution: Lua script (atomic)
$script = <<<'LUA'
    local current = tonumber(redis.call('GET', KEYS[1]) or 0)
    if current < tonumber(ARGV[1]) then
        return redis.call('INCR', KEYS[1])
    end
    return current
LUA;

$result = Redis::eval($script, 1, 'post:1:views', 1000);

Practical: Atomic Inventory Check

$luaScript = <<<'LUA'
    local stock = tonumber(redis.call('GET', KEYS[1]) or 0)
    local requested = tonumber(ARGV[1])

    if stock >= requested then
        redis.call('DECRBY', KEYS[1], requested)
        return 1
    end
    return 0
LUA;

$success = Redis::eval($luaScript, 1, "product:{$productId}:stock", $quantity);

if ($success) {
    // Proceed with order
} else {
    // Out of stock
}

Redis Pipelining: Batch Commands

Sending 100 individual commands = 100 round trips. Pipelining = 1 round trip.

// Bad: 100 round trips
foreach ($postIds as $id) {
    Redis::incr("post:{$id}:views");
}

// Good: 1 round trip
Redis::pipeline(function ($pipe) use ($postIds) {
    foreach ($postIds as $id) {
        $pipe->incr("post:{$id}:views");
    }
});

HyperLogLog: Count Unique Visitors

Count unique visitors without storing each visitor ID:

// Add visitor (uses ~12KB regardless of cardinality!)
Redis::pfadd('unique:visitors:2026-04-21', $visitorIp);
Redis::pfadd('unique:visitors:2026-04-21', $anotherIp);

// Count unique visitors (approximate, ~0.81% error rate)
Redis::pfcount('unique:visitors:2026-04-21'); // 2

// Merge multiple days
Redis::pfmerge('unique:visitors:week', [
    'unique:visitors:2026-04-15',
    'unique:visitors:2026-04-16',
    'unique:visitors:2026-04-17',
]);

Session Management with Redis

// config/session.php
'driver' => 'redis',
'connection' => 'session',

// config/database.php
'redis' => [
    'session' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', 6379),
        'database' => 1, // Separate database from cache
    ],
],

Why separate databases? So php artisan cache:clear doesn't destroy all sessions.

Production Best Practices

1. Key Naming Convention

{resource}:{id}:{field}
─────────
post:42:views
user:1:profile
trending:posts:week:15
cart:user:789
rate_limit:api:user:5

Consistent conventions help with debugging (knowing which feature owns a key) and management (safe wildcard operations).

2. Always Set TTL

// ❌ No TTL = data lives forever → memory leak
Redis::set('temp:data', $value);

// ✅ Always set TTL for temporary data
Redis::setex('temp:data', 3600, $value); // 1 hour

3. Avoid Oversized Keys

// ❌ Storing full HTML page (500KB+) in Redis
Cache::put('page:home', $hugeHtml, 3600);

// ✅ Cache small fragments
Cache::put('posts:recent:10', $recentPosts, 3600);

4. Monitor Memory

redis-cli info memory
# used_memory_human: 1.23G
# maxmemory_human: 2.00G
# maxmemory_policy: allkeys-lru

maxmemory-policy: When Redis runs out of memory, it uses a policy to decide which keys to evict:

  • allkeys-lru — evict least recently used key (recommended for cache)
  • volatile-lru — evict least recently used key that has a TTL
  • noeviction — return errors when full (use for critical data)

Conclusion

Redis in Laravel is more than Cache::remember(). Use the right data structure for the job:

  • Strings for simple cache, counters, and locks
  • Hashes for objects with individual field access/update
  • Sorted Sets for leaderboards, trending, scheduling
  • Lists for queues, activity feeds, recent items
  • Sets for unique collections and tag operations
  • Pub/Sub for real-time messaging (fire-and-forget)
  • Lua scripts for atomic multi-step operations
  • Pipelines for batch performance
  • HyperLogLog for approximate unique counting

Production checklist:

  1. Separate Redis databases for cache, sessions, queues
  2. Always set TTL
  3. Use pipelines when > 5 sequential commands
  4. Monitor memory usage
  5. Consistent key naming convention
  6. Use Lua for atomic operations

Comments