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 encryption ❌ Forgetting to invalidate cache on update ❌ TTL too short (overhead) or too long (stale data) ❌ Caching user-specific data with a global key ❌ Not 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