wddyousuf/eloquent-autocache
AutoCache
Automatic, self-invalidating query caching for Eloquent models.
Add one trait to a model and its read queries are cached transparently. Any write — including bulk, raw, and event-suppressing "quiet" writes — flushes that model's cache automatically, so you never serve stale data.
Invalidation uses cache tags when the store supports them (immediate,
targeted flushing) and falls back to a per-model version counter on every
other store (file, database, array, …). Either way, it just works.
Table of contents
- Requirements
- Installation
- Quick start
- How it works
- What triggers a flush
- Per-query controls
- Caching modes (auto vs opt-in)
- Row-level caching
- Relationship-aware invalidation
- Stale-while-revalidate
- Facade & Artisan commands
- Events
- Testing your application
- Configuration
- Per-model overrides
- Laravel Octane
- Limitations & notes
- Comparison with other packages
- Contributing
Requirements
- PHP 8.1+
- Laravel 10, 11, 12, or 13
On Laravel 13, config/cache.php ships 'serializable_classes' => false, which
forbids unserializing PHP objects out of a serializing cache store (file,
database, redis, memcached) to guard against gadget-chain attacks. AutoCache
works out of the box under this default — it caches only arrays and scalars, so
you do not need to add stdClass (or your models) to the allow-list.
Installation
Install via Composer:
composer require wddyousuf/eloquent-autocache
The service provider and AutoCache facade are auto-discovered — no manual
registration needed.
Optionally publish the config file to config/autocache.php:
php artisan vendor:publish --tag=autocache-config
That's the whole setup. Every store works out of the box; there's nothing to migrate and no external service to run.
Quick start
Add the Cacheable trait to any Eloquent model:
use Wddyousuf\AutoCache\Traits\Cacheable;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use Cacheable;
}
Now reads are cached and writes flush automatically:
Post::where('published', true)->get(); // hits the DB, caches the result
Post::where('published', true)->get(); // served from cache
Post::count(); // cached
Post::find(1); // cached (per-row, see below)
Post::create(['title' => 'Hello']); // flushes Post's cache
Post::where('published', true)->get(); // fresh from the DB again
How it works
- The
Cacheabletrait backs the model with aCachedQueryBuilder. - Every SELECT funnels through the builder's
runSelect()(andexists()), soget,first,find,pluck,value,count,sum,exists, and even the pagination count query are all cached from one place. - Every write funnels through the builder's write methods, which flush the
model's cache — catching bulk updates, raw inserts,
increment,truncate, and quiet writes that bypass model events. - Flushing either clears the model's cache tags or bumps a per-model version counter, depending on whether the store supports tags.
- Only plain data is ever written to the cache — query rows are stored as
arrays and
find()stores a row's raw attributes, never a serializedstdClassor Eloquent model. This keeps AutoCache compatible with Laravel 13's secure-by-defaultcache.serializable_classesallow-list (see Requirements).
What triggers a flush
create/save/update/delete/restore/forceDelete- Bulk
Post::where(...)->update()/->delete()(including joined updates) - Raw
insert/upsert/insertOrIgnore/insertUsing/insertOrIgnoreUsing updateFromincrement/decrementtruncate- The
*Quietly()variants (saveQuietly,updateQuietly, …)
Per-query controls
Post::withoutCache()->get(); // skip the cache for this query
Post::cacheFor(60)->get(); // custom TTL (seconds; null = forever) for this query
Post::cache()->where('active', 1)->get(); // explicitly opt in (opt-in mode)
Post::query()->cacheKey('homepage')->get(); // use a fixed cache key
Each also works from an existing builder chain, e.g.
Post::where(...)->withoutCache()->get().
Caching modes (auto vs opt-in)
By default (mode => 'auto') every read is cached. Set the mode to opt-in
to cache only queries you explicitly mark:
// config/autocache.php
'mode' => 'opt-in',
Post::all(); // NOT cached
Post::cache()->get(); // cached
Post::cacheFor(120)->get(); // cached (cacheFor implies opt-in)
The mode can also be set per model, overriding the global setting:
class AuditLog extends Model
{
use Cacheable;
protected $cacheMode = 'opt-in'; // or 'auto'
}
Writes always flush, regardless of mode.
Row-level caching
Canonical find($id) lookups are cached under a stable per-row key, so a
single row's cache survives writes to other rows:
Post::find(1); // cached
Post::find(2); // cached
Post::find(2)->update(['title' => 'Changed']); // only row 2's cache is dropped
Post::find(1); // still served from cache
Post::find(2); // refetched (fresh)
Bulk updates, truncate, and upsert clear all row caches (they may touch
unknown rows). Missing rows are never cached, so a later insert is picked up
immediately. Non-canonical finds (extra constraints, custom columns, removed
scopes, eager loads) fall back to normal query caching.
Disable it with 'row_cache' => false (or per model, see below). Row-level
survival applies to version-counter stores; tag stores flush per-model.
Relationship-aware invalidation
Flush a parent (or any related model) whenever this model changes:
class Comment extends Model
{
use Cacheable;
protected $flushRelated = ['post']; // relation name or model class
public function post()
{
return $this->belongsTo(Post::class);
}
}
Creating or updating a Comment now also flushes the cached Post queries.
Many-to-many (pivot) writes
$flushRelated covers belongsTo / hasMany, but a many-to-many write —
$post->tags()->sync(), ->attach(), ->detach() — is a bare pivot statement
that never touches a cacheable model's builder, so a cached relation read
($post->tags, or any query that joins the pivot) keeps serving stale rows until
its TTL expires. When the pivot gates authorization, that is unsafe.
Map each pivot table to the cacheable models whose reads it can change:
// config/autocache.php
'pivot_invalidation' => [
'map' => [
'post_tag' => [App\Models\Post::class, App\Models\Tag::class],
'role_user' => [App\Models\Role::class, App\Models\User::class],
],
],
AutoCache then watches the query stream and flushes those models on any write to a listed pivot — wherever it originates (Eloquent, a Filament relation manager, or raw SQL). List both sides that are cacheable; a non-cacheable class in the map is skipped. The listener is only registered when the map is non-empty, so there is no query-stream overhead if you don't use the feature.
Stale-while-revalidate
On Laravel 11+, serve an expired value instantly while it recomputes in the
background (via Cache::flexible()):
// config/autocache.php
'ttl' => 60, // fresh for 60s
'swr' => 30, // then served stale for up to 30s more while refreshing
SWR needs a finite TTL, and it is skipped for models with a max_rows cap
(the background refresh cannot apply the size guard).
Facade & Artisan commands
use Wddyousuf\AutoCache\Facades\AutoCache;
AutoCache::flush(Post::class); // flush one model
AutoCache::clear(); // flush all registered models
AutoCache::warm(Post::class); // pre-populate a model's cache
AutoCache::warmAll(); // warm every registered model
AutoCache::stats(); // ['hits' => ..., 'misses' => ...]
AutoCache::resetStats(); // zero the counters (optionally per model)
php artisan autocache:flush "App\Models\Post"
php artisan autocache:clear
php artisan autocache:warm "App\Models\Post"
php artisan autocache:warm --all
php artisan autocache:stats
php artisan autocache:stats --reset
Customize what warming runs by overriding cacheWarmupQueries() on the model:
public function cacheWarmupQueries(): array
{
return [
static::query(),
static::where('published', true),
];
}
clear and warm discover models registered at runtime; list any that must
be reachable before boot in config('autocache.models').
Events
Three events are dispatched so you can log or measure cache behavior:
Wddyousuf\AutoCache\Events\CacheHitWddyousuf\AutoCache\Events\CacheMissedWddyousuf\AutoCache\Events\CacheFlushed
Each carries the $model (and, for hit/miss, the $key).
Testing your application
Swap in a recording fake and assert on cache behavior — no counting SQL by hand:
use Wddyousuf\AutoCache\Facades\AutoCache;
public function test_publishing_flushes_the_cache(): void
{
$fake = AutoCache::fake();
Post::factory()->create();
$fake->assertFlushed(Post::class);
}
Available assertions:
$fake->assertFlushed(Post::class);
$fake->assertNotFlushed(Post::class);
$fake->assertNothingFlushed();
$fake->assertHit(Post::class); // or assertHit() for "any"
$fake->assertMissed(Post::class); // or assertMissed() for "any"
Configuration
config/autocache.php (every key is env-driven):
| Key | Default | Description |
|---|---|---|
enabled |
true |
Master on/off switch. |
store |
null |
Cache store (null = app default). |
ttl |
3600 |
Seconds to cache; null = forever. |
ttl_jitter |
0.1 |
Randomly spread each TTL by ±this fraction (anti thundering-herd). |
prefix |
autocache |
Key prefix. |
mode |
auto |
auto caches everything; opt-in caches only ->cache() queries. |
row_cache |
true |
Per-row caching for canonical find($id). |
swr |
0 |
Stale-while-revalidate grace seconds (Laravel 11+; 0 = off). |
use_tags |
auto |
auto/true = tags when supported; false = version counter. |
lock_for |
10 |
Seconds to hold a stampede lock (needs a lock-capable store; 0 off). |
max_rows |
null |
Skip caching result sets larger than this. |
volatile_patterns |
now(), rand(, … |
Queries containing these substrings are never cached. |
stats |
false |
Collect hit/miss counters. |
models |
[] |
Models for clear/warm to discover before boot. |
Env variables: AUTOCACHE_ENABLED, AUTOCACHE_STORE, AUTOCACHE_TTL,
AUTOCACHE_TTL_JITTER, AUTOCACHE_PREFIX, AUTOCACHE_MODE,
AUTOCACHE_ROW_CACHE, AUTOCACHE_SWR, AUTOCACHE_USE_TAGS,
AUTOCACHE_LOCK_FOR, AUTOCACHE_MAX_ROWS, AUTOCACHE_STATS.
Per-model overrides
Declare any of these properties to override the global config for one model:
class Post extends Model
{
use Cacheable;
protected $cacheStore = 'redis'; // this model's store
protected $cacheTtl = 600; // seconds; null = forever
protected $cacheEnabled = true; // disable caching for just this model
protected $cacheMode = 'opt-in'; // 'auto' or 'opt-in' for this model
protected $cacheTags = ['catalog']; // extra tags (tag mode)
protected $cacheMaxRows = 5000; // skip caching larger results
protected $flushRelated = ['comments'];// relations/models to co-flush
}
Laravel Octane
AutoCache is Octane-safe: it registers listeners on RequestReceived,
TaskReceived, and TickReceived to reset its process-static flush guard
between requests, so a long-lived worker never carries state across requests.
Nothing to configure.
Limitations & notes
-
Eager-loaded relations are cached as part of the related model's own queries (which must also use
Cacheable). A change to a related model does not flush a parent's root query unless you wire it up with$flushRelated. -
cursor()streams and is intentionally never cached. -
Reads inside a transaction are cached by default, which keeps caching working under
RefreshDatabase. A read that observes an uncommitted write can cache a value a later rollback discards; setcache_in_transactions => false(or$cacheInTransactions = falseon a model) so open-transaction reads bypass the cache entirely and a rollback can never leave a stale entry. -
Tag mode flushes the whole model on every write. With a taggable store (redis, memcached), any write clears the model's entire tag; the surgical single-row flush only applies to the version-counter path (
row_cache, on non-taggable stores). Prefer caching read-heavy / write-light models in tag mode — a model written on nearly every request gains little. -
Direct
DB::table()writes touch neither the model nor its cache-aware builder, so AutoCache cannot see them on its own — the one write class that slips through. Two remedies: callAutoCache::flush(Model::class)right after the raw write, or — to catch it automatically wherever it happens — list the model's own table inpivot_invalidation.map(the query-stream listener matches on the raw table name, so it invalidates any write to that table, not just pivots):// config/autocache.php — auto-invalidate raw writes to the posts table 'pivot_invalidation' => [ 'map' => [ 'posts' => [App\Models\Post::class], ], ], -
The version counter on non-atomic stores (
file) can, under heavy concurrent writes, briefly miss an increment. Use an atomic store (redis, memcached) or a taggable store for high-write workloads. -
Row caching stores raw attributes, not the model. Every
find($id)reconstructs a fresh model instance from the cached attributes, so unsaved changes to a returned model are never leaked to later calls — this holds on thearraystore too, and keeps the cache safe to unserialize under Laravel 13'sserializable_classesallow-list.
Comparison with other packages
Several excellent packages cache Eloquent queries. AutoCache's focus is complete write-path coverage on any cache store — no Redis requirement, and no write that can slip past invalidation.
| AutoCache | laravel-model-caching | eloquent-query-cache | lada-cache | |
|---|---|---|---|---|
Works on any cache store (file, database, …) |
✅ | ❌ ¹ | ⚠️ ² | ❌ (Redis only) |
| Automatic caching (zero per-query code) | ✅ | ✅ | ⚠️ ³ | ✅ |
| Opt-in / per-query mode | ✅ | ❌ | ✅ | ❌ |
Bulk where(...)->update() / ->delete() flushes |
✅ | ❌ ⁴ | ❌ ⁴ | ✅ |
Raw insert / upsert / insertUsing flushes |
✅ | ❌ ⁴ | ❌ ⁴ | ✅ |
Quiet writes (saveQuietly, …) flush |
✅ | ❌ ⁴ | ❌ ⁴ | ✅ |
| Transaction-aware invalidation | ✅ | ❌ (manual flush) | ❌ | ❌ |
Row-level find() cache survives other rows' writes |
✅ | ❌ | ❌ | ✅ |
| Stale-while-revalidate | ✅ | ❌ | ❌ | ❌ |
| Stampede (dog-pile) protection | ✅ | ❌ | ❌ | ❌ |
| TTL jitter (anti thundering-herd) | ✅ | ❌ | ❌ | ❌ |
Test fake with assertions (AutoCache::fake()) |
✅ | ❌ | ❌ | ❌ |
| Warm / clear / stats Artisan commands | ✅ | ⚠️ (flush) | ❌ | ⚠️ (flush) |
¹ Requires Redis, Memcached, APC, or DynamoDB; file, database, and array
stores are unsupported.
² Per-query caching works anywhere, but automatic invalidation requires a
taggable store.
³ Caches only queries marked cacheFor() (or a model-wide $cacheFor
property).
⁴ Invalidation hooks Eloquent model events, so writes that bypass events
(bulk builder writes, raw inserts, quiet saves) leave stale cache entries.
AutoCache and lada-cache hook the query-builder layer instead.
rememberable is also worth a
mention: a minimal manual remember($seconds) per query, with no automatic
invalidation. And
mostafaznv/laracache is a
different approach entirely — you predefine named CacheEntity queries on the
model rather than caching reads transparently.
Based on each package's documentation as of July 2026 — corrections welcome.
Contributing
composer install
composer test # phpunit
composer analyse # phpstan / larastan
composer format # pint
License
MIT