Master Laravel Eloquent Cache-Aside patterns using the Decorator pattern. Ensure data consistency and slash latency with this clean, production-ready guide.
Last month, we faced a classic "stale data" nightmare on our primary user-dashboard service. We had cache-aside logic scattered across five different controllers, and inevitably, someone forgot to clear a key after an update() call, leaving users staring at outdated information for roughly 15 minutes.
If you’re tired of manually managing Cache::forget() calls inside your business logic, it’s time to move toward a more architectural solution. By using the Decorator Pattern to wrap your data access, you can enforce Laravel Eloquent Cache-Aside consistency without polluting your service layer.
We first tried the standard approach: adding Cache::remember() blocks directly inside our repository methods. While it worked for a week, it quickly became a maintenance burden. We needed to keep the database and Redis in sync, but the update() methods were completely decoupled from the get() methods.
We were constantly duplicating logic. If you're building a system where data integrity is non-negotiable, you should consider integrating the Laravel Repository Pattern: Decoupling Data Access from Business Logic to isolate these concerns before applying decorators.
Instead of bloating your models or repositories, define an interface for your data access layer. Then, create a "Cached" implementation that wraps the "Eloquent" implementation.
Here is a simplified look at how we structure this:
PHPinterface UserRepositoryInterface { public function find(int $id): User; } class EloquentUserRepository implements UserRepositoryInterface { public function find(int $id): User { return User::findOrFail($id); } } class CachedUserRepository implements UserRepositoryInterface { public function __construct( protected UserRepositoryInterface $repository, protected \Illuminate\Contracts\Cache\Repository $cache ) {} public function find(int $id): User { return $this->cache->remember("user:{$id}", 3600, function () use ($id) { return $this->repository->find($id); }); } }
By binding UserRepositoryInterface to CachedUserRepository in your AppServiceProvider, your controllers remain completely unaware that caching is even happening. They just call $repository->find($id) and enjoy the speed.
The real challenge with Data Consistency isn't reading; it's writing. If your cache invalidation fails, you have a problem. When dealing with complex state changes, we often lean on the Transactional Outbox Pattern in Laravel: Ensuring Data Consistency to ensure our side effects—like clearing cache keys—are as atomic as possible.
To keep things deterministic, wrap your invalidation logic inside a database transaction callback. This ensures that the cache is only purged if the database operation actually succeeds:
PHPpublic function update(int $id, array $data): User { return DB::transaction(function () use ($id, $data) { $user = $this->repository->update($id, $data); $this->cache->forget("user:{$id}"); return $user; }); }
If the database write fails, the transaction rolls back, and the cache remains untouched. It’s not perfect—network partitions between Redis and your DB can still occur—but it’s significantly more robust than firing forget() calls at the end of a controller method.
CachedUserRepository by mocking the underlying EloquentUserRepository and asserting that the cache is called.We’ve found that for high-traffic endpoints, combining this with Laravel Cache Warming: Predictive Pipelines with Redis Streams provides the best performance. Instead of waiting for a cache miss, the stream pushes the updated record into the cache immediately after the transaction commits.
You’ll notice that this approach introduces a slight overhead in service resolution. We’re essentially adding a layer of indirection. In most Laravel apps, this cost is negligible—measured in microseconds—compared to the latency of a database query. However, if you are working on a system with extreme constraints, you might find that the complexity of maintaining these decorators outweighs the benefits.
I’m still experimenting with how to handle cache tags more effectively within this pattern. Currently, if we need to purge a user's entire related cache (posts, comments, etc.), the decorator starts to get messy with tag arrays. For now, we keep it simple, but I’m looking into implementing a more robust "Invalidator" service that the decorators can inject.
If you’re starting this implementation, start with your most expensive queries. Don't try to wrap everything at once. Build the decorator, bind it, and watch your database CPU usage drop. It’s a clean way to handle Eloquent caching that keeps your codebase readable and your data consistent.
Q: Does this work with Laravel's built-in model caching? A: It works alongside it, but this decorator approach is more explicit. It avoids the "magic" that often makes built-in model caching difficult to debug in production.
Q: How do I handle cache tags with the decorator?
A: You can pass an array of tags to the decorator constructor or define an invalidate() method on your interface that accepts tags, allowing the decorator to handle the Cache::tags([...])->forget() call internally.
Q: Is the Decorator pattern overkill for small apps?
A: Probably. If your app is a simple CRUD dashboard, stick to Cache::remember(). Use this pattern when you have multiple developers working on the same entities and you need to enforce a consistent caching policy across the entire system.
Laravel Proxy Pattern implementations can solve memory bloat in large Eloquent relationships. Learn to build deterministic lazy loading for high-scale apps.