rene-roscher / conditional-cache-laravel
Conditional caching for Laravel: only cache a value when it passes validation (Cache::rememberWhen and friends).
Package info
github.com/Rene-Roscher/conditional-cache-laravel
pkg:composer/rene-roscher/conditional-cache-laravel
Requires
- php: ^8.2
- illuminate/cache: ^11.24|^12.0|^13.0
- illuminate/container: ^11.24|^12.0|^13.0
- illuminate/support: ^11.24|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^9.2|^10.0|^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Only cache a value when it is actually worth caching.
Cache::remember() stores whatever the callback returns: an empty array, a ['success' => false]
error payload, a half-broken API response. This package adds Cache::rememberWhen() and friends:
the value is only written to the cache when it passes your validator. If it doesn't, the value is
not cached: you get it back (or a default of your choice), and an optional onInvalid callback
runs.
The idea was proposed for the framework in laravel/framework#55951, which was closed with the suggestion to ship it as a package. This is that package.
Installation
composer require rene-roscher/conditional-cache-laravel
The service provider is auto-discovered. It registers the macros on Illuminate\Cache\Repository,
so they work on the Cache facade, on Cache::store('redis'), on Cache::tags([...]) and on any
injected Illuminate\Contracts\Cache\Repository.
Requires PHP 8.2+ and Laravel 11.24+, 12 or 13.
| Macro | Like | Stores valid values |
|---|---|---|
rememberWhen |
remember |
with a TTL |
rememberForeverWhen |
rememberForever |
forever |
flexibleWhen |
flexible |
stale-while-revalidate |
Usage
rememberWhen
Cache::rememberWhen( string|UnitEnum $key, Closure|DateTimeInterface|DateInterval|int|null $ttl, Closure $callback, callable|string|null $validator = null, // fn (mixed $value): bool, or an invokable class name ?callable $onInvalid = null, // fn (mixed $value, string $key): void mixed $default = null, // a value, or fn (mixed $value, string $key): mixed DateTimeInterface|DateInterval|int|null $retryAfter = null, // requires a default ?int $lock = null, // seconds ): mixed
- If the key is in the cache, the cached value is returned. The callback and validator don't run.
- Otherwise the callback runs and its result goes to the validator.
- If the validator returns
true, the value is cached (likeremember()) and returned. - If it returns
false, nothing is cached, aCacheValueRejectedevent is dispatched,onInvalidruns, and thedefaultis returned (or the rejected value, if there is no default). The next call runs the callback again.
The later parameters are easiest to use as named arguments, so you only pass what you need:
$data = Cache::rememberWhen('api-data', 3600, fn () => $api->fetch(), validator: fn ($v) => ($v['success'] ?? false) === true, default: ['success' => false, 'data' => []], );
A default instead of the invalid value
Pass default to get a clean value back instead of the broken one. It is never cached.
// A plain value $rates = Cache::rememberWhen('rates', 600, fn () => $api->rates(), filled(...), default: []); // A closure: only called when the validation fails, gets the rejected value and the key $rates = Cache::rememberWhen('rates', 600, fn () => $api->rates(), filled(...), default: fn ($value, $key) => config('rates.defaults'), );
- A closure
defaultis lazy, so an expensive fallback (a DB query, a config lookup) only runs when it's needed. - Because
nullmeans "no default", usedefault: fn () => nullif you really wantnullback. - Without a
default, you get the rejected value back, likeremember()would return it.
Reacting to invalid values: onInvalid
onInvalid is for side effects (logging, reporting, alerting). Its return value is ignored. It
runs before the default is resolved.
$data = Cache::rememberWhen('api-data', 3600, fn () => Http::get('api.example.com/data')->json(), fn ($value) => ($value['success'] ?? false) === true, onInvalid: fn ($value, string $key) => Log::warning("Not caching [{$key}]", ['payload' => $value]), default: [], ); // Or fail loudly instead of returning anything Cache::rememberWhen('rates', 600, fn () => $api->rates(), filled(...), onInvalid: fn () => throw new RatesUnavailable);
Reusable validators
Instead of a closure you can pass the class name of an invokable class. It's resolved from the container, so it can use dependency injection:
class SuccessfulApiResponse { public function __invoke(mixed $value): bool { return is_array($value) && ($value['success'] ?? false) === true; } } Cache::rememberWhen('api-data', 3600, fn () => Http::get('...')->json(), SuccessfulApiResponse::class);
Default validator
Without a validator, only filled() values are
cached. null, '', whitespace-only strings and empty arrays/collections are not cached.
0 and false are cached.
$user = Cache::rememberWhen("github-user:{$name}", 3600, fn () => $github->user($name));
Named arguments
Named arguments work through the facade:
Cache::rememberWhen( key: 'api-data', ttl: now()->addHour(), callback: fn () => Http::get('...')->json(), validator: fn ($value) => $value['success'] ?? false, onInvalid: fn ($value) => report(new InvalidApiResponse($value)), default: [], ); // Default validator, only a default value Cache::rememberWhen('api-data', 3600, fn () => $api->fetch(), default: []);
TTL based on the value
As with remember(), the TTL can be a closure that receives the (validated) value:
Cache::rememberWhen( 'token', fn (array $token) => $token['expires_in'] - 60, fn () => $oauth->fetchToken(), fn ($token) => isset($token['access_token']), );
Don't hammer a broken upstream: retryAfter
By default nothing is cached when the value is rejected, so every request runs the callback again.
If the API is down, that means every request hits it. With retryAfter the resolved default is
kept for that long and returned without running the callback, onInvalid or a closure default
again:
$data = Cache::rememberWhen( 'api-data', 3600, fn () => Http::get('api.example.com/data')->json(), fn ($value) => ($value['success'] ?? false) === true, default: ['success' => false, 'data' => []], retryAfter: 30, // retry the API at most every 30 seconds while it's failing );
retryAfterrequires adefault, because that's what's returned while waiting. This way a rejected value is never stored by accident. If you really want the rejected value back, say so:default: fn ($value) => $value.- The default is stored under a separate key (
conditional-cache:retry:{key}). It never counts as a cache hit for the valid entry, and the first valid value is cached normally. Cache::forget('api-data')also forgets that marker, so you can always force a retry. This works on every store and on tagged caches. (It's done by a listener on Laravel'sForgettingKeyevent, so eachforget()in your app sends one extra, cheap delete for the marker key.)
Only compute once under load: lock
Like remember(), many requests that miss at the same time all run the callback in parallel. For
an expensive or rate-limited upstream, pass lock (in seconds): only one process computes the value,
the others wait for it and then read it from the cache.
$data = Cache::rememberWhen('api-data', 3600, fn () => $api->fetch(), $validator, default: [], lock: 10, // one fetch at a time, others wait up to 10 s );
If the lock can't be acquired within that time, the waiting process computes the value itself, so a stuck lock never breaks a request. It needs a store with lock support (Redis, database, file, array, ...).
rememberForeverWhen
The same as rememberWhen, but valid values are stored with forever():
Cache::rememberForeverWhen('settings', fn () => Setting::all()->pluck('value', 'key'), fn ($s) => $s->isNotEmpty());
flexibleWhen (stale-while-revalidate)
flexibleWhen is the validated version of Laravel's
Cache::flexible(), the "stale-while-revalidate" pattern.
It takes two TTLs, [$fresh, $stale]:
| Age of the cached value | Cache::flexible() |
Cache::flexibleWhen() |
|---|---|---|
| No value yet | Runs the callback, caches the result | Runs the callback, caches it only if valid |
Younger than $fresh |
Returns it | Returns it |
Between $fresh and $stale |
Returns it immediately and refreshes it after the response has been sent | The same, but the refresh only overwrites the value if the new one is valid |
Older than $stale |
Expired, like a miss | Expired, like a miss |
So users never wait for a slow API once the value is cached: they get the slightly stale value
instantly and the refresh happens in the background (via Laravel's defer()).
The problem with plain flexible(): if the API is broken during a background refresh, the broken
response replaces your good value. flexibleWhen keeps the last valid value instead:
$stats = Cache::flexibleWhen( 'dashboard-stats', [300, 3600], // fresh for 5 min, stale for up to 1 h fn () => Http::get('stats.example.com')->json(), fn ($value) => ($value['success'] ?? false) === true, onInvalid: fn ($value, $key) => Log::warning("Refresh for [{$key}] rejected"), default: ['success' => false, 'data' => []], );
- No value yet, invalid response: nothing is stored, the
default(or the rejected value) is returned. - Stale value, invalid refresh: the last valid value stays in the cache and keeps being served.
The refresh runs again on the next stale hit, until a valid value comes back or
$staleruns out. retryAfter: after a rejected refresh, no new refresh is started until the window has passed.- It uses the same keys and lock as
Cache::flexible(), so the two can share an entry.
Signature: flexibleWhen($key, array $ttl, callable $callback, $validator = null, ?callable $onInvalid = null, $default = null, ?array $lock = null, bool $alwaysDefer = false, $retryAfter = null).
Here lock is the same option as in Cache::flexible() (['seconds' => 10, 'owner' => ...]) and
guards the background refresh.
A background refresh doesn't use
default: the request was already answered with the stale value, and a rejected refresh never writes anything.
Listening for rejected values globally
Every rejection dispatches ReneRoscher\ConditionalCache\Events\CacheValueRejected. You can use it
for logging or metrics without adding an onInvalid to every call:
use ReneRoscher\ConditionalCache\Events\CacheValueRejected; Event::listen(function (CacheValueRejected $event) { Log::debug('Cache value rejected', [ 'store' => $event->storeName, 'key' => $event->key, 'method' => $event->method, // rememberWhen | rememberForeverWhen | flexibleWhen ]); });
Notes
nullcan never be cached, inremember()or here, becausenullmeans "not in the cache".- Overhead on a cache hit is about 1 µs compared to
remember()(measured on the array store and on Redis).retryAfterreads the marker in the same round trip (many()), andlockis only taken on a miss. - If you queue a listener for
CacheValueRejected, the rejected value is serialized with the event. Keep that in mind for large or non-serializable values. - If the callback throws, nothing is cached and the exception propagates, the same as
remember(). - The validator gets only the value, so you can pass callables like
is_array(...).onInvalidgets($value, $key). - Invalid arguments (a TTL closure that returns a string, a malformed
[$fresh, $stale]pair, an unknown validator class) throw anInvalidArgumentExceptionright away. - IDE autocompletion: barryvdh/laravel-ide-helper
picks up the macros when it generates
_ide_helper.php. Larastan reads the macros of theCachefacade on its own and takes the parameter types from them. - The macros are registered on
Illuminate\Cache\Repository. Should Laravel ever add a native method with the same name, the native method wins. The test suite checks for that and runs weekly against the latest Laravel releases. flexibleWhenmirrors the bookkeeping ofCache::flexible()(keys, lock anddefer()), which is why Laravel 11.24 is the minimum. The weekly CI run also catches changes there.
Development
composer test # PHPUnit composer analyse # PHPStan (level max) composer format # Laravel Pint composer check # all of the above, style in check mode
StoreIntegrationTest also runs everything against real Redis, database (SQLite) and file stores.
Without a local Redis on 127.0.0.1:6379 its Redis cases are skipped; CI runs them with a Redis
service and fails if it isn't reachable.
License
MIT. See LICENSE.md.
