Most Laravel performance work starts with the database — indexes, eager loading, fixing N+1 queries. That is the right first move. But past a certain traffic level the next win is not another index; it is not running the query at all. This tutorial covers how to cache a Laravel application so that it stays fast and stays correct: where to put the cache, how to name keys so you can actually invalidate them, when to use tags, how to stop a cold key from knocking over your database, and how to tell whether any of it worked.
Assumptions: Laravel 11 or newer, Redis available, and an app that already has its obvious query problems fixed. Caching a slow query is a way of hiding it, not fixing it.
Pick the right store
config/cache.php ships with several drivers. In practice:
redis— the default choice for any app with more than one web node, queue workers, or scheduled tasks. Shared, supports locks, supports tags, survives deploys.database— fine for small apps and for cache locks when you have no Redis. It puts read load back on the thing you are trying to protect, so watch it.file— local disk only. On a multi-node deploy each node gets its own private, divergent cache. Use it for local development, not production.array— in-memory for the current request. This is what you want in most tests.octane— per-worker in-memory cache when running Octane. Extremely fast, but node-local and lost on worker restart, so only for derived data that is cheap to recompute.
A common production layout is two stores: Redis for everything shared, plus a short-lived in-process memo for values read many times in a single request.
// config/cache.php
'stores' => [
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
'lock_connection' => 'default',
],
],
Note the separate cache Redis connection. Keep the cache database apart from queues and sessions: a cache flush should never wipe pending jobs, and a memory-pressure eviction policy that is right for cache (allkeys-lru) is wrong for queues (which need noeviction). If you run one Redis instance for all three, an eviction event silently deletes jobs.
REDIS_CACHE_DB=1
CACHE_PREFIX=pnp_cache
The prefix matters when several environments share an instance. Staging and production pointed at the same Redis with the same prefix will read each other's cache entries.
Memoization: the cheapest cache there is
Before reaching for Redis, check whether the value is simply being computed several times in one request. Laravel's memo driver handles that:
$settings = Cache::driver('memo')->remember('settings', 3600, fn () => Setting::all());
The memo driver stores the value for the lifetime of the current request (or Octane request) only. It costs nothing, needs no invalidation, and removes a surprising amount of repeated work in apps that resolve the same config object in a dozen view components.
Key design decides how hard invalidation will be
The hard part of caching is never writing the value. It is knowing when to throw it away. Key design is what makes that easy or impossible.
Rules that hold up:
- Put every input into the key. If the value depends on tenant, locale and filters, all three belong in the key. A key like
dashboard_statsthat quietly differs per tenant is a data-leak bug waiting to happen. - Namespace by entity.
invoice:{id}:totalsis greppable and predictable.totals_{id}is not. - Hash long or user-supplied fragments.
search:. md5(json_encode($filters)) keeps the key bounded and safe. - Version the key, not the data. Bump a version segment when the shape of the cached value changes so a deploy cannot serve last week's structure to new code.
final class InvoiceTotalsCache
{
private const VERSION = 'v2';
public static function key(Invoice $invoice): string
{
return sprintf('invoice:%s:totals:%s', $invoice->getKey(), self::VERSION);
}
public static function get(Invoice $invoice): array
{
return Cache::remember(
self::key($invoice),
now()->addHour(),
fn () => $invoice->calculateTotals(),
);
}
public static function forget(Invoice $invoice): void
{
Cache::forget(self::key($invoice));
}
}
Putting keys behind a small class means there is exactly one place that knows the key format — which is the only way forget() stays in sync with get() a year later.
Invalidation: events, not hope
The reliable pattern is to invalidate on write, in the model's own lifecycle, so no caller has to remember:
class Invoice extends Model
{
protected static function booted(): void
{
static::saved(fn (Invoice $invoice) => InvoiceTotalsCache::forget($invoice));
static::deleted(fn (Invoice $invoice) => InvoiceTotalsCache::forget($invoice));
}
}
Two things that will bite you here:
- Mass updates skip model events.
Invoice::where(...)->update([...])fires nosavedevent, so nothing is invalidated. Either loop over models when the set is small, or explicitly clear the affected keys after a bulk update. - Related models change derived values. If invoice totals depend on line items, the line item model must invalidate the parent's key too.
When the dependency graph gets wide, stop enumerating keys and switch to a version stamp:
// Any write bumps the stamp; all keys built from it become unreachable at once.
$version = Cache::rememberForever("tenant:{$tenantId}:cache_version", fn () => 1);
$key = "tenant:{$tenantId}:v{$version}:reports:monthly";
// On write:
Cache::increment("tenant:{$tenantId}:cache_version");
This is cheap, works on every driver including ones without tag support, and leaves the old entries to expire on their own TTL. It is the technique to reach for before tags.
Tags, and their sharp edges
Tagged caching works on Redis, DynamoDB and the memcached store:
Cache::tags(['tenant:42', 'reports'])->remember('monthly', 600, $callback);
Cache::tags(['tenant:42'])->flush();
The convenience is real, but know what you are signing up for:
- Tags are not supported on the
fileordatabasestores, so a test suite runningCACHE_STORE=arraybehaves differently from afilefallback. - Tag flushes are more expensive than a single
forget, since the store must track membership sets. - You cannot read a tagged entry without the same tag set;
Cache::get('monthly')will not findCache::tags([...])->get('monthly').
Use tags when you genuinely need to drop a group of unrelated keys. For a single entity's derived values, a versioned key is simpler and faster.
Stampede protection: locks and flexible caching
Here is the failure that turns caching from a win into an outage. A popular key expires. Two hundred concurrent requests all miss, all run the same expensive query, and the database falls over — precisely at peak traffic, because that is when the key is popular. This is a cache stampede.
Laravel gives you two tools.
Cache::lock() for exactly-once work
$lock = Cache::lock('rebuild:sales-report', 30);
if ($lock->get()) {
try {
$report = SalesReport::build();
Cache::put('sales-report', $report, now()->addMinutes(30));
} finally {
$lock->release();
}
}
The 30-second argument is the lock's own TTL: if the process dies mid-build, Redis releases the lock rather than deadlocking the feature forever. block() lets a caller wait for the holder instead of skipping:
Cache::lock('rebuild:sales-report', 30)->block(5, function () {
// Waits up to 5 seconds for the lock, throws LockTimeoutException otherwise.
});
Locks are also the correct tool for non-cache concurrency: preventing two queue workers from processing the same external sync, or guarding a scheduled task that must not overlap.
Flexible caching for stale-while-revalidate
Cache::flexible() gives a value two lifetimes — fresh, then stale-but-usable:
$stats = Cache::flexible('dashboard:stats', [300, 3600], function () {
return DashboardStats::compute();
});
For the first 300 seconds the cached value is served directly. Between 300 and 3600 seconds it is still served immediately, but Laravel dispatches a deferred refresh after the response is sent, so the next request gets a fresh value. Only after 3600 seconds does a request actually block on recomputation.
This is the single highest-value change for a dashboard or pricing page: user-facing latency stays flat, and the expensive computation happens exactly once per refresh window instead of once per concurrent miss. Note that the background refresh rides on Laravel's deferred-function machinery, so it needs a request lifecycle to complete — on an endpoint that streams or terminates early, verify the refresh is actually running.
Caching HTTP responses and queries
Two more layers worth knowing:
Response caching. For fully public, anonymous pages, caching the rendered response in middleware beats caching its ingredients. Be strict about the cache key (path plus query plus locale) and never cache a response for an authenticated user in a shared store unless the key includes the user id — the classic incident in this area is one customer being served another customer's dashboard.
Query-level caching. Resist the urge to wrap every query in remember(). Cache at the boundary of a meaningful unit of work — a report, an API payload, a rendered fragment — rather than at each individual query. Fine-grained query caching produces thousands of keys nobody can invalidate.
Cache in tests
Use the array store in phpunit.xml so tests are isolated and fast:
<env name="CACHE_STORE" value="array"/>
Then assert behaviour explicitly rather than trusting it:
it('invalidates invoice totals on save', function () {
$invoice = Invoice::factory()->create();
InvoiceTotalsCache::get($invoice);
expect(Cache::has(InvoiceTotalsCache::key($invoice)))->toBeTrue();
$invoice->update(['status' => 'paid']);
expect(Cache::has(InvoiceTotalsCache::key($invoice)))->toBeFalse();
});
If your production store supports tags and your test store does not, add at least one integration test against Redis. Otherwise the first time tagged code runs for real is in production.
Measure, or you are guessing
Caching without measurement produces apps that are complicated and slow. Watch three numbers:
- Hit rate. Redis
INFO statsgiveskeyspace_hitsandkeyspace_misses. A hit rate under about 80% on a key you deliberately cached usually means the TTL is too short or the key has too many variants. - Evictions.
evicted_keysclimbing means Redis is under memory pressure and dropping entries early — your TTLs are not what you think they are. Raise memory or cache less. - Endpoint latency, p95 not mean. Caching mostly improves the tail. If p95 did not move, the thing you cached was not the bottleneck.
Laravel Pulse's slow-query and slow-request cards, or Telescope locally, will tell you which endpoints are worth caching before you write any of this code. Cache the two endpoints that actually hurt; leave the rest alone.
A short checklist
- Fix queries first; cache second.
- Separate Redis databases (and eviction policies) for cache, queues and sessions.
- Every key: fully parameterised, namespaced, versioned, built in one place.
- Invalidate in model events; remember that mass updates bypass them.
- Versioned keys before tags; tags only for true group invalidation.
Cache::flexible()for hot read-heavy values,Cache::lock()for expensive rebuilds.- Test with the
arraystore, plus one real Redis test if you use tags. - Track hit rate, evictions and p95 latency after every change.
If an application is slow under load and it is not obvious whether the answer is caching, better indexing, queue offloading or an architectural change, that diagnosis is exactly the kind of work our team does — get in touch and we will look at the traces with you.