codewithdennis/laravel-model-cache
Cache Eloquent model queries in Laravel with a single trait. Cached results are automatically invalidated when models are created, updated, or deleted, cutting database load and speeding up repeated reads with minimal setup.
v1.0.0-beta.1, so avoid ^1.0-style constraints and treat the API as provisional:
composer require codewithdennis/laravel-model-cache:"1.0.0-beta.1"
php artisan vendor:publish output):
php artisan vendor:publish --tag="model-cache-config"
Open config/model-cache.php and note three things before writing any code: the default cache store, the default TTL, and the key prefix. These determine correctness more than anything else in the package.CACHE_STORE at redis (or memcached) in staging and production. On file, database, or array, tag-based invalidation degrades or silently no-ops, which is the single most common way this package "works" locally and goes stale in production.use CodeWithDennis\LaravelModelCache\HasModelCache;
class Category extends Model
{
use HasModelCache;
}
$categories = Category::where('active', true)->orderBy('name')->get();
Check with Laravel Telescope's cache and query panels: on request two, the query should disappear and a cache hit should appear.Where to look first: the trait source (which builder methods are actually intercepted — this defines what is and isn't cacheable), the config file defaults, and the test suite, which is the most reliable usage documentation for a package with thin prose docs.
Opt in per model; never globally. Applying the trait via a base model is tempting and wrong for a beta dependency. Choose models by read/write ratio: reference data and content reads are ideal, transactional tables (orders, inventory, balances) are not.
Query-level caching as the default posture. Rather than relying on blanket model caching you haven't fully characterized, be explicit per query:
Category::where('active', true)->cache(ttl: 600)->get();
Post::with('author', 'tags')->cache()->paginate(20);
This makes the cache surface reviewable in code review — a real advantage over a trait that silently changes every read on the model.
Warm the cache deliberately. The package's headline feature is pre-warming heavy queries once and holding them indefinitely. Use a scheduled command for expensive aggregates rather than letting the first unlucky user pay the cost:
// app/Console/Commands/WarmModelCache.php
public function handle(): void
{
Category::where('active', true)->cache(forever: true)->get();
Plan::with('features')->cache(forever: true)->get();
}
Register it in routes/console.php or the scheduler, and re-run it after deploys or cache flushes — otherwise you get a cold-start thundering herd exactly when traffic is highest.
Keep writes going through Eloquent instances. Invalidation is driven by model events (saved, deleted, restored, forceDeleted). Any write path that skips those events leaves stale data behind:
$post->update(['title' => 'New']); // invalidates
Post::where('id', $id)->update([...]); // mass update — see Gotchas
DB::table('posts')->where(...)->update([]); // bypasses everything
Compose with scopes and relationships. Because interception happens at the builder level, local scopes chain naturally:
public function scopePublished($query)
{
return $query->where('published_at', '<=', now());
}
Post::published()->latest()->cache(300)->get();
Cache eager-loaded relations on their own models rather than assuming the parent's entry covers them.
Neutralize the cache in tests. Do it in TestCase::setUp() so no test can forget:
protected function setUp(): void
{
parent::setUp();
config(['model-cache.store' => 'array']);
}
Plus one integration test that does exercise Redis, asserting invalidation on update and delete — that's the test that catches a broken upgrade.
Wrap it behind repository or scope methods. Given beta instability, funnel cached queries through a small number of methods so a rename in 1.0.0 touches one file instead of forty.
Instrument before and after. Log query counts per route (Telescope, or DB::listen counters in a middleware). A cache hit ratio below ~50% on a model means the trait is adding serialization cost without buying anything — remove it.
Post::where(...)->update([]) and ->delete() fire no model events, so cached entries go stale until TTL expiry. Either iterate models, or explicitly forget the affected keys afterwards. Audit every mass write in your codebase before trusting the trait.DB::afterCommit).file and database drivers ignore or over-approximate tags. If invalidation depends on tags, Redis/Memcached is a prerequisite, not a preference. Read the trait's forget logic to confirm what it actually does.$model->refresh(), fresh(), and immediate re-queries can return stale data if the read path is cached. For payments, inventory, and permission checks, do not read through the cache.app()->environment().MODEL_CACHE_ENABLED=false (or your config equivalent) so N+1s and missing indexes stay visible while developing — caching hides exactly the performance problems you want to find early.Cache::tags([...])->flush() and Cache::get() manually in tinker to see the exact keys and payloads the package writes. If a query still appears in the query log on repeat requests, your call isn't intercepted — most often it's a raw DB::table() query or an aggregate method the trait doesn't wrap.How can I help you explore Laravel packages today?