The invoice list for a team is not expensive once. It is expensive on every dashboard refresh, and it is the same total for every member of that team until someone voids an invoice. Caching that total is reasonable. Caching the draft a single user is editing is not: the next request expects the row they just wrote, and a cache makes that look like a bug.
Cache a query when it is expensive, read often, and acceptable to be a few seconds old. Then make the key describe the result, and decide, before you ship it, which write forgets it.
01 Build a key that matches the result
The key has to change whenever the result would change. The team id belongs in it. So does anything you filtered on. A key of outstanding is how one team starts reading another team's total.
// app/Support/OutstandingInvoices.php
namespace App\Support;
use App\Models\Team;
use Illuminate\Support\Facades\Cache;
class OutstandingInvoices
{
public function totalFor(Team $team): int
{
$key = sprintf('team:%d:outstanding', $team->id);
return Cache::remember($key, now()->addMinutes(10), function () use ($team): int {
return (int) $team->invoices()
->where('status', 'open')
->sum('total');
});
}
}Cache::remember() returns the stored value on a hit and runs the closure on a miss, then stores whatever the closure returns. The closure must return the value you want next time, not a query builder. sum() returns a number. Store the number.
Ten minutes is a guess until you have measured the query. A short TTL is the safety net for a forget path you have not written yet. It is not a substitute for forgetting the key when an invoice changes, if the dashboard is supposed to be current.
02 Bust it when the row changes
Invalidate in the model events for saved and deleted, or keep the short TTL and accept the lag. The event version is the one that updates the moment someone voids an invoice. Forget the key after the commit. A forget inside the transaction lets another request read the old row and write the stale total back before the commit lands.
// app/Models/Invoice.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class Invoice extends Model
{
protected static function booted(): void
{
$forget = function (Invoice $invoice): void {
DB::afterCommit(function () use ($invoice): void {
Cache::forget(sprintf('team:%d:outstanding', $invoice->team_id));
});
};
static::saved($forget);
static::deleted($forget);
}
}DB::afterCommit() runs the closure immediately when there is no open transaction. Inside DB::transaction() it waits, and it is dropped if the transaction rolls back. That is the behaviour you want: a rolled-back void should not clear a total that is still correct.
There is a second path the events never see. Invoice::query()->where('team_id', $team->id)->update([...]) does not fire saved. A bulk mark-as-void has to forget the key itself, in the same place it runs the update. If you add that path and forget to forget, the dashboard is wrong until the TTL, and the observer looks guilty.
Check yourself · 1 question
Question 1 of 1You call Cache::forget() on the team total inside the same transaction that voids the invoice, before the commit. Another request can still serve the old total afterwards. Why?
03 What you should not cache
Do not cache a single invoice the user is editing. The next request is a write, or it expects the write, and a cached model hides it. Do not cache across teams to "make the key shorter". Do not cache the Eloquent models themselves unless you have a reason to pay the unserialise cost and you accept that a cached model does not see later casts or appended attributes you add next week. Caching the integer total avoids that.
Cache::remember() has a stampede: the key expires, ten dashboard requests miss together, and ten identical sums run. For a total that takes a few milliseconds, that is noise. For a report that takes seconds, reach for Cache::flexible(), which keeps serving the previous value while one request rebuilds it. The TTL is a pair. The first value is how long the result stays fresh. The second is how long it may still be served after that.
// app/Support/OutstandingInvoices.php
return Cache::flexible(
sprintf('team:%d:outstanding', $team->id),
[60, 600],
function () use ($team): int {
return (int) $team->invoices()->where('status', 'open')->sum('total');
},
);Flexible does not remove the need to forget. A void should still drop the key. The pair only changes what a miss costs.
Tags feel like the elegant forget: tag every team key and flush the tag. They are not on the file and database drivers in a way you can lean on everywhere, and a flush is a blunt instrument once more than one key shares the tag. A key you can name is a key you can forget. Start there.
04 Edge cases
The key includes the team id and nothing else, because the query has no other input. The moment you add a status filter from the request, that status has to join the key. Otherwise the open total and the void total share a slot.
team_id on a newly created invoice is available in saved, including when the invoice was created through $team->invoices()->create(). If a job moves an invoice between teams, you have to forget both the old team and the new one. saved sees the new team_id. The old one is $invoice->getOriginal('team_id').
$previous = $invoice->getOriginal('team_id');
DB::afterCommit(function () use ($invoice, $previous): void {
Cache::forget(sprintf('team:%d:outstanding', $invoice->team_id));
if ($previous !== null && (int) $previous !== $invoice->team_id) {
Cache::forget(sprintf('team:%d:outstanding', $previous));
}
});A cache you cannot bust from a test is a bug with a long life. Write the key yourself, update the model, and assert the key has gone. That fails when someone removes the saved hook.
05 Testing it
// tests/Feature/OutstandingInvoicesTest.php
use App\Models\Invoice;
use App\Models\Team;
use Illuminate\Support\Facades\Cache;
it('forgets the outstanding total when an invoice is voided', function () {
$team = Team::factory()->create();
$invoice = Invoice::factory()->for($team)->create([
'status' => 'open',
'total' => 1000,
]);
$key = sprintf('team:%d:outstanding', $team->id);
Cache::put($key, 1000, 60);
$invoice->update(['status' => 'void']);
expect(Cache::has($key))->toBeFalse();
});Cache::put() plants the stale value so the test does not depend on a previous remember(). update() fires saved. If the forget moves behind afterCommit and this test uses RefreshDatabase, the outer test transaction is still open, so the callback waits. Assert after the test's transaction commits, or call the forget path in a test that does not wrap the update in a transaction you never commit. Dispatch after commit is the same trap for a job. The cache key has it too.
Check yourself · 1 question
Question 1 of 1A bulk action runs Invoice::query()->where('team_id', $id)->update(['status' => 'void']). The Invoice model forgets the total on saved(). What does the dashboard show until the TTL?
06 Checklist
- Cache a value that is expensive, shared, and allowed to lag. Do not cache one user's draft.
- Put the team id, and every filter, in the key. Store the total, not the query.
- Forget the key from
savedanddeleted, and do it inDB::afterCommit()so a reader cannot rebuild the stale row. - A query-builder
update()does not fire those events. Forget beside that update. - If the team id can change, forget the original team as well as the new one.
- Plant the key in a test, change the invoice, and assert the key is gone.
07 Further reading
- Dispatch after commit is the same commit race for a queued job.
- Catching N+1 queries is the cost you should measure before you cache.
- Indexes, select and chunking is the version that does not need a cache.
- Cache covers
remember(),flexible(), andadd().