Multi-Level Caching in Laravel: L1/L2 Strategies with Redis and PostgreSQL for Maximum Performance
Introduction: Why Redis Alone Is Not Enough
Redis is an excellent tool, and most Laravel applications use it as their sole caching layer. But once an application starts handling thousands of requests per second, Redis alone begins to fall short. The reasons vary: network latency on every Redis call (even 0.5–2 ms per request adds up to hundreds of milliseconds under load), cold-start issues when large keys are invalidated, and situations where aggregated data is cheaper to store at the database level in precomputed form.
Multi-level caching solves this problem through a hierarchy of stores with different access speeds and costs. The classic scheme looks like this:
- L0 — in-process cache (memory driver, Octane-compatible): nanosecond access, lives within a single worker.
- L1 — Redis: microsecond network access, shared across all workers.
- L2 — PostgreSQL Materialized Views: precomputed aggregates stored directly in the database, refreshed on a schedule.
In this article, we will build such a system in a Laravel application step by step — from architecture to metrics and anti-patterns.
L1/L2 Architecture: Three Caching Levels
L0 — In-Process Cache (array driver / Octane)
If you use Laravel Octane (Swoole or RoadRunner), workers persist between requests. This allows storing "hot" data directly in PHP process memory via the array driver with no network overhead. Access time is in the range of single microseconds. The downside: data is isolated within the worker and is lost when the worker restarts.
L1 — Redis
Redis serves as a shared cache for all workers and servers. It stores serialized PHP objects, supports TTL, atomic operations, and Pub/Sub for invalidation. Access latency is 0.1–2 ms depending on the network.
L2 — PostgreSQL Materialized Views
A Materialized View is a physically stored result of a SQL query. Unlike a regular VIEW, the data is stored on disk and is not recomputed on every SELECT. For aggregated statistics (sales totals, top products, dashboards), this is an ideal L2 layer: the data is already in the database, and there is no need to transfer large volumes over the network from Redis.
Implementing a Cache Manager in Laravel
Custom Multi-Level Driver
Laravel allows registering custom drivers via Cache::extend(). Let's create a TieredCacheStore class that implements fallback logic: first checking L0, then L1 (Redis), then L2 (PostgreSQL), and on a miss, populating the upper levels.
<?php
namespace App\Cache;
use Illuminate\Cache\Repository;
use Illuminate\Contracts\Cache\Store;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TieredCacheStore implements Store
{
private array $l0 = [];
private Repository $l1; // Redis
private int $l0Ttl;
private int $l1Ttl;
public function __construct(Repository $l1, int $l0Ttl = 5, int $l1Ttl = 300)
{
$this->l1 = $l1;
$this->l0Ttl = $l0Ttl;
$this->l1Ttl = $l1Ttl;
}
public function get($key): mixed
{
// L0: in-process
if (isset($this->l0[$key]) && $this->l0[$key]['expires_at'] > now()->timestamp) {
return $this->l0[$key]['value'];
}
// L1: Redis
$value = $this->l1->get($key);
if ($value !== null) {
$this->storeL0($key, $value);
return $value;
}
// L2: PostgreSQL materialized view or slow query
$value = $this->fetchFromL2($key);
if ($value !== null) {
$this->l1->put($key, $value, $this->l1Ttl);
$this->storeL0($key, $value);
}
return $value;
}
private function storeL0(string $key, mixed $value): void
{
$this->l0[$key] = [
'value' => $value,
'expires_at' => now()->timestamp + $this->l0Ttl,
];
}
private function fetchFromL2(string $key): mixed
{
// Example: key pattern "catalog:category:{id}:top"
if (preg_match('/^catalog:category:(\d+):top$/', $key, $m)) {
return DB::select(
'SELECT * FROM mv_category_top_products WHERE category_id = ?',
[(int) $m[1]]
);
}
return null;
}
public function put($key, $value, $seconds): bool
{
$this->storeL0($key, $value);
return $this->l1->put($key, $value, $seconds);
}
public function forget($key): bool
{
unset($this->l0[$key]);
return $this->l1->forget($key);
}
// Remaining Store methods delegate to L1
public function many(array $keys): array { return $this->l1->many($keys); }
public function putMany(array $values, $seconds): bool { return $this->l1->putMany($values, $seconds); }
public function increment($key, $value = 1): int|bool { return $this->l1->increment($key, $value); }
public function decrement($key, $value = 1): int|bool { return $this->l1->decrement($key, $value); }
public function forever($key, $value): bool { return $this->l1->forever($key, $value); }
public function flush(): bool { $this->l0 = []; return $this->l1->flush(); }
public function getPrefix(): string { return 'tiered:'; }
}
Registering the Driver in a ServiceProvider
<?php
namespace App\Providers;
use App\Cache\TieredCacheStore;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
class TieredCacheServiceProvider extends ServiceProvider
{
public function boot(): void
{
Cache::extend('tiered', function ($app) {
$l1 = Cache::store('redis');
return Cache::repository(new TieredCacheStore($l1, l0Ttl: 5, l1Ttl: 300));
});
}
}
After registration, add the following to config/cache.php:
'stores' => [
// ...
'tiered' => [
'driver' => 'tiered',
],
],
'default' => env('CACHE_DRIVER', 'tiered'),
PostgreSQL Materialized Views as an L2 Cache
Creating a Materialized View for the Product Catalog
Let's create a materialized view that precomputes the top 20 products per category with aggregated ratings and order counts:
CREATE MATERIALIZED VIEW mv_category_top_products AS
SELECT
p.category_id,
p.id AS product_id,
p.name,
p.price,
p.slug,
COALESCE(AVG(r.rating), 0)::NUMERIC(3,2) AS avg_rating,
COUNT(DISTINCT oi.id) AS total_orders,
ROW_NUMBER() OVER (
PARTITION BY p.category_id
ORDER BY COUNT(DISTINCT oi.id) DESC, AVG(r.rating) DESC
) AS rank
FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
LEFT JOIN order_items oi ON oi.product_id = p.id
WHERE p.is_active = true
GROUP BY p.category_id, p.id, p.name, p.price, p.slug
HAVING ROW_NUMBER() OVER (
PARTITION BY p.category_id
ORDER BY COUNT(DISTINCT oi.id) DESC
) <= 20
WITH DATA;
CREATE UNIQUE INDEX ON mv_category_top_products (category_id, product_id);
CREATE INDEX ON mv_category_top_products (category_id, rank);
The unique index is required to use REFRESH MATERIALIZED VIEW CONCURRENTLY — it allows updating the view without blocking reads.
Cache Invalidation: An Event-Driven Approach
Observer + Redis Pub/Sub
Invalidation is the most complex part of multi-level caching. We use the Observer pattern in Laravel to track model changes and Redis Pub/Sub to broadcast invalidation signals to all workers.
<?php
namespace App\Observers;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
class ProductObserver
{
public function saved(Product $product): void
{
$this->invalidateProductCache($product);
}
public function deleted(Product $product): void
{
$this->invalidateProductCache($product);
}
private function invalidateProductCache(Product $product): void
{
$keys = [
"catalog:category:{$product->category_id}:top",
"product:{$product->id}",
"product:{$product->id}:details",
];
// Remove from Redis (L1)
foreach ($keys as $key) {
Cache::store('redis')->forget($key);
}
// Publish event to invalidate L0 across all workers
Redis::publish('cache:invalidate', json_encode([
'keys' => $keys,
'category_id'=> $product->category_id,
'timestamp' => now()->toISOString(),
]));
}
}
Subscriber for L0 Invalidation in Octane
In the Octane worker, we run a background Redis Pub/Sub listener that clears L0 upon receiving an event:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class CacheInvalidationListenerCommand extends Command
{
protected $signature = 'cache:listen-invalidation';
protected $description = 'Subscribe to Redis Pub/Sub for L0 cache invalidation';
public function handle(): void
{
$this->info('Listening for cache invalidation events...');
Redis::subscribe(['cache:invalidate'], function (string $message) {
$payload = json_decode($message, true);
// Access the TieredCacheStore singleton
// and clear its L0 array for the specified keys
app('cache.tiered')->forgetL0($payload['keys']);
$this->line('Invalidated: ' . implode(', ', $payload['keys']));
});
}
}
Refreshing Materialized Views on a Schedule
PostgreSQL Materialized Views do not update automatically — they must be refreshed explicitly. We use the Laravel Scheduler for periodic refresh without blocking readers:
<?php
// app/Console/Kernel.php or routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
// CONCURRENTLY does not block SELECT during the refresh
DB::statement('REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_top_products');
// After refresh, invalidate L1 for all affected keys
$categories = DB::table('mv_category_top_products')
->distinct()
->pluck('category_id');
foreach ($categories as $categoryId) {
Cache::store('redis')->forget("catalog:category:{$categoryId}:top");
}
logger()->info('Materialized view refreshed', ['categories' => $categories->count()]);
})->everyFiveMinutes()->withoutOverlapping()->name('refresh-mv-category-top');
// Aggregated statistics — refreshed less frequently as it is more costly
Schedule::call(function () {
DB::statement('REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sales_stats_daily');
})->hourly()->withoutOverlapping()->name('refresh-mv-sales-stats');
Important:
REFRESH MATERIALIZED VIEW CONCURRENTLYrequires a unique index on the view and runs in a separate transaction. Old data remains available for reading until the refresh is complete.
Cache Performance Metrics
Measuring Hit Rate and Latency
Without metrics, it is impossible to know whether your caching system is working. Let's add instrumentation directly to TieredCacheStore:
<?php
// Add to TieredCacheStore
private array $stats = ['l0_hits' => 0, 'l1_hits' => 0, 'l2_hits' => 0, 'misses' => 0];
public function get($key): mixed
{
$start = hrtime(true);
// L0
if (isset($this->l0[$key]) && $this->l0[$key]['expires_at'] > now()->timestamp) {
$this->stats['l0_hits']++;
$this->recordLatency('l0', hrtime(true) - $start);
return $this->l0[$key]['value'];
}
// L1
$value = $this->l1->get($key);
if ($value !== null) {
$this->stats['l1_hits']++;
$this->storeL0($key, $value);
$this->recordLatency('l1', hrtime(true) - $start);
return $value;
}
// L2
$value = $this->fetchFromL2($key);
if ($value !== null) {
$this->stats['l2_hits']++;
$this->l1->put($key, $value, $this->l1Ttl);
$this->storeL0($key, $value);
$this->recordLatency('l2', hrtime(true) - $start);
return $value;
}
$this->stats['misses']++;
return null;
}
private function recordLatency(string $level, int $nanoseconds): void
{
$ms = $nanoseconds / 1_000_000;
// Send to StatsD, Prometheus, or simply log
logger()->debug("Cache hit [{$level}]", ['latency_ms' => $ms]);
}
public function getStats(): array
{
$total = array_sum($this->stats);
if ($total === 0) return $this->stats;
return array_merge($this->stats, [
'hit_rate' => round(($total - $this->stats['misses']) / $total * 100, 2),
'l0_hit_rate' => round($this->stats['l0_hits'] / $total * 100, 2),
'l1_hit_rate' => round($this->stats['l1_hits'] / $total * 100, 2),
]);
}
Target Performance Benchmarks
- L0 (array/Octane): latency <0.01 ms, target hit rate — 60–70% for hot keys.
- L1 (Redis): latency 0.1–2 ms, target hit rate — 25–35% of remaining requests.
- L2 (PostgreSQL MV): latency 1–10 ms, covers the remaining 5–10%.
- Cache miss (cold query): 50–500 ms — should occur rarely.
Practical Use Cases
Scenario 1: E-Commerce Product Catalog
A category page displays 20 products sorted by popularity. Data changes with every purchase or review. Strategy:
- L0 stores the result for 5 seconds — protects against traffic spikes on a single page.
- L1 (Redis) stores data for 5 minutes — a shared cache for all servers.
- L2 (MV) is refreshed every 5 minutes via the Scheduler and serves as the source for warming L1.
Scenario 2: Dashboard with Aggregated Statistics
Sales graphs, DAU, and hourly revenue. The data is expensive to compute (JOINs across several large tables), but a staleness window of 15–60 minutes is acceptable:
- Create materialized views
mv_sales_stats_dailyandmv_hourly_revenue. - Refresh them every hour via
REFRESH MATERIALIZED VIEW CONCURRENTLY. - Redis stores API response results for 30 minutes.
- L0 is not used — data is rarely requested more than once within a single worker's lifetime.
Pitfalls and Anti-Patterns
1. Cache Stampede
When a large key expires simultaneously, hundreds of requests attempt to recompute it. The solution is Cache::lock() (an atomic lock via Redis) or the probabilistic early expiration pattern.
$value = Cache::remember('expensive:key', 300, function () {
// Only one worker enters here thanks to the lock inside remember
return DB::select('...');
});
// Or explicitly:
$lock = Cache::lock('lock:expensive:key', 10);
if ($lock->get()) {
try {
$value = computeExpensiveValue();
Cache::put('expensive:key', $value, 300);
} finally {
$lock->release();
}
}
2. Too Short a TTL at L0
A TTL of 1–2 seconds at L0 under high RPS provides little benefit — the cache doesn't have time to absorb the load. For stable data (configuration, lookup tables), use 30–60 seconds.
3. Pattern-Based Invalidation (KEYS/SCAN)
Never use Redis::keys('catalog:*') in production — it is a blocking operation. Use explicit keys or tags via Cache::tags() (supported only with Redis and Memcached).
4. Ignoring Materialized View Staleness
A Materialized View is a snapshot of data. If the business requires real-time accuracy, MV is not suitable as L2. Use Redis Sorted Sets or indexed PostgreSQL queries instead of MV.
5. No Cache Warming
After a deploy or flush, all levels are empty. Implement a warm-up command that runs after deployment and populates L1 with data from L2:
// artisan cache:warm
$categories = DB::table('categories')->pluck('id');
foreach ($categories as $id) {
$data = DB::select('SELECT * FROM mv_category_top_products WHERE category_id = ?', [$id]);
Cache::store('redis')->put("catalog:category:{$id}:top", $data, 300);
}
Summary
Multi-level caching in Laravel with Redis as L1 and PostgreSQL Materialized Views as L2 is not over-engineering — it is a necessity for high-load applications. A properly configured cache hierarchy allows you to achieve a hit rate above 95%, reduce average response latency from 200–500 ms to 5–20 ms, and reduce the load on your primary database by a factor of 10–50.
Key principles to keep in mind: each level must have a clear responsibility and TTL; invalidation should be event-driven, not timer-based; hit rate and latency metrics are a mandatory part of the system, not an optional add-on. Start by implementing L1 (Redis) and L2 (MV) for your heaviest queries, measure the impact, and only then add L0 for Octane workers.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →