On this page
- What You’ll Need
- Different Data Ages Differently: TTL Constants and the Logic Behind Them
- Cache Keys as a Hierarchy
- Read-Through Caching in Controllers
- Pattern-Based Cache Invalidation With Redis SCAN
- Event-Driven Invalidation with Observers
- Redis Pipelining for Batch Cache Warming
- Cache Stampede: The Problem Nobody Mentions Until It Bites Them
- Things That Will Go Wrong
- Monitoring Cache Health
A social platform profile page isn’t one query — it’s fifteen. User profile data, friends list, activity feed, privacy settings, friendship status with the viewer, content counts. Hit that page with 1000 concurrent users and you’re running 15,000 database queries per second. Even with optimized queries, that’s unsustainable.
The naive fix is to cache everything with a single TTL. The problem is that “everything” has wildly different change rates. An activity feed needs to reflect new posts within a minute. A friendship — which might change a few times a year — doesn’t need to expire every 60 seconds. Cache them the same way and you’re either showing stale activity data or hammering the database to refresh friend lists that haven’t changed.
This tutorial walks through MyKpopLists’ SocialCacheService — a service class that gives different data types different TTLs, uses Redis SCAN for pattern-based invalidation, and handles the edge cases that break naive caching implementations.
What You’ll Need
- Advanced Laravel (service containers, facades, events)
- Understanding of Redis data structures
- Database query optimization basics
- Some familiarity with distributed systems concepts
Environment:
- PHP 8.2+, Laravel 12.x, Redis 6.0+
sudo apt-get install redis-server
sudo systemctl start redis
CACHE_STORE=redis
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Different Data Ages Differently: TTL Constants and the Logic Behind Them
Not all cached data is equal. Some changes constantly, some barely changes at all:
class SocialCacheService
{
const PROFILE_DATA_TTL = 300; // 5 minutes
const ACTIVITY_FEED_TTL = 60; // 1 minute
const FRIEND_DATA_TTL = 600; // 10 minutes
const STATIC_CONTENT_TTL = 3600; // 1 hour
const USER_STATS_TTL = 900; // 15 minutes
}
Activity feeds get 60 seconds because users expect near-real-time updates. Profiles get 5 minutes — profile changes are infrequent and users tolerate a short delay. Friend lists get 10 minutes because friendships don’t change often. User stats (post counts, etc.) get 15 minutes because they’re expensive to compute and slightly stale values are acceptable.
The decision framework: high volatility and high access gets a short TTL. Low volatility and high access gets a medium TTL. Low volatility and low access gets a long TTL. Expensive computation gets a longer TTL regardless of volatility.
Cache Keys as a Hierarchy
Before any caching code, the key structure matters enormously. Inconsistent keys make invalidation a nightmare:
private static function getCacheKey(string $type, ...$params): string
{
$key = match($type) {
'user_profile' => "profile:{$params[0]}",
'user_lists' => "user:{$params[0]}:lists:{$params[1]}",
'user_following' => "user:{$params[0]}:following",
'user_activity' => "user:{$params[0]}:activity:{$params[1]}",
'friend_requests' => "user:{$params[0]}:friend_requests",
'friends_list' => "user:{$params[0]}:friends",
'friendship_status'=> "friendship:{$params[0]}:{$params[1]}",
'user_stats' => "user:{$params[0]}:stats",
'activity_feed' => "activity_feed:{$params[0]}:{$params[1]}",
'privacy_settings' => "user:{$params[0]}:privacy",
default => throw new \InvalidArgumentException("Unknown cache type: {$type}")
};
return config('cache.prefix') . $key;
}
The hierarchical structure — user:{id}:friends, user:{id}:activity:1, etc. — groups all data for a user under a predictable prefix. This makes pattern-based invalidation possible: to clear everything for user 42, you scan for keys matching user:42:*.
For friendship status, I always use min($userId1, $userId2) as the first parameter. This ensures friendship:10:25 and friendship:25:10 don’t create two cache entries for the same relationship:
public static function cacheFriendshipStatus(int $userId1, int $userId2, ?string $status): void
{
$key = self::getCacheKey(
'friendship_status',
min($userId1, $userId2),
max($userId1, $userId2)
);
Cache::put($key, $status, self::FRIEND_DATA_TTL);
}
Read-Through Caching in Controllers
The pattern in controllers is simple: try the cache first, fall back to the database on a miss, store the result:
class ProfileController
{
public function show(string $username)
{
$profileData = SocialCacheService::getUserProfile($username);
if ($profileData === null) {
$user = User::where('username', $username)->firstOrFail();
$profileData = [
'id' => $user->id,
'username' => $user->username,
'bio' => $user->bio,
];
SocialCacheService::cacheUserProfile($username, $profileData);
}
return Inertia::render('Profile/Show', ['user' => $profileData]);
}
}
Pattern-Based Cache Invalidation With Redis SCAN
When a user updates their profile, we don’t just need to clear the profile key. We need to clear their activity appearances in friends’ feeds, their friendship status records, their stats. The invalidateUserCaches method uses Redis SCAN to find and delete all keys matching a set of patterns:
public static function invalidateUserCaches(int $userId): void
{
$patterns = [
"profile:*",
"user:{$userId}:*",
"friendship:{$userId}:*",
"friendship:*:{$userId}",
"activity_feed:*",
];
foreach ($patterns as $pattern) {
$fullPattern = config('cache.prefix') . $pattern;
if (Cache::getStore() instanceof \Illuminate\Cache\RedisStore) {
$redis = Redis::connection('cache');
$cursor = 0;
do {
[$cursor, $keys] = $redis->scan($cursor, [
'MATCH' => $fullPattern,
'COUNT' => 100
]);
if (!empty($keys)) {
$redis->del($keys);
}
} while ($cursor !== 0);
} else {
Cache::forget($fullPattern);
}
}
}
SCAN is cursor-based — it returns a batch of results and a cursor to continue from. The loop runs until the cursor returns to 0, which means the full keyspace has been scanned. This is non-blocking: unlike the KEYS command, which locks Redis for O(N) time while it scans everything, SCAN processes results incrementally.
Never use KEYS in production:
// Blocks Redis while scanning the entire keyspace — dangerous
$keys = $redis->keys('user:*');
// Safe — cursor-based, non-blocking
$cursor = 0;
do {
[$cursor, $keys] = $redis->scan($cursor, ['MATCH' => 'user:*']);
} while ($cursor !== 0);
For surgical invalidation — like when two users update their friendship status — a targeted approach clears only the specific keys involved without touching the broader user cache:
public static function invalidateFriendshipCaches(int $userId1, int $userId2): void
{
$statusKey = self::getCacheKey('friendship_status', min($userId1, $userId2), max($userId1, $userId2));
Cache::forget($statusKey);
Cache::forget(self::getCacheKey('friends_list', $userId1));
Cache::forget(self::getCacheKey('friends_list', $userId2));
Cache::forget(self::getCacheKey('friend_requests', $userId1));
Cache::forget(self::getCacheKey('friend_requests', $userId2));
Cache::forget(self::getCacheKey('user_stats', $userId1));
Cache::forget(self::getCacheKey('user_stats', $userId2));
}
Event-Driven Invalidation with Observers
Rather than calling invalidation manually in every controller, register observers that fire automatically when models change:
class UserObserver
{
public function updated(User $user): void
{
SocialCacheService::invalidateUserCaches($user->id);
}
public function deleted(User $user): void
{
SocialCacheService::invalidateUserCaches($user->id);
}
}
class FriendshipObserver
{
public function created(Friendship $friendship): void
{
SocialCacheService::invalidateFriendshipCaches(
$friendship->requester_id,
$friendship->addressee_id
);
}
public function updated(Friendship $friendship): void
{
SocialCacheService::invalidateFriendshipCaches(
$friendship->requester_id,
$friendship->addressee_id
);
}
}
Register both in AppServiceProvider::boot(). Now when any code anywhere calls $user->update(...), the cache invalidation fires automatically without anyone having to remember to call it.
Redis Pipelining for Batch Cache Warming
When you need to pre-populate cache for multiple users at once (e.g., after registration or a major data update), batch the Redis operations with pipelining:
public static function batchCache(array $items): void
{
if (Cache::getStore() instanceof \Illuminate\Cache\RedisStore) {
$redis = Redis::connection('cache');
$pipeline = $redis->pipeline();
foreach ($items as $key => $data) {
$pipeline->setex($key, $data['ttl'], serialize($data['value']));
}
$pipeline->execute();
} else {
foreach ($items as $key => $data) {
Cache::put($key, $data['value'], $data['ttl']);
}
}
}
Pipelining batches all the SETEX commands into a single network round trip. Five cache writes with individual calls = five round trips to Redis ≈ 5ms. Five cache writes with pipelining = one round trip ≈ 1ms. At scale this adds up.
Cache Stampede: The Problem Nobody Mentions Until It Bites Them
When a popular cache key expires and 1000 concurrent requests all get a cache miss simultaneously, all 1000 hit the database at once. This is a cache stampede.
Laravel’s cache locking solves it cleanly:
$data = Cache::lock('expensive_data_lock', 10)->get(function () {
return Cache::remember('expensive_data', 300, function () {
return DB::table('users')
->join('posts', ...)
->join('comments', ...)
->get();
});
});
The first request acquires the lock, executes the query, and caches the result. The other 999 requests wait (up to 10 seconds), then read from cache. The database sees one query instead of 1000.
For celebrity users — accounts with millions of followers whose profile cache expiration triggers massive stampedes — probabilistic early expiration helps spread the load:
public static function getUserProfile(string $username): ?array
{
$key = self::getCacheKey('user_profile', $username);
$data = Cache::get($key);
if ($data === null) return null;
$ttl = Cache::getStore()->getRedis()->ttl($key);
// When TTL drops below 10% of original, each request has a 10% chance
// of triggering a cache refresh — spreads the stampede across time
if ($ttl < (self::PROFILE_DATA_TTL * 0.1)) {
if (rand(1, 100) <= 10) {
return null;
}
}
return $data;
}
Things That Will Go Wrong
Cache key collisions. user:42:lists versus user:4:2:lists — without consistent structure, keys can collide in ambiguous ways. Always use the getCacheKey() method. Never construct cache keys inline in controller code.
Caching Eloquent models directly. Serializing a full model with its relationships creates a massive cache entry that goes stale in non-obvious ways. Only cache the data your views actually need:
// Don't: caches everything including lazy-loaded relations
Cache::put('user:42', User::with('posts')->find(42));
// Do: cache only what the profile page needs
Cache::put('user:42', ['id' => $user->id, 'username' => $user->username, 'bio' => $user->bio]);
User input in cache keys. If a user-controlled value ends up in a cache key without validation, an attacker could construct malicious keys:
// Vulnerable
$userId = request()->input('user_id');
$key = "user:{$userId}:profile";
// Safe
$userId = (int) request()->input('user_id');
if ($userId <= 0) abort(400);
$key = "user:{$userId}:profile";
When to skip the cache entirely. Don’t cache real-time data (live scores, stock prices), user-specific sensitive data (credentials), large binary data (use a CDN instead), or data with deeply nested invalidation relationships. The complexity of invalidating complex relational data often costs more than the query savings.
Monitoring Cache Health
public static function getCacheStats(): array
{
if (Cache::getStore() instanceof \Illuminate\Cache\RedisStore) {
$redis = Redis::connection('cache');
$info = $redis->info();
return [
'memory_used' => $info['used_memory_human'] ?? 'N/A',
'keyspace_hits' => $info['keyspace_hits'] ?? 0,
'keyspace_misses'=> $info['keyspace_misses'] ?? 0,
'hit_rate' => self::calculateHitRate($info),
'evicted_keys' => $info['evicted_keys'] ?? 0,
];
}
return ['message' => 'Redis only'];
}
A hit rate above 80% means the caching strategy is working. Between 50–80% means there’s room to improve — likely TTLs are too short or cache keys aren’t aligned with access patterns. Below 50% means either the cache isn’t being used effectively or TTLs are expiring before content gets a second request. Evicted keys greater than zero means Redis is running out of memory and purging entries — either increase Redis memory or reduce TTLs.
The performance difference between no caching and this multi-tiered approach on MyKpopLists: profile page load dropped from ~500ms (15 database queries) to ~50ms (0–2 queries). Database CPU under load dropped from 80% to 20%. That’s the practical payoff from thinking carefully about what changes at what rate and caching accordingly.