foxws/laravel-modelcache
Cache Eloquent model queries and results with a simple, fluent API for Laravel. Reduce database load by storing frequently accessed model data in your cache store, with automatic invalidation on updates. Lightweight, easy to configure, and works with any Laravel cache driver.
Install and configure (5 minutes)
composer require foxws/laravel-modelcache
php artisan vendor:publish --tag="modelcache-config" # optional but do it
MODEL_CACHE_ENABLED=true
MODEL_CACHE_STORE=redis # set explicitly; do not inherit CACHE_STORE
MODEL_CACHE_LIFETIME=604800 # seconds; default is one week
No migrations, no provider registration, no queue workers. Add the trait to the one or two models you actually intend to cache:
use Foxws\ModelCache\Concerns\InteractsWithModelCache;
class Video extends Model
{
use InteractsWithModelCache;
}
Where to look first, in this order
docs/usage.md — the trait methods and their signatures.docs/facade.md — ModelCache:: surface for use outside models (controllers, jobs, listeners).docs/customization.md — shouldModelCache(), cacheNameSuffix(), CacheProfile.config/modelcache.php after publishing — the cache_profile, hasher, and serializer keys.src/ — it is a handful of files. Read it before your first PR. The package description on the repo landing page contradicts the docs on two significant points (see Gotchas), and the only way to settle that is the source.First use case: resume-where-you-left-off
The canonical fit is a small scalar attached to a record that is nice to keep but not worth a column:
// on write (e.g. from a heartbeat endpoint)
$video->modelCache('playback_position', $seconds);
// on read, always with a working default
$position = $video->modelCached('playback_position', 0);
That is the whole feature. No table, no index, no backfill, and if the cache is cold the user starts at zero — a cosmetic regression, not an incident. Start here, ship it behind the MODEL_CACHE_ENABLED flag, and only then widen to other keys.
Read-through helpers on the model. Don't scatter raw modelCached() calls through controllers. Put the semantics on the model so the default and the key name live in one place:
public function playbackPosition(): int
{
return $this->modelCached('playback_position', 0);
}
public function recordPlayback(int $seconds): void
{
$this->modelCache('playback_position', $seconds);
}
This also gives you a single grep target when you need to audit or rename a key.
Memoizing an expensive computation. modelCacheRemember is the pattern for detail-page stats where you want a cheaper stand-in for a materialized view or a scheduled aggregation:
$stats = $video->modelCacheRemember(
'stats.v1',
fn () => $this->computeStats(),
now()->addDay(),
);
Treat this as a cache of a derived value, never of the model or a collection of models. Version the key name (stats.v1) from day one — see Gotchas.
Per-class values for "one value for everyone on this model type" — a featured seed, a rotating daily pick:
Video::setModelCache('random_seed', 0.42, now()->addWeek());
$seed = Video::getModelCache('random_seed', 0.5);
Video::hasModelCache('random_seed');
Video::forgetModelCache('random_seed');
Note that these static calls still go through the same cache name suffix, so "per class" does not mean "shared between users" unless you override the suffix.
Facade outside models. For controllers, listeners, and anywhere you don't have a model instance handy, or where you want the guard rails inline:
use Foxws\ModelCache\Facades\ModelCache;
if (ModelCache::enabled() && ModelCache::shouldCache($video, 'playback_position', $seconds)) {
ModelCache::cache($video, 'playback_position', $seconds, now()->addDay());
}
ModelCache::getCachedValue($video, 'playback_position');
ModelCache::hasBeenCached($video, 'playback_position');
ModelCache::forget($video, ['playback_position', 'random_seed']);
ModelCache::enabled() is your in-code kill-switch check for write paths that are expensive to compute the value for. Read paths don't need it — they degrade naturally through their defaults.
Per-model policy: shouldModelCache(). Override it to skip keys or values you don't want stored, most commonly a null or a sentinel:
public function shouldModelCache(string $key, mixed $value = null): bool
{
return $value !== null;
}
This is the correct place for "don't cache zero-length strings," "don't cache negative numbers," and "don't cache a debug-only key" — not a wrapper at each call site.
Namespace control: cacheNameSuffix(). The default suffixes by authenticated user ID. Override to '' for values that must be shared, or to $key to separate storage per key:
public function cacheNameSuffix(string $key): string
{
return $key === 'random_seed' ? '' : (string) auth()->id();
}
Anything that must be readable by a queued job, a console command, a guest, or another user needs this override. Treat every override as a design decision you record in the key registry, not a tweak.
App-wide rules: a CacheProfile. When several models need the same TTL and the same "is this cacheable" rule, extend Foxws\ModelCache\CacheProfiles\BaseCacheProfile, implement shouldUseCache() and shouldCacheValue(), and point modelcache.cache_profile at your class. This is what keeps each squad from inventing its own key conventions — own the profile as a shared contract.
Key registry as a real artifact. For every key you introduce, record: model, key name (with version), owner (user-scoped vs. shared), TTL, store, and whether the cache may be the system of record (usually no). Keep this in the repo, in the PR that adds the key. Unowned keys become ghost data with no one able to say whether they're safe to drop.
Testing. The suite runs against the array store by default, which means per-user namespacing and TTL expiry need explicit simulation. Test the modelCacheForget path and the MODEL_CACHE_ENABLED=false path explicitly, and assert that read paths still return their defaults when the cache is empty. For a stale-value bug, the fastest reproduction is usually to forget and re-read.
Values are per-user by default, and that surprises everyone. A value written during user A's request is invisible to user B, to guests, to artisan tinker, and to queued jobs. This is the right default for personalization and the wrong default for anything a job or a scheduled command needs to read. Classify every candidate key as user-scoped or shared before the first PR.
cacheNameSuffix() returning '' is a security decision in the shared direction. On a genuinely per-user value it is a cross-user data exposure bug, not a performance regression. Code review this one method harder than everything else in the integration.
The package description lies about two things — verify against source before planning around them. The repo blurb advertises "automatic invalidation on create, update, and delete" and Eloquent query caching. The usage, facade, and customization docs say the opposite: there are no invalidation hooks, it is not a query cache, and it is not for storing models or collections. If a feature's freshness depends on saved/deleted invalidating a cached value, wire the observers yourself (or use a real column). Do not let this drift into the "query cache" role — the docs are explicit and the design won't support it.
Cache-off is silent. MODEL_CACHE_ENABLED=false disables everything with no error. Every read path needs a working default (modelCached($key, $default)), and every write path that computes an expensive value before caching should gate on ModelCache::enabled() first.
Store volatility will make the feature look broken in staging. With array or file, values vanish on deploy or cache:clear. If a value must survive a deploy, it needs Redis or the database store, and that should be an explicit decision per key.
Set MODEL_CACHE_STORE explicitly. It falls back to CACHE_STORE, which means this package's keys compete with session, fragment, and rate-limit keys on the same store — and a future flush of one evicts the others.
A null or false value is not the same as "absent." Distinguish modelCacheHas() / hasBeenCached() from a defaulted read. If you cache false and then read with a default of true, you need to know which one you'll get. shouldCacheValue() on the profile is where you decide that globally.
TTL default is one week — shorter than you think for evolving values. If you change the shape or meaning of playback_position, stale values can live for seven days. Bake a version into the key name (stats.v1) and bump it when semantics change; then the old keys just expire.
Verify the closure signature on modelCacheRemember. The docs show fn () => $this->computeStats($video) — that $video reference suggests the callback may receive the model as an argument. Check the source and use whichever is correct in your version; a callback that silently ignores its argument is easy to write and hard to spot.
The provenance is a real risk, not a footnote. Two stars, zero dependents, single maintainer, and a listed release date that is in the future. Confirm the actual latest tag and diff history in the repo rather than trusting the metadata, pin the version, and budget half a day to read the source. The exit cost is low — a trait, a facade, a profile, a config file, MIT — so vendoring or forking is a reasonable default posture. Keep the call sites concentrated behind model helper methods (see above) so replacing the package later is a small diff rather than a grep expedition.
Debugging a stale value: check which store is actually configured, check whether the writing context had an authenticated user while the reading context did not, then modelCacheForget the key and re-read. Nine times out of ten it's the namespace suffix or the store, not the value logic.
Extension points worth knowing exist: the cache_profile class (app-wide rules), and the swappable hasher and serializer in config. Check that the hasher and serializer are compatible with your existing key prefixes and any tenancy conventions before you turn them on — changing them after keys are in production orphans everything already stored.
How can I help you explore Laravel packages today?