Caching Strategies Recipe#
Implement smart caching for optimal performance.
Problem#
You need to cache expensive operations, API responses, and database queries efficiently.
Solution#
1. Cache Service#
<?php
// src/Service/CacheService.php
namespace App\Service;
use Psr\SimpleCache\CacheInterface;
final class CacheService
{
public function __construct(
private CacheInterface $cache
) {}
public function remember(string $key, int $ttl, callable $callback): mixed
{
$value = $this->cache->get($key);
if ($value !== null) {
return $value;
}
$value = $callback();
$this->cache->set($key, $value, $ttl);
return $value;
}
public function rememberForever(string $key, callable $callback): mixed
{
return $this->remember($key, 86400 * 365, $callback); // 1 year
}
public function forget(string $key): void
{
$this->cache->delete($key);
}
public function flush(string $pattern = '*'): void
{
// Pattern-based deletion (Redis-specific)
if ($this->cache instanceof \Symfony\Component\Cache\Adapter\RedisAdapter) {
$keys = $this->cache->getAdapter()->getConnection()->keys($pattern);
foreach ($keys as $key) {
$this->cache->delete($key);
}
}
}
public function tags(array $tags): TaggedCache
{
return new TaggedCache($this->cache, $tags);
}
}
final class TaggedCache
{
public function __construct(
private CacheInterface $cache,
private array $tags
) {}
public function remember(string $key, int $ttl, callable $callback): mixed
{
$taggedKey = $this->getTaggedKey($key);
$value = $this->cache->get($taggedKey);
if ($value !== null) {
return $value;
}
$value = $callback();
$this->cache->set($taggedKey, $value, $ttl);
// Track key for this tag
foreach ($this->tags as $tag) {
$tagKeys = $this->cache->get("tag:{$tag}") ?? [];
$tagKeys[] = $taggedKey;
$this->cache->set("tag:{$tag}", array_unique($tagKeys));
}
return $value;
}
public function flush(): void
{
foreach ($this->tags as $tag) {
$keys = $this->cache->get("tag:{$tag}") ?? [];
foreach ($keys as $key) {
$this->cache->delete($key);
}
$this->cache->delete("tag:{$tag}");
}
}
private function getTaggedKey(string $key): string
{
return 'tagged:' . md5(implode('|', $this->tags) . '|' . $key);
}
}
2. HTTP Response Cache Middleware#
<?php
// src/Middleware/HttpCacheMiddleware.php
namespace App\Middleware;
use Infocyph\Webrick\Request\Request;
use Infocyph\Webrick\Response\Response;
use Psr\SimpleCache\CacheInterface;
use Closure;
final class HttpCacheMiddleware
{
public function __construct(
private CacheInterface $cache,
private int $defaultTtl = 60
) {}
public function __invoke(Request $r, Closure $next): Response
{
// Only cache GET/HEAD
if (!in_array($r->getMethod(), ['GET', 'HEAD'])) {
return $next($r);
}
// Generate cache key
$cacheKey = $this->getCacheKey($r);
// Check cache
$cached = $this->cache->get($cacheKey);
if ($cached) {
return $this->unserializeResponse($cached)
->withHeader('X-Cache', 'HIT');
}
// Get fresh response
$response = $next($r);
// Cache if cacheable
if ($this->isCacheable($response)) {
$ttl = $this->getTtl($response);
$this->cache->set($cacheKey, $this->serializeResponse($response), $ttl);
}
return $response->withHeader('X-Cache', 'MISS');
}
private function getCacheKey(Request $r): string
{
$parts = [
$r->getMethod(),
$r->getHost(),
$r->getPath(),
$r->getQueryString(),
$r->getHeaderLine('Accept'),
$r->getHeaderLine('Accept-Language')
];
return 'http:' . md5(implode('|', $parts));
}
private function isCacheable(Response $response): bool
{
$statusCode = $response->getStatusCode();
// Only cache successful responses
if ($statusCode < 200 || $statusCode >= 300) {
return false;
}
$cacheControl = $response->getHeaderLine('Cache-Control');
// Don't cache if explicitly told not to
if (str_contains($cacheControl, 'no-store') ||
str_contains($cacheControl, 'private')) {
return false;
}
// Don't cache responses with Set-Cookie
if ($response->hasHeader('Set-Cookie')) {
return false;
}
return true;
}
private function getTtl(Response $response): int
{
$cacheControl = $response->getHeaderLine('Cache-Control');
if (preg_match('/max-age=(\d+)/', $cacheControl, $matches)) {
return (int) $matches[1];
}
return $this->defaultTtl;
}
private function serializeResponse(Response $response): array
{
return [
'status' => $response->getStatusCode(),
'headers' => $response->getHeaders(),
'body' => (string) $response->getBody()
];
}
private function unserializeResponse(array $data): Response
{
$response = Response::create($data['body'], $data['status']);
foreach ($data['headers'] as $name => $values) {
foreach ($values as $value) {
$response = $response->withAddedHeader($name, $value);
}
}
return $response;
}
}
3. Query Result Caching#
<?php
// src/Repository/UserRepository.php
namespace App\Repository;
use App\Service\CacheService;
final class UserRepository
{
public function __construct(
private CacheService $cache
) {}
public function find(int $id): ?array
{
return $this->cache->remember(
key: "user:{$id}",
ttl: 3600,
callback: fn() => DB::queryOne('SELECT * FROM users WHERE id = ?', [$id])
);
}
public function all(): array
{
return $this->cache->remember(
key: 'users:all',
ttl: 600,
callback: fn() => DB::query('SELECT * FROM users ORDER BY name')
);
}
public function create(array $data): array
{
$id = DB::insert('users', $data);
// Invalidate list cache
$this->cache->forget('users:all');
return $this->find($id);
}
public function update(int $id, array $data): array
{
DB::update('users', $data, ['id' => $id]);
// Invalidate caches
$this->cache->forget("user:{$id}");
$this->cache->forget('users:all');
return $this->find($id);
}
public function delete(int $id): void
{
DB::delete('users', ['id' => $id]);
// Invalidate caches
$this->cache->forget("user:{$id}");
$this->cache->forget('users:all');
}
}
4. Tag-Based Cache Invalidation#
<?php
// src/Repository/PostRepository.php
namespace App\Repository;
use App\Service\CacheService;
final class PostRepository
{
public function __construct(
private CacheService $cache
) {}
public function findByUser(int $userId): array
{
return $this->cache
->tags(['posts', "user:{$userId}"])
->remember(
key: "user:{$userId}:posts",
ttl: 600,
callback: fn() => DB::query(
'SELECT * FROM posts WHERE user_id = ? ORDER BY created_at DESC',
[$userId]
)
);
}
public function findByCategory(int $categoryId): array
{
return $this->cache
->tags(['posts', "category:{$categoryId}"])
->remember(
key: "category:{$categoryId}:posts",
ttl: 600,
callback: fn() => DB::query(
'SELECT * FROM posts WHERE category_id = ?',
[$categoryId]
)
);
}
public function create(array $data): array
{
$id = DB::insert('posts', $data);
// Flush all posts caches
$this->cache->tags(['posts'])->flush();
// Flush user-specific cache
$this->cache->tags(["user:{$data['user_id']}"])->flush();
return $this->find($id);
}
}
5. Fragment Caching (View Caching)#
<?php
// src/View/CachedView.php
namespace App\View;
use App\Service\CacheService;
final class CachedView
{
public function __construct(
private CacheService $cache
) {}
public function render(string $template, array $data = [], ?int $ttl = null): string
{
if ($ttl === null) {
return $this->renderTemplate($template, $data);
}
$cacheKey = 'view:' . md5($template . serialize($data));
return $this->cache->remember(
key: $cacheKey,
ttl: $ttl,
callback: fn() => $this->renderTemplate($template, $data)
);
}
private function renderTemplate(string $template, array $data): string
{
extract($data);
ob_start();
include __DIR__ . "/../../views/{$template}.php";
return ob_get_clean();
}
}
// Usage
Route::get('/dashboard', function() use ($view) {
$stats = $view->render('partials/stats', ['user' => $user], ttl: 300);
$posts = PostRepository::latest();
return Response::create(
$view->render('dashboard', ['stats' => $stats, 'posts' => $posts]),
200,
['Content-Type' => 'text/html; charset=UTF-8']
);
});
Cache Patterns#
1. Cache-Aside (Lazy Loading)#
public function getUser(int $id): array
{
$cacheKey = "user:{$id}";
// Try cache first
$user = $this->cache->get($cacheKey);
if ($user === null) {
// Cache miss - load from database
$user = DB::queryOne('SELECT * FROM users WHERE id = ?', [$id]);
// Store in cache
$this->cache->set($cacheKey, $user, 3600);
}
return $user;
}
2. Write-Through Cache#
public function updateUser(int $id, array $data): array
{
// Update database
DB::update('users', $data, ['id' => $id]);
// Update cache immediately
$user = DB::queryOne('SELECT * FROM users WHERE id = ?', [$id]);
$this->cache->set("user:{$id}", $user, 3600);
return $user;
}
3. Cache Warming#
// scripts/warm-cache.php
use App\Repository\UserRepository;
use App\Repository\PostRepository;
// Warm user cache
$popularUsers = DB::query('SELECT id FROM users ORDER BY followers DESC LIMIT 100');
foreach ($popularUsers as $user) {
UserRepository::find($user['id']); // Loads into cache
}
// Warm post cache
$latestPosts = DB::query('SELECT id FROM posts ORDER BY created_at DESC LIMIT 100');
foreach ($latestPosts as $post) {
PostRepository::find($post['id']);
}
echo "Cache warmed\n";
4. Cache Stampede Prevention#
public function getExpensiveData(): array
{
$cacheKey = 'expensive:data';
$lockKey = 'lock:expensive:data';
$data = $this->cache->get($cacheKey);
if ($data === null) {
// Try to acquire lock
if ($this->cache->add($lockKey, true, 10)) { // 10 second lock
try {
// We got the lock - compute the data
$data = $this->computeExpensiveData();
$this->cache->set($cacheKey, $data, 600);
} finally {
$this->cache->delete($lockKey);
}
} else {
// Someone else is computing - wait and retry
sleep(1);
return $this->getExpensiveData();
}
}
return $data;
}
5. Probabilistic Early Expiration#
Prevents cache stampede by refreshing before expiration:
public function get(string $key, int $ttl, callable $callback): mixed
{
$value = $this->cache->get($key);
if ($value !== null) {
// Check if we should refresh early
$beta = 1.0;
$now = time();
$created = $this->cache->get("{$key}:created") ?? $now;
$delta = $created + $ttl - $now;
// Probabilistic early refresh
if ($delta - $beta * log(random_int(1, PHP_INT_MAX) / PHP_INT_MAX) <= 0) {
$value = $callback();
$this->cache->set($key, $value, $ttl);
$this->cache->set("{$key}:created", $now, $ttl);
}
return $value;
}
// Cache miss
$value = $callback();
$this->cache->set($key, $value, $ttl);
$this->cache->set("{$key}:created", time(), $ttl);
return $value;
}
Testing#
Unit Test#
<?php
use PHPUnit\Framework\TestCase;
use App\Service\CacheService;
class CacheServiceTest extends TestCase
{
public function testRememberCachesValue(): void
{
$cache = $this->createMock(CacheInterface::class);
$cache->expects($this->once())
->method('get')
->with('test:key')
->willReturn(null);
$cache->expects($this->once())
->method('set')
->with('test:key', 'computed', 60);
$service = new CacheService($cache);
$callCount = 0;
$callback = function() use (&$callCount) {
$callCount++;
return 'computed';
};
$result = $service->remember('test:key', 60, $callback);
$this->assertEquals('computed', $result);
$this->assertEquals(1, $callCount);
}
public function testRememberReturnsCachedValue(): void
{
$cache = $this->createMock(CacheInterface::class);
$cache->expects($this->once())
->method('get')
->with('test:key')
->willReturn('cached');
$cache->expects($this->never())
->method('set');
$service = new CacheService($cache);
$callCount = 0;
$callback = function() use (&$callCount) {
$callCount++;
return 'computed';
};
$result = $service->remember('test:key', 60, $callback);
$this->assertEquals('cached', $result);
$this->assertEquals(0, $callCount); // Callback not called
}
}
Best Practices#
1. Use Meaningful Keys#
// ❌ Bad: Unclear keys
$cache->get('u1');
$cache->get('data');
// ✅ Good: Clear, namespaced keys
$cache->get('user:1');
$cache->get('posts:user:1:latest');
2. Set Appropriate TTLs#
// Frequently changing data
$cache->set('stats:online_users', $count, 10); // 10 seconds
// Moderate change rate
$cache->set('posts:latest', $posts, 300); // 5 minutes
// Rarely changing
$cache->set('config:settings', $settings, 3600); // 1 hour
// Quasi-static
$cache->set('countries:list', $countries, 86400); // 24 hours
3. Always Invalidate on Write#
public function updatePost(int $id, array $data): void
{
DB::update('posts', $data, ['id' => $id]);
// Invalidate specific post
$this->cache->forget("post:{$id}");
// Invalidate lists
$this->cache->forget('posts:latest');
$this->cache->forget("posts:user:{$data['user_id']}");
}
4. Handle Cache Failures Gracefully#
public function getUser(int $id): array
{
try {
$user = $this->cache->remember(
"user:{$id}",
3600,
fn() => DB::queryOne('SELECT * FROM users WHERE id = ?', [$id])
);
} catch (\Exception $e) {
// Cache failed - log and continue without cache
$this->logger->error('Cache error', ['exception' => $e]);
$user = DB::queryOne('SELECT * FROM users WHERE id = ?', [$id]);
}
return $user;
}
5. Monitor Cache Hit Rates#
final class CacheMetrics
{
private static int $hits = 0;
private static int $misses = 0;
public static function recordHit(): void
{
self::$hits++;
}
public static function recordMiss(): void
{
self::$misses++;
}
public static function getHitRate(): float
{
$total = self::$hits + self::$misses;
return $total > 0 ? self::$hits / $total : 0;
}
}
// In cache service
public function get(string $key): mixed
{
$value = $this->cache->get($key);
if ($value !== null) {
CacheMetrics::recordHit();
} else {
CacheMetrics::recordMiss();
}
return $value;
}
Summary#
This recipe provides:
✅ Flexible caching service with remember pattern
✅ HTTP response caching middleware
✅ Query result caching
✅ Tag-based cache invalidation
✅ Fragment/view caching
✅ Cache stampede prevention
✅ Multiple caching patterns
Production checklist:
Use Redis or Memcached for production
Set appropriate TTLs per data type
Implement cache invalidation on writes
Handle cache failures gracefully
Monitor cache hit rates
Use cache tags for complex invalidation
Implement cache warming for critical data
Add cache versioning for schema changes