Backend development

PostgreSQL + Redis: Caching Strategies for High-Load Laravel Applications

Ruslan Ismailov Published 10 min read
P

Introduction: Why Caching Matters and When PostgreSQL Starts to Struggle

PostgreSQL is one of the most reliable and feature-rich relational database systems available. It handles transactional workloads, complex JOIN queries, and large datasets with ease. But even the best-tuned database has its limits: when handling hundreds of requests per second against heavy analytical queries, response times begin to climb and server CPU utilization approaches 100%.

Typical symptoms that signal it's time to implement caching:

  • Key query execution times exceed 100–200 ms even with proper indexes in place.

  • The same query runs dozens of times per second with identical parameters.

  • The PostgreSQL server becomes a bottleneck for I/O or CPU under peak load.

  • Horizontal database scaling is impractical or too expensive.

Redis paired with Laravel addresses these problems elegantly: frequently requested data is stored in memory, while PostgreSQL only responds to queries that cannot be served from cache. This is the cornerstone of high-load PHP application architecture in 2026.

The Architectural Role of Redis Alongside PostgreSQL: Caching Patterns

Before writing any code, it's essential to choose the right caching pattern. Each one comes with its own use cases and trade-offs.

Cache-Aside (Lazy Loading)

The most common pattern in Laravel applications. The logic is straightforward: the application checks the cache first, and only on a cache miss does it query PostgreSQL, saving the result to Redis.

$products = Cache::remember('products.catalog.page.'.$page, 3600, function () use ($page) {\n    return Product::with('category')\n        ->where('is_active', true)\n        ->orderBy('created_at', 'desc')\n        ->paginate(20, ['*'], 'page', $page);\n});

Pros: simple to implement; only data that is actually requested gets cached.
Cons: the first request always hits the database (cold start); brief data staleness is possible.

Write-Through

On every write, data is saved simultaneously to both PostgreSQL and Redis. Best suited for data that is read far more often than it is written.

public function updateProduct(int $id, array $data): Product\n{\n    $product = Product::findOrFail($id);\n    $product->update($data);\n    \n    // Synchronously update the cache\n    Cache::put('product.'.$id, $product->fresh(), 3600);\n    Cache::tags(['products'])->flush();\n    \n    return $product;\n}

Read-Through

Caching logic is encapsulated in a dedicated layer (repository or service); the application always works exclusively with the cache, which independently queries PostgreSQL on a miss. In Laravel, this pattern is conveniently implemented via the Repository Pattern with a decorator.

For most Laravel projects, it is recommended to start with Cache-Aside as the most flexible approach, moving to Write-Through for critical reference data.

Configuring Redis in Laravel: Settings, Drivers, phpredis vs predis in 2026

Installation and Basic Configuration

In 2026, the phpredis extension is unequivocally recommended for production environments over the predis package. phpredis is implemented in C, runs significantly faster, and consumes less memory. Predis remains an option only when installing PHP extensions is not possible (e.g., on some managed hosting platforms).

Installing phpredis:

# Ubuntu/Debian\napt install php-redis\n\n# or via PECL\npecl install redis

Configure the connection in config/database.php:

'redis' => [\n    'client' => env('REDIS_CLIENT', 'phpredis'),\n\n    'default' => [\n        'host'     => env('REDIS_HOST', '127.0.0.1'),\n        'password' => env('REDIS_PASSWORD', null),\n        'port'     => env('REDIS_PORT', 6379),\n        'database' => env('REDIS_DB', 0),\n    ],\n\n    'cache' => [\n        'host'     => env('REDIS_HOST', '127.0.0.1'),\n        'password' => env('REDIS_PASSWORD', null),\n        'port'     => env('REDIS_PORT', 6379),\n        'database' => env('REDIS_CACHE_DB', 1), // separate DB for cache\n    ],\n],

In .env:

CACHE_DRIVER=redis\nSESSION_DRIVER=redis\nQUEUE_CONNECTION=redis\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PORT=6379\nREDIS_CACHE_DB=1

Separating Redis Databases

It is critically important to use different Redis database indexes for different purposes: cache, sessions, and queues. This simplifies monitoring and allows you to flush only the relevant area when needed, without affecting other systems.

Caching PostgreSQL Queries with Laravel Cache: Tags, TTL, and Invalidation

Cache Tags

Cache Tags are a powerful tool for group invalidation. Instead of manually deleting each key, you simply flush an entire tag.

// Storing with tags\n$users = Cache::tags(['users', 'admin'])->remember(\n    'users.admin.list',\n    now()->addHour(),\n    fn() => User::role('admin')->with('permissions')->get()\n);\n\n// Invalidating the entire tag when a user changes\npublic function boot(): void\n{\n    User::saved(function (User $user) {\n        Cache::tags(['users'])->flush();\n    });\n}

Note: Cache Tags are only available with the Redis and Memcached drivers. They are not supported when using the file or database driver.

Flexible TTL Management

Set TTL values based on how frequently data changes:

  • Reference data (categories, settings): 24 hours or more.

  • Product catalog: 1–4 hours.

  • Aggregated statistics: 5–15 minutes.

  • User-specific data: 1–30 minutes depending on freshness requirements.

Invalidation via Model Events

Automatic invalidation on model changes is a reliable way to prevent stale data:

// app/Observers/ProductObserver.php\nclass ProductObserver\n{\n    public function saved(Product $product): void\n    {\n        Cache::tags(['products'])->flush();\n        Cache::forget('product.'.$product->id);\n    }\n\n    public function deleted(Product $product): void\n    {\n        Cache::tags(['products'])->flush();\n        Cache::forget('product.'.$product->id);\n    }\n}\n\n// app/Providers/AppServiceProvider.php\nProduct::observe(ProductObserver::class);

Caching Sessions and Queues in Redis

Sessions in Redis

Moving sessions from the filesystem or PostgreSQL to Redis provides a noticeable performance boost when scaling horizontally. All application nodes gain access to a single session store without any file synchronization required.

// config/session.php\n'driver'     => env('SESSION_DRIVER', 'redis'),\n'lifetime'   => env('SESSION_LIFETIME', 120),\n'connection' => 'session', // dedicated Redis connection

Queues on Redis

Laravel's Redis Queue delivers high throughput for background jobs. Compared to the database queue driver, Redis operates orders of magnitude faster thanks to atomic list operations.

// .env\nQUEUE_CONNECTION=redis\n\n// dispatching a job\nProcessOrderJob::dispatch($order)->onQueue('orders');\n\n// starting workers\nphp artisan queue:work redis --queue=orders,default --tries=3 --timeout=60

Practical Example: Caching a Complex PostgreSQL Query with Automatic Invalidation

Let's look at a real-world scenario: a dashboard displaying aggregated sales statistics that involves multiple JOINs and window functions in PostgreSQL.

// app/Services/SalesDashboardService.php\nclass SalesDashboardService\n{\n    private const CACHE_TTL = 900; // 15 minutes\n    private const CACHE_TAG = 'sales_dashboard';\n\n    public function getSalesStats(int $userId, string $period): array\n    {\n        $cacheKey = "sales.stats.{$userId}.{$period}";\n\n        return Cache::tags([self::CACHE_TAG, 'user.'.$userId])\n            ->remember($cacheKey, self::CACHE_TTL, function () use ($userId, $period) {\n                return DB::select(\n                    "SELECT\n                        DATE_TRUNC(:period, o.created_at) AS period_date,\n                        COUNT(o.id) AS orders_count,\n                        SUM(o.total_amount) AS revenue,\n                        AVG(o.total_amount) AS avg_order_value,\n                        RANK() OVER (ORDER BY SUM(o.total_amount) DESC) AS revenue_rank\n                    FROM orders o\n                    INNER JOIN order_items oi ON oi.order_id = o.id\n                    WHERE o.user_id = :user_id\n                        AND o.created_at >= NOW() - INTERVAL '1 year'\n                        AND o.status = 'completed'\n                    GROUP BY DATE_TRUNC(:period, o.created_at)\n                    ORDER BY period_date DESC",\n                    ['period' => $period, 'user_id' => $userId]\n                );\n            });\n    }\n\n    public function invalidateUserCache(int $userId): void\n    {\n        Cache::tags(['user.'.$userId])->flush();\n    }\n}

Invalidation when an order is updated:

// app/Observers/OrderObserver.php\nclass OrderObserver\n{\n    public function __construct(\n        private SalesDashboardService $dashboardService\n    ) {}\n\n    public function saved(Order $order): void\n    {\n        // Invalidate only the specific user's cache\n        $this->dashboardService->invalidateUserCache($order->user_id);\n        \n        // Also flush the global statistics cache\n        Cache::tags([SalesDashboardService::CACHE_TAG])->flush();\n    }\n}

This architecture reduces the load on PostgreSQL for repeated dashboard requests from ~150 ms down to ~2 ms by serving responses directly from Redis.

Monitoring Redis and PostgreSQL: How to Identify Bottlenecks

Redis Monitoring

Key Redis metrics to watch:

  • hit_rate (keyspace_hits / (keyspace_hits + keyspace_misses)) — should be above 80–90% in a stable system.

  • used_memory — avoid exceeding maxmemory, otherwise Redis will start evicting keys according to the eviction policy.

  • connected_clients — an abnormal increase may indicate a connection leak.

  • latency — average command execution time; the norm is < 1 ms.

# Monitoring via redis-cli\nredis-cli INFO stats | grep keyspace\nredis-cli INFO memory | grep used_memory_human\nredis-cli MONITOR  # caution: puts significant load on Redis itself\nredis-cli --latency -h 127.0.0.1

For production, integration with Prometheus + Redis Exporter is recommended, or use Laravel Telescope to track cache hits/misses at the application level.

PostgreSQL Monitoring

When running alongside Redis, it's important to verify that caching is actually reducing the load on PostgreSQL:

-- Top slow queries (requires pg_stat_statements)\nSELECT query,\n       calls,\n       mean_exec_time,\n       total_exec_time,\n       rows\nFROM pg_stat_statements\nORDER BY mean_exec_time DESC\nLIMIT 20;\n\n-- Active connections\nSELECT count(*), state\nFROM pg_stat_activity\nGROUP BY state;

If the calls count for heavy queries has not decreased after implementing caching, then cache invalidation is happening too frequently or cache keys are being constructed incorrectly.

Laravel Telescope and Debugbar

In development mode, Laravel Telescope provides detailed information about every interaction with Redis and PostgreSQL: which keys are being read, how many cache misses occur, and query execution times. It is an indispensable tool for debugging your caching strategy.

Conclusion: When Caching Helps and When It Gets in the Way

Redis caching is a powerful tool, but it's not a silver bullet. It's important to understand when it delivers real benefits and when it creates additional problems.

Caching helps when:

  • Data is read far more frequently than it is changed.

  • Fetching or computing data from PostgreSQL takes a noticeable amount of time.

  • Identical queries are executed repeatedly with the same parameters.

  • Horizontal scaling is required without increasing the load on the database.

Caching gets in the way when:

  • Data changes very frequently and freshness requirements are strict — the cache will be constantly invalidated, creating overhead.

  • Invalidation logic is complex and tangled — there is a high risk of serving stale data.

  • The volume of cached data is massive relative to available Redis memory — eviction will reduce the hit rate.

  • Queries are always unique (e.g., complex filters with a large number of combinations) — the cache will almost never produce a hit.

An effective caching strategy in Laravel applications is built iteratively: first measure actual bottlenecks using pg_stat_statements and Laravel Telescope, then cache only what is genuinely straining PostgreSQL, and carefully design your invalidation logic. When applied correctly, the PostgreSQL + Redis combination can handle workloads 10–50 times greater than what the system could manage without caching.

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 →