The convention is simple: every place that writes data knows exactly which cache key that write invalidates, and forgets it right there.
// app/Services/License/StatamicDriver.php
public function create(
string $name,
string $domain,
): License {
try {
$data = $this->http()->post('/sites', [
'name' => $name,
'domain' => $domain,
])->throw()->json('data');
} finally {
Cache::forget($this->cacheKey());
}
return License::fromProvider((array) $data);
}
The finally is there for Chapter 8’s worst case: a create that succeeded at the provider and failed on the way back. If the list stayed cached after that failure, the retry would look for the license in a stale list, not find it, and create a second one. So the list is forgotten whether the call succeeded or not.
delete() forgets the same key. The read, the write, and the invalidation live in one class, a few lines apart, naming the same key. Whoever reads create() a year from now sees all of it without searching for where else that key is touched.
Compare that with a generic “clear related caches” helper that fans out to keys it doesn’t name. It looks more sophisticated. It is also the thing nobody can explain in the middle of an incident. If you can’t trace a cache key back to the write that should clear it, you can’t trust what it holds.
The N+1 You Will Actually Hit
The License API has few queries of its own. Most APIs are the opposite: their time goes to the database, and the first mistake they make there is the same one.
A query that looks reasonable in isolation runs once per row of a collection, when it should run once for the whole collection. Here it is in a sister service that manages domain names, in a command that looks for domains whose subscription has lapsed:
// Bad: a query per iteration
Domain::query()
->where('auto_renew', true)
->lazyById()
->each(function (Domain $domain) {
$active = Subscription::query()
->where('stripe_id', $domain->subscription_id)
->whereNull('ends_at')
->exists();
// ...
});
A thousand domains means a thousand subscription lookups. It doesn’t have to be a lazy relationship hiding behind a property. Any query fired from inside a loop has the same disease.
// Good: one query per batch
Domain::query()
->where('auto_renew', true)
->chunkById(500, function (Collection $domains) {
$active = Subscription::query()
->whereIn(
'stripe_id',
$domains->pluck('subscription_id'),
)
->whereNull('ends_at')
->pluck('stripe_id')
->flip();
// ... flag each domain whose subscription
// is not in $active
});
Five hundred domains now cost two queries instead of five hundred. The fix isn’t free: it holds five hundred models in memory at once. That is nearly always the better trade.
When the query is a relationship, say which ones you need up front. Chapter 5’s list asked only for a count, with withCount. A page that shows the tokens themselves asks for them:
$consumers = Consumer::query()
->with('tokens')
->paginate(25);
Two queries, however many consumers are on the page: one for the consumers and one for all of their tokens. Lazy loading would have run one more for each consumer. This is the other half of the rule from Chapter 5. The Resource uses whenLoaded('tokens') and can’t run a query. The controller decides, here, what is loaded.
The trouble with N+1 is that nobody notices until a job that used to finish in seconds is taking minutes. So make Laravel notice for you:
// app/Providers/AppServiceProvider.php, in boot()
Model::shouldBeStrict(! $this->app->isProduction());
With that line, a lazy-loaded relationship throws an exception in development and in tests. The N+1 fails the build, before any consumer has to wait for it.
shouldBeStrict() turns on two more checks while it is there. Filling an attribute that isn’t fillable throws, where Eloquent would otherwise discard the value without a word, and so does reading an attribute the query never loaded. Chapter 17 meets the first of those. (Laravel documentation: Eloquent: Getting Started › Configuring Eloquent Strictness.)