Redis + Laravel: Smart Caching Strategies for Production

Meta Description: Master Redis in your Laravel projects. Advanced caching strategies, production patterns, and measured performance gains from real-world experience.

Category: Laravel & Performance

Tags: Redis, Cache, Laravel, Performance, Scalability, Production

Suggested Publication Date: 3 weeks ago


Introduction

Redis transformed the way I design Laravel applications. On a recent SaaS marketplace project, I reduced page load time from 2.7 seconds to 180ms simply by implementing a smart caching strategy.

But Redis isn’t magic. Used incorrectly, it can complicate your architecture without adding value. Here’s what I learned after 4 years using it in production.


Why Redis Over Laravel’s Database Cache?

Before I discovered Redis, I was using Laravel’s database cache driver. It worked, but it wasn’t great.

Redis offers:

  • Speed: Data in memory, no disk reads
  • Native TTL: Automatic key expiration
  • Advanced data types: Lists, Sets, Sorted Sets, Hashes
  • Pub/Sub: For broadcasting and queues
  • Atomic operations: Thread-safe increments

My approach now:

  • Redis for application cache and sessions
  • Database cache only in local development (because it requires zero setup)

Production-Ready Installation & Configuration

Installation

composer require predis/predis

config/database.php

'redis' => [
    'client' => env('REDIS_CLIENT', 'predis'),
    
    'options' => [
        'cluster' => env('REDIS_CLUSTER', 'redis'),
        'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
    ],
    
    'default' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_DB', '0'),
        'read_timeout' => 60,
        'retry_interval' => 100,
    ],
    
    'cache' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_CACHE_DB', '1'),
    ],
],

.env (Production)

CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_CLIENT=predis

Strategy #1: Caching Heavy Queries

Queries with multiple JOINs or aggregations are perfect candidates.

The Pattern

public function getDashboardStats(User $user)
{
    return Cache::remember(
        "dashboard.stats.{$user->id}",
        now()->addMinutes(10),
        function() use ($user) {
            return [
                'total_orders' => $user->orders()->count(),
                'revenue' => $user->orders()->sum('total'),
                'pending' => $user->orders()->where('status', 'pending')->count(),
                'completed_this_month' => $user->orders()
                    ->whereMonth('created_at', now()->month)
                    ->where('status', 'completed')
                    ->count(),
            ];
        }
    );
}

Measured Result

  • Without cache: 340ms
  • With cache: 4ms
  • 98.8% improvement

This dashboard stats query runs every time a user refreshes their page. Without cache, it was hitting the database hard. With cache, it’s instant for 10 minutes.


Strategy #2: Cache Tags for Group Invalidation

Cache tags let you flush multiple related keys at once. This is incredibly powerful.

How It Works

// Store with tags
Cache::tags(['posts', 'user:'.$userId])->put(
    "post.{$postId}",
    $post,
    now()->addHour()
);

// Invalidate all posts for a user with one call
Cache::tags(['user:'.$userId])->flush();

Real Example: Blog with Categories

class PostRepository
{
    public function getByCategory(Category $category)
    {
        return Cache::tags(['posts', 'category:'.$category->id])
            ->remember(
                "posts.category.{$category->id}",
                3600,
                fn() => $category->posts()
                    ->with('author')
                    ->latest()
                    ->paginate(20)
            );
    }
    
    public function clearCategoryCache(Category $category)
    {
        Cache::tags(['category:'.$category->id])->flush();
    }
}

When a post is updated, you can flush all caches related to that category without knowing all the specific cache keys.

Why This Saved Me Hours

Before cache tags, I was manually tracking all cache keys related to a resource. It was a nightmare. One missed key and users saw stale data.

With tags, I just flush by tag and everything updates correctly.


Strategy #3: Cache-Aside Pattern (Most Common)

This is the pattern I use 80% of the time.

The Implementation

class UserRepository
{
    public function find($id)
    {
        $cacheKey = "users.{$id}";
        
        // Try to get from cache
        $user = Cache::get($cacheKey);
        
        if ($user === null) {
            // Not in cache, fetch from database
            $user = User::find($id);
            
            if ($user) {
                // Store in cache for 1 hour
                Cache::put($cacheKey, $user, 3600);
            }
        }
        
        return $user;
    }
    
    public function update($id, array $data)
    {
        $user = User::find($id);
        $user->update($data);
        
        // Immediately invalidate cache
        Cache::forget("users.{$id}");
        
        return $user;
    }
}

Why I Like This Pattern

  • Simple to understand
  • Cache is populated on-demand (lazy loading)
  • Always invalidated on updates
  • No stale data issues

Strategy #4: Caching Configuration/Settings

Settings that rarely change are perfect for long-term cache.

class SettingsService
{
    public function get(string $key, $default = null)
    {
        return Cache::rememberForever(
            "settings.{$key}",
            fn() => Setting::where('key', $key)->value('value') ?? $default
        );
    }
    
    public function set(string $key, $value)
    {
        Setting::updateOrCreate(['key' => $key], ['value' => $value]);
        Cache::forget("settings.{$key}");
    }
    
    public function all()
    {
        return Cache::rememberForever(
            'settings.all',
            fn() => Setting::pluck('value', 'key')->toArray()
        );
    }
}

I use rememberForever() here because settings truly change rarely. But I still invalidate on update.


Strategy #5: Rate Limiting with Redis

Protecting your APIs from abuse is crucial.

Basic Rate Limiting

use Illuminate\Support\Facades\RateLimiter;

// In RouteServiceProvider
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

Custom Rate Limiting Logic

public function sendEmail(User $user)
{
    $key = 'send-email:'.$user->id;
    
    if (RateLimiter::tooManyAttempts($key, 3)) {
        $seconds = RateLimiter::availableIn($key);
        throw new \Exception("Too many emails. Try again in {$seconds} seconds.");
    }
    
    RateLimiter::hit($key, 3600); // 1 hour window
    
    // Send the email
    Mail::to($user)->send(new GenericEmail());
}

This prevents users from spamming emails. 3 emails max per hour.


Strategy #6: Request-Level Caching (Advanced)

For complex queries that get called multiple times during a single HTTP request.

class ProductRepository
{
    private array $requestCache = [];
    
    public function find($id)
    {
        // Check request-level cache first
        if (isset($this->requestCache[$id])) {
            return $this->requestCache[$id];
        }
        
        // Then check Redis cache
        $product = Cache::remember(
            "products.{$id}",
            3600,
            fn() => Product::with(['category', 'brand'])->find($id)
        );
        
        // Store in request cache
        $this->requestCache[$id] = $product;
        
        return $product;
    }
}

Useful when the same product is called multiple times in a single request (e.g., in a loop building a report).


Monitoring and Debugging

Laravel Telescope

Enable cache watcher to see hit/miss rates:

// config/telescope.php
'watchers' => [
    Watchers\CacheWatcher::class => true,
],

Useful Redis Commands

# Clear all cache
php artisan cache:clear

# View Redis keys
redis-cli KEYS "*"

# Monitor in real-time
redis-cli MONITOR

# Get statistics
redis-cli INFO stats

My Debugging Approach

When investigating cache issues:

  1. Check Telescope cache watcher
  2. Look at hit/miss ratio (target >80%)
  3. Identify keys that are never hit (probably wrong keys)
  4. Check TTL settings (too short = cache thrashing, too long = stale data)

My Production Caching Rules

After years of trial and error, these are my golden rules:

  1. Never use rememberForever() on business data (except global settings)
  2. TTL based on change rate: 5min for dashboards, 1h for listings, 24h for static data
  3. Always invalidate cache on UPDATE/DELETE
  4. Use tags to group invalidations
  5. Monitor hit ratio (target >80%)
  6. Prefix keys by context (users., posts., api.)
  7. Document caching strategy in code comments

Common Mistakes to Avoid

Caching sensitive data without encryptionForgetting to invalidate cache on updateTTL too short (overhead) or too long (stale data) ❌ Caching user-specific data with a global keyNot handling cache failures (Redis down = app down?)

Cache Failure Handling

try {
    $data = Cache::remember('key', 3600, fn() => heavyQuery());
} catch (\Exception $e) {
    Log::error('Cache failed: ' . $e->getMessage());
    // Fallback to direct query
    $data = heavyQuery();
}

Always have a fallback. Redis shouldn’t be a single point of failure.


Real-World Example: API Response Caching

This is how I cache API responses in a SaaS I’m working on:

class ApiController extends Controller
{
    public function products(Request $request)
    {
        $cacheKey = 'api.products.' . md5(json_encode($request->all()));
        
        return Cache::tags(['api', 'products'])
            ->remember($cacheKey, 300, function() use ($request) {
                return Product::query()
                    ->when($request->category, fn($q, $cat) => $q->where('category_id', $cat))
                    ->when($request->search, fn($q, $search) => $q->where('name', 'like', "%{$search}%"))
                    ->with(['category', 'images'])
                    ->paginate($request->per_page ?? 20);
            });
    }
}

The cache key includes all request parameters, so different searches get different cache entries. TTL is 5 minutes because product data changes frequently.


Conclusion

Redis isn’t just a cache—it’s a scalability tool. Used correctly, it lets you handle 10x more traffic with the same infrastructure.

My typical ROI: 2-3 days of implementation for 60-90% improvement in response times.

Start small: Cache your heaviest queries first. Measure the impact. Then expand gradually.

Don’t cache everything. Cache intelligently.


Is Your Application Lacking Responsiveness?

I help startups and SMEs optimize their Laravel applications with tailored caching strategies. Let’s discuss your performance needs.

What I offer:

  • Performance audit with caching recommendations
  • Redis setup and configuration
  • Cache invalidation strategy design
  • Monitoring and alerting setup
  • Load testing to validate improvements

Let’s make your app blazing fast!

Leave Your Comment

Table of Contents

Categories

Tags