Skip to main content
Laravel, shipping fast.
Chapter 6 ยท Performance & Optimization

Caching What Is Expensive, Not What Is Easy

Julian Beaujardin

Here's the rule: cache the thing that's slow, not the thing that's convenient to cache. A local Eloquent lookup by primary key is already fast. Caching it saves you nothing and adds a class of bug (stale reads) you didn't have before.

The bearer-token lookup in front of every License API request is genuinely expensive if you don't cache it: EnsureTokenIsValidMiddleware runs before the request reaches a controller, and without help that means a database lookup by token on every single authenticated call.

// api-infrastructure/src/Middleware/EnsureTokenIsValidMiddleware.php
final class EnsureTokenIsValidMiddleware extends VerifyBearerToken
{
    use CachesBearerTokens;

    public function handle(Request $request, Closure $next): Response
    {
        $token = $request->bearerToken();
        // ...
        $foundToken = $this->rememberBearerToken($token);
        // ...
    }
}

use CachesBearerTokens: a shared trait, Webplo\ApiInfrastructure\Traits\CachesBearerTokens, not a local method. Every service that authenticates a bearer token pulls in the same caching logic instead of five slightly different copies drifting apart over time.

rememberBearerToken(): checks the cache for the resolved Bearer model first, and only queries the database on a miss. Same shape as Cache::remember, just wrapped so the cache-vs-database decision isn't repeated at every call site that needs a token.

The TTL isn't a flat number. When the token carries an expiration, the cache duration is calculated from that expires_at minus a five-minute safety buffer, not a fixed hour, so a cached token can never meaningfully outlive the record it was cached from. A flat "cache everything for an hour" TTL would let an expired or revoked token keep authenticating for up to an hour after it should have stopped working. That's not a performance bug, that's a security bug wearing a performance disguise.

The test isn't "can I cache this?" It's "would I notice if this call disappeared for the length of the TTL?" A primary-key lookup, no. A database hit on every authenticated request across the fleet, yes: that's exactly the kind of call worth caching, and exactly why it lives as a shared trait instead of an app-local shortcut.

Cache Invalidation You Can Reason About

Caching is easy. Knowing when to throw it away is the actual problem, and it's the reason "just cache it" is bad advice on its own.

The fleet's convention is boring on purpose: every place that writes data knows exactly which cache key that write invalidates, and calls Cache::forget() on it directly. No tags, no wildcard clears, no invalidation event bus. DependencyHealth (app/Services/DependencyHealth.php) is the clearest example: it tracks per-vendor health as a pair of cache keys, and the method that records a success forgets exactly the keys the method that records a failure set.

// app/Services/DependencyHealth.php
public function recordSuccess(Dependency $dependency): void
{
    Cache::forget($this->downKey($dependency));
    Cache::forget($this->failKey($dependency));
}

public function recordFailure(Dependency $dependency): void
{
    $key = $this->failKey($dependency);

    $failures = Cache::add($key, 1, now()->addSeconds(self::$windowSeconds))
        ? 1
        : (int) Cache::increment($key);

    if ($failures >= self::$failureThreshold) {
        $this->markDown($dependency);
        Cache::forget($key);
    }
}

recordFailure(): counts failures inside a rolling window, and once the count crosses a threshold, marks the dependency down and forgets its own counter key. Its job is done.

recordSuccess(): forgets both the "down" flag and the failure counter, by name, not by guessing. A single healthy call is enough to clear the slate.

The write and the invalidation live in the same class, a few lines apart, naming the exact keys involved. Six months from now, whoever reads recordFailure() sees the whole story without going to find where else those keys get touched.

Compare that to a generic "clear related caches" helper that fans out to keys it doesn't name explicitly. It looks more sophisticated. It's also the thing nobody can explain mid-incident: which caches does this actually clear? A cache key you can't trace back to the write that should clear it is a cache key you can't trust. Name the key, forget it at the write site, done.