mike-bronner/laravel-model-caching
Automatic query caching for Laravel Eloquent models. Cache query results and relationships, then invalidate cached data automatically when models change, reducing database load and speeding up repeated reads with minimal code changes.
1. Check compatibility before anything else. This package reaches deep into Eloquent's builder and event system, so the release you install must match your Laravel/PHP major. As of 13.2.0 the floor is PHP 8.3 — on 8.2 Composer silently resolves you to 13.1.11, and you'll spend an hour wondering why the TTL config key does nothing. Verify with php -v and read composer.json constraints in the repo, not just the README. Also note the namespace moved over time — older releases are GeneaLabs\LaravelModelCaching, newer ones are MikeBronner\ModelCaching. Confirm the trait path in vendor/ rather than copy-pasting from a blog post:
composer require mikebronner/laravel-model-caching:^13.2
php -v # must be 8.3+
grep -r "trait Cachable" vendor/mike-bronner vendor/mikebronner vendor/genealabs 2>/dev/null
2. Use a taggable cache store. Invalidation is tag-based, so redis, memcached, or array — not file or database. Set it in .env and in the package config (published via php artisan vendor:publish, then pick the model-caching entry):
CACHE_STORE=redis
3. Add the trait and prove it works. Start with one model, not your base model:
use Illuminate\Database\Eloquent\Model;
use MikeBronner\ModelCaching\Traits\Cachable; // verify exact namespace in vendor/
class Category extends Model
{
use Cachable;
}
Then hit the same query twice and watch the store:
Category::where('active', true)->orderBy('name')->get(); // miss, hits DB
Category::where('active', true)->orderBy('name')->get(); // hit, no DB
Confirm via Redis (redis-cli keys 'mlmc*'), Laravel Telescope, or Debugbar's query count. If the second query still hits the DB, your store isn't taggable or the trait isn't resolving — fix that before rolling out.
Where to look first: the config file (cache-prefix, cache-ttl, per-model TTL options added in 13.2.0), src/Traits/Cachable.php (the public API: disableCache(), cacheFor(), cacheWithTags(), cachePrefix(), flushCache()), and the cached builder class, which is where query-key hashing lives. Keep the CHANGELOG open — this release changed key derivation in a dozen places, and the notes tell you exactly which query shapes are affected.
Best first use case: a reference-data model (countries, categories, settings) or a filterable index endpoint that gets hammered with identical queries. High read-to-write ratio, small invalidation surface. Do not start with your User or Order model — those are where the invalidation edge cases live.
Post-upgrade, before you trust any numbers: run php artisan modelCache:clear. 13.2.0 changed the key text for scoped closure queries, morphTo eager loads, many-to-many relations, and every macro call with arguments. Some of those changed queries keep their old key, so without a flush they can read an entry written by 13.1.x.
Centralize the trait in a base model, then opt out selectively. The package's sweet spot is "cached by default." Put use Cachable; on App\Models\BaseModel, and use disableCache() on write-heavy models:
// app/Models/BaseModel.php
abstract class BaseModel extends Model
{
use Cachable;
}
// AuditLog::query()->disableCache()->create([...]);
// or per-read, for admin/back-office screens where staleness is unacceptable:
AuditLog::disableCache()->latest()->paginate();
If any model redeclares $isCachable, it must now be typed: protected bool $isCachable = false;. An untyped redeclaration is a fatal error at class load in 13.2.0, not a warning.
Set TTL at the right altitude. 13.2.0 added global and per-model TTL configuration alongside the existing cacheFor(). Use the config for a sane default on reference data, cacheFor() for call-site precision on anything aggregating:
return Order::query()
->cacheFor(now()->addMinutes(5))
->where('status', 'open')
->withCount('lines')
->get();
Note that TTL is now resolved through eager loads too and validated at both levels — a bad TTL on a with() constraint throws rather than silently coercing.
Use prefixes as your multi-tenant / multi-environment isolation. Both cache-prefix in config and cachePrefix() per query. If staging and production share a Redis instance and you skip this, you'll spend an afternoon debugging phantom rows. Be aware that 13.2.0 changed cool-down key derivation for models declaring $cachePrefix — an open cool-down window ends early exactly once on upgrade.
Make invalidation explicit where events don't fire. Pivot writes (attach/detach/sync) and eager-loaded many-to-many queries now flush and tag pivot tables correctly as of 13.2.0 — that gotcha is largely retired. What still bites is anything outside the Eloquent event path: raw DB::table()->update(), queued bulk imports, insert()/upsert(), Model::where(...)->update(). Flush in the owning service/observer/job:
class ProductObserver
{
public function saved(Product $product): void
{
// Query-builder writes bypass model events entirely
Product::flushCache();
}
}
Warm the cache on deploy instead of letting users eat the cold start. This matters more in 13.2.0 than ever: the key changes mean your entire cache is cold on the first deploy, and several query shapes that used to be cacheable now never are. A tiny command that runs your hottest queries once per release turns "first 100 requests are slow" into "deploy is 20 seconds slower."
// app/Console/Commands/WarmModelCache.php
Category::all();
Country::all();
Product::where('featured', true)->with('media')->get();
Test with the array store and flush between tests. Model caches survive across test methods otherwise, and you'll get failures that pass on re-run:
protected function setUp(): void
{
parent::setUp();
config(['cache.default' => 'array']);
// or, if you need the real store: Category::flushCache();
}
Treat it as a read-path optimization only. Don't reach for it in jobs that immediately re-read what they just wrote. The pattern that pays off is: HTTP GET → cached query → view/API resource. The pattern that bites is: write → read → assert freshness in the same request cycle.
Upgrade checklist for 13.2.0 — work through it in order. (1) PHP 8.3 in every environment, including CI images and Dockerfiles. (2) Type every $isCachable redeclaration as bool. (3) Delete any trait rule naming Cachable::newBaseQueryBuilder — the method was added in 13.1.8, collided with CTE traits, and is gone; Other::newBaseQueryBuilder insteadof Cachable still works, but Cachable::newBaseQueryBuilder as ... is a fatal error. (4) Run php artisan modelCache:clear after deploy. (5) If a deploy script relied on modelCache:clear wiping the store, add an explicit php artisan cache:clear — 13.2.0 scopes the command to the package's own entries only.
Tag support is non-negotiable. With file or database as the default store you get a "store does not support tagging" failure the first time you run a cached query. Set the package's store config explicitly rather than relying on CACHE_STORE being correct in every environment — CI and local .env files drift.
Some queries silently stopped being cached. 13.2.0 deliberately runs these uncached rather than risk a shared key returning wrong rows: where()/having() values that can't be keyed reliably (objects serialize() rejects, resources), eager-load constraints with afterQuery(), relations eager-loading a model without Cachable, and macros/custom builder methods receiving a closure, an object without __toString(), or an array json_encode can't handle. If a query's hit rate cratered after upgrading, this is why — and it's correct behaviour, not a bug. Fix by making the argument serializable or accepting the DB read.
Macro arguments now key as a JSON list. null, false, and "" used to collide, as did 1 and "1" — now they don't. Two consequences: cache cardinality goes up for macro-heavy builders (watch Redis memory), and every existing macro key is cold once.
Bulk writes are still the #1 source of stale data even with the pivot fixes. Model::where(...)->update(), insert(), upsert(), and raw statements don't fire the events this package listens to. Audit every write path that bypasses save()/delete() on a single instance, and flush explicitly.
Cross-model joins aren't invalidated. Caching Order queries is fine, but a query that joins customers won't be busted when a Customer is saved — only Customer-tagged entries are. Where-subquery table tagging improved in 13.2.0, but joined tables in the outer query are still your responsibility: either keep joins out of cached queries, or flush both models in the relevant observer.
Non-deterministic queries cache one arbitrary answer. inRandomOrder(), orderByRaw('RAND()'), and anything with NOW()/CURRENT_TIMESTAMP in the SQL will return the same cached result for everyone until invalidation. ->disableCache() these.
Watch Redis memory. High-cardinality whereIn filters (whereIn('id', $bigArray)) generate a lot of keys with near-zero reuse, and 13.2.0's finer-grained keys add more. Under allkeys-lru eviction you'll silently lose cached entries and quietly regain them — the symptom is erratic hit rates, not errors. Cap maxmemory, monitor used_memory, and trim the query shapes you allow to cache.
php artisan cache:clear nukes the whole store, including sessions and rate limiters if they share Redis. Use php artisan modelCache:clear (now correctly scoped) or Model::flushCache(), or a targeted prefix deletion in deploy scripts.
Chunking and cursors. chunk() re-runs with new offsets, so each chunk gets its own cache entry — usually useless overhead for a one-shot backfill. disableCache() in migrations, seeders, and backfill commands.
Long-lived processes (Octane, queue workers). The trait hooks model events at boot; that keeps working, but any in-memory state or manual flushes you do in a request won't propagate to sibling workers. After a deploy that changes cached query structure — and 13.2.0 is exactly such a deploy — do a full model-cache flush, or you'll serve entries written by the old code shape.
Debugging checklist when data looks stale: (1) is the write path firing model events? (2) is the store taggable? (3) does the read query include a model other than the one you saved? (4) does the query have a cachePrefix() that differs between write and read paths? (5) is the query on the 13.2.0 "runs uncached" list above? (6) does the generated key differ between two identical-looking calls (bindings, connection name, with() ordering, macro arguments)? Comparing the keys side-by-side answers most of these in one shot.
Don't fight it with your own builder override. If a model defines newEloquentBuilder() or a custom builder that reimplements toBase()/getModels(), the package's cached builder may be bypassed. Keep custom builders thin, avoid trait method-name collisions (the reason newBaseQueryBuilder was pulled), and test that caching actually engages on those models.
Extension points worth knowing: override the cache key derivation on a model to add tenant/version dimensions, use cacheWithTags() to group related resources under a business tag (e.g. reports), set global or per-model TTL in config for a sane baseline, and hang manual flushes off observers rather than sprinkling flushCache() through controllers — the observer is the one place a future developer will look.
How can I help you explore Laravel packages today?