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 (use SCAN)
  • 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.