Here's the most important optimization in this chapter: cache tokens in memory instead of querying the database every request.
Let's do the math. Start with a realistic baseline: a service-to-service API handling 60 requests per minute. Each request validates a Bearer token. Without caching:
- 60 requests/min = 60 database queries/min
- Call it 3,600 queries per hour in normal operation
- Multiply across multiple staging environments and you're easily 5-10k queries per hour
Not catastrophic, but also not free. Each query hits the database, uses a connection, adds latency to the response.
But here's where it matters: this is a service-to-service API. Unlike user-facing APIs that serve millions of occasional requests, service-to-service APIs handle fewer distinct callers making repeated requests. A deployment system might poll your API thousands of times per hour. A billing service runs batch operations against your API constantly. Organization integrations hit your endpoints repeatedly throughout the day.
Scale happens fast. That baseline 60 requests/min can easily jump to 600, then 1000+ as more partners integrate. When you reach 1000 requests per minute, the math changes dramatically:
Without caching at 1000 requests/min:
- 1000 requests/min = 1000 database queries/min = 60,000 queries per hour
- Your database connection pool gets stressed
- Each query adds 5-10ms of latency
- Every response is slightly slower
- Under load spikes, database becomes the bottleneck
With caching at 1000 requests/min:
- 1000 requests/min against cache = instant hits (< 1ms)
- Only new tokens cause database queries
- In steady state with repeated callers: roughly 50-100 database queries per hour
- 99% reduction in database load
- Responses stay fast even under 10x traffic spikes
That is where caching becomes critical. You go from a scalability problem to a non-problem. Your database can handle 1000 requests per minute when you're querying it once per hour instead of 60,000 times.
However, earlier in this guide, we emphasized avoiding premature optimization and not solving problems you don't have yet. Token caching is the exception, and here's why. Adding caching costs nothing, a few lines of code in a trait, reused in two places. It adds zero complexity to your system. But it unlocks 10x scalability for free. Most importantly, scaling from 60 requests/min to 1000 requires zero code changes. You deploy the same application. You add more servers, increase your cache TTL, adjust database pool sizes. Infrastructure scaling, not code changes. That's the hallmark of correct architecture: you built it right, so growth is a deployment problem, not a refactoring problem. This is different from speculative architecture built for a problem you might never encounter. Caching is proven, simple, and essential from day one in any authentication system. Build it now and thank yourself later.
Here's how it works:
trait CachesBearerTokens
{
private function rememberBearerToken(string $token): ?Model
{
$cache = $this->resolveBearerCacheRepository();
$cacheKey = $this->buildBearerCacheKey($token);
$cached = $cache->get($cacheKey);
if ($cached instanceof Model && $this->isCachedTokenStillValid($cached, $token)) {
return $cached;
}
// The model class is resolved from config, so a service can supply its
// own without the trait knowing anything about it.
$bearerModel = config('webplo.bearer_model', Bearer::class);
/** @var class-string<Model> $bearerModel */
$bearer = $bearerModel::where('token', $token)->first();
if ($bearer === null) {
return null;
}
$cache->put($cacheKey, $bearer, $this->calculateBearerCacheTTL($bearer));
return $bearer;
}
private function resolveBearerCacheRepository(): Repository
{
$store = config('webplo.bearer_cache_store');
return is_string($store) && $store !== ''
? Cache::store($store)
: Cache::store();
}
}
private function calculateBearerCacheTTL(Model $bearer): int
{
/** @var \DateTimeInterface|string|null $expiresAt */
$expiresAt = $bearer->getAttribute('expires_at');
if (! $expiresAt) {
return 3600; // No expiry: an hour is long enough to help, short enough to forget.
}
$secondsUntilExpiry = now()->diffInSeconds($expiresAt, absolute: false);
$ttlConfig = config('webplo.bearer_cache_ttl', 'token_expiration');
return match (true) {
is_int($ttlConfig) => $ttlConfig,
// Expire the cache entry five minutes BEFORE the token itself.
$ttlConfig === 'token_expiration' => (int) max(1, $secondsUntilExpiry - 300),
default => (int) max(1, min($secondsUntilExpiry, 3600)),
};
}
The buffer is the part worth stealing. Cache a token for exactly as long as it is valid and you have built a race: the entry expires at the same instant the token does, and a request arriving in that window can be served a token that is valid in cache and dead in the database. Subtracting five minutes means the cache always gives up first, and the database is always the one that decides.
The same reasoning explains the revalidation on read. A cached model is only trusted if its token still matches — otherwise a token revoked a minute ago keeps working until its TTL runs out, which is precisely the moment you least want it to.
The beutiful part is this: anyone who needs to the "rememberBearerToken" can call this trait. any middleware. The LicenseServiceProvider uses it. Both hit the same cache key. So:
Request arrives
↓
Middleware calls rememberBearerToken()
→ First request to the token: database query, cache for TTL, return
→ Second request in same hour: cache hit, no database query
↓
LicenseServiceProvider calls rememberBearerToken()
→ Same cache hit from middleware, no additional query
↓
Controller executes with token already validated and configured
One database query, two validations, infinite scalability.