Redis Deep Dive for Laravel Developers
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
Practical: Trending Posts
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 TTLnoeviction— 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:
- Separate Redis databases for cache, sessions, queues
- Always set TTL
- Use pipelines when > 5 sequential commands
- Monitor memory usage
- Consistent key naming convention
- Use Lua for atomic operations