PHP Caching Strategies
Caching is the highest-impact performance optimization for PHP web applications. A well-designed cache reduces database load, lowers response latency, and improves scalability under traffic spikes.
Layers of PHP Caching
Browser cache → CDN → HTTP reverse proxy → Application cache → OPcache → Database
Each layer serves different data at different TTLs. Apply caching at every layer that makes sense.
OPcache — Bytecode Cache
PHP compiles source to opcodes on every request without OPcache. OPcache stores compiled bytecode in memory.
; php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; production — restart PHP-FPM on deploy
opcache.jit=1255 ; PHP 8+ JIT for CPU-bound code
opcache.jit_buffer_size=128M
Verify:
<?php
print_r(opcache_get_status()['opcache_enabled']); // 1
Restart PHP-FPM after deployment when validate_timestamps=0:
sudo systemctl reload php8.2-fpm
Application-Level Caching with Redis
Install the PHP Redis extension:
pecl install redis
Basic usage:
<?php
declare(strict_types=1);
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
function getCachedUser(Redis $redis, int $id): ?array
{
$key = "user:{$id}";
$cached = $redis->get($key);
if ($cached !== false) {
return json_decode($cached, true);
}
$user = fetchUserFromDatabase($id);
if ($user === null) {
return null;
}
$redis->setex($key, 300, json_encode($user)); // TTL 5 minutes
return $user;
}
Cache-Aside Pattern
1. Check cache
2. Cache hit → return
3. Cache miss → query DB → store in cache → return
Cache Invalidation
The hard part — stale data is worse than no cache:
function updateUser(Redis $redis, int $id, array $data): void
{
updateUserInDatabase($id, $data);
$redis->del("user:{$id}"); // invalidate
$redis->del("users:list:page:1"); // invalidate related keys
}
Tag-based invalidation (Redis sets):
function cacheWithTags(Redis $redis, string $key, array $tags, mixed $data, int $ttl): void
{
$redis->setex($key, $ttl, serialize($data));
foreach ($tags as $tag) {
$redis->sAdd("tag:{$tag}", $key);
}
}
function invalidateTag(Redis $redis, string $tag): void
{
$keys = $redis->sMembers("tag:{$tag}");
foreach ($keys as $key) {
$redis->del($key);
}
$redis->del("tag:{$tag}");
}
Laravel Caching
Laravel’s cache facade supports Redis, Memcached, file, and array drivers:
use Illuminate\Support\Facades\Cache;
// Store
Cache::put('settings', $settings, now()->addHour());
// Retrieve with fallback
$settings = Cache::remember('settings', 3600, function () {
return Settings::all()->pluck('value', 'key');
});
// Tags (Redis/Memcached only)
Cache::tags(['users', 'user:1'])->put('user:1', $user, 600);
Cache::tags(['users'])->flush(); // invalidate all user caches
Configure in .env:
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Route caching for production:
php artisan route:cache
php artisan config:cache
php artisan view:cache
php artisan event:cache
Clear on deploy:
php artisan optimize:clear
php artisan optimize
Symfony Caching
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\CacheInterface;
class ProductService
{
public function __construct(private CacheInterface $cache) {}
public function getProduct(int $id): Product
{
return $this->cache->get("product.{$id}", function (ItemInterface $item) use ($id) {
$item->expiresAfter(3600);
return $this->repository->find($id);
});
}
}
HTTP cache with Symfony:
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
$response->setPublic();
$response->setMaxAge(3600);
$response->setEtag(md5($content));
Memcached vs Redis
| Feature | Memcached | Redis |
|---|---|---|
| Data structures | Strings only | Strings, hashes, lists, sets |
| Persistence | No | Optional |
| Pub/Sub | No | Yes |
| Best for | Simple object cache | Cache + sessions + queues |
Use Redis when you need one service for caching, sessions, and queues.
HTTP and CDN Caching
Set cache headers for static and semi-static responses:
header('Cache-Control: public, max-age=3600');
header('ETag: "' . md5($content) . '"');
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) &&
$_SERVER['HTTP_IF_NONE_MATCH'] === '"' . md5($content) . '"') {
http_response_code(304);
exit;
}
CDN (Cloudflare, Fastly) caches based on these headers — reduce origin load dramatically.
Query Result Caching
Cache expensive database queries:
function getTopProducts(Redis $redis, PDO $db): array
{
$key = 'top_products:' . date('Y-m-d-H');
if ($cached = $redis->get($key)) {
return json_decode($cached, true);
}
$stmt = $db->query('
SELECT p.id, p.name, SUM(o.qty) AS sold
FROM products p
JOIN order_items o ON p.id = o.product_id
WHERE o.created_at > NOW() - INTERVAL 24 HOUR
GROUP BY p.id
ORDER BY sold DESC
LIMIT 10
');
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
$redis->setex($key, 3600, json_encode($results));
return $results;
}
Monitoring Cache Effectiveness
Track these metrics:
| Metric | Target |
|---|---|
| Hit rate | > 90% for hot keys |
| Eviction rate | Low — increase memory if high |
| Latency | < 1ms for Redis GET |
| Memory usage | < 80% of maxmemory |
redis-cli INFO stats | grep keyspace
# keyspace_hits:100000 keyspace_misses:5000
# Hit rate = hits / (hits + misses) = 95.2%
Anti-Patterns
- Caching user-specific data with long TTL without invalidation
- Caching error responses
- Using
KEYS *in production (useSCAN) - No TTL on cache keys — memory exhaustion
- Caching before validating input
Caching transforms PHP application performance — combine OPcache, Redis, and HTTP caching for production-grade response times.