Caching Real Queries
What to cache, when to expire it against when to invalidate it, and the stale answer a user must never be shown.
Cache::flexible(string $key, array $ttl, Closure $callback): mixedCaching is a trade: an older answer, faster. The work is deciding which answers may be old, and for how long.
What is worth caching
Three shapes cover most of it. A computed fragment, such as a rendered list of featured products. A number that costs a scan, such as a count or a sum. A lookup that barely changes, such as plan limits. All three are read far more often than they change.
Expiry or invalidation
A TTL is a promise about how stale a value may get. Cache::remember($key, 300, ...) says five minutes of wrong is acceptable. Explicit invalidation promises the value is never wrong: the code that writes the data calls Cache::forget() on the key, or Cache::tags([...])->flush() on the group. Use a TTL for figures nobody audits, and invalidation for anything a user will compare against the source.
The stale answer nobody may see
Never cache per-user data under a shared key. A key like orders.recent, filled by whoever hit the page first, serves one customer's orders to everyone. Put the identity in the key, or do not cache. The same caution applies to anything the user acts on: a price, a balance, a permission.
When a popular key expires, every request that misses runs the closure at once. Cache::flexible($key, [$fresh, $stale], $callback) handles that: inside the first window the cached value is returned, between the two it is still returned and a deferred function refreshes it after the response is sent, and past the second window one request recomputes and waits.
Example
<?php
// app/Support/ProductStats.php
declare(strict_types=1);
namespace App\Support;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
class ProductStats
{
/**
* One place that names the key, so the writer and the reader agree.
*/
public const IN_STOCK_KEY = 'products.in-stock-count';
public function inStockCount(): int
{
return Cache::remember(
self::IN_STOCK_KEY,
300,
fn (): int => Product::where('stock', '>', 0)->count(),
);
}
public function forgetInStockCount(): void
{
Cache::forget(self::IN_STOCK_KEY);
}
}When to use it
- Cache a slow dashboard figure for five minutes rather than per request.
- Keep a featured-products list warm and drop it the moment an admin edits one.
- Stop a nightly report query from running once per visitor when the cache expires.
- Cache a per-user page without leaking it to the next visitor.
More examples
Invalidating from an observer
Generated with php artisan make:observer ProductObserver --model=Product, and attached with #[ObservedBy(ProductObserver::class)] on the model. This moves invalidation off every controller that happens to touch a product. The trade is that it fires on model events only: a raw query builder update or a bulk delete does not reach it.
<?php
// app/Observers/ProductObserver.php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Product;
use App\Support\ProductStats;
use Illuminate\Support\Facades\Cache;
class ProductObserver
{
public function saved(Product $product): void
{
Cache::forget(ProductStats::IN_STOCK_KEY);
}
public function deleted(Product $product): void
{
Cache::forget(ProductStats::IN_STOCK_KEY);
}
}flexible() for a page that must never block
Fresh for 60 seconds. Between 60 and 600 seconds the stale array is returned immediately and the recalculation happens after the response is sent. Past 600 seconds the request pays for the rebuild. Compare with remember(), where the first request after expiry always waits for the query.
use App\Models\Order;
use Illuminate\Support\Facades\Cache;
$stats = Cache::flexible('dashboard.stats', [60, 600], function (): array {
return [
'orders' => Order::count(),
'revenue' => (float) Order::where('status', 'paid')->sum('total'),
];
});The identity belongs in the key
The key carries the user id so two customers cannot see each other's orders, and the tag gives a way to drop all of that user's cached pages when an order changes. Do not cache this branch at all for guests, where there is no id to key on.
// app/Http/Controllers/OrderController.php
public function index(Request $request): View
{
$user = $request->user();
$orders = Cache::tags(['orders', 'user:'.$user->id])->remember(
"orders.recent.{$user->id}",
120,
fn () => $user->orders()->latest()->take(5)->get(),
);
return view('orders.index', ['orders' => $orders]);
}
Discussion