Memory Efficient, Normalized, self-invalidating Redis cache for Laravel Eloquent. Cluster Ready
Most caching packages cache query results as a single blob — the entire collection, serialized and stored together. Normcache takes a different approach: it stores the list of matching IDs separately from the model data, and keeps each model's attributes in its own key. Every model is stored exactly once, no matter how many queries return it.
Query cache → query:{posts}:v3:... → [4, 7, 12]
Model cache → model:{posts}:4 → { id:4, title:..., body:... }
→ model:{posts}:7 → { id:7, title:..., body:... }
→ model:{posts}:12 → { id:12, title:..., body:... }
When post 7 is updated, Normcache deletes model:{posts}:7 and increments a version counter. The version is embedded in every query cache key, so all cached queries that returned post 7 — filtered, paginated, or sorted however they were — automatically miss on the next read. No index of which queries to invalidate is needed.
- You never store the same record twice.
- A popular model appearing in 50 cached query results is stored once, not 50 times. This massively reduces your Redis memory usage.
- Warming one query warms every query.
- When a model is fetched by ID, its attributes land in the model cache. Every other query that later includes that model — a search, a paginated list, a relationship — gets a model cache hit on that record for free.
- Invalidation is O(1).
- A single
INCRon a version key makes all cached queries for a model stale. No tag scanning, no key enumeration, no O(n) overhead as your cache grows.
- A single
Requirements:
- PHP 8.2+
- Laravel 11, 12, or 13
- Redis 4.0+
composer require kai-init/laravel-normcachePublish the config:
php artisan vendor:publish --tag=normcache-configAdd the Cacheable trait to any Eloquent model you want cached:
use NormCache\Traits\Cacheable;
class Post extends Model
{
use Cacheable;
}That's it. All queries on that model now go through the two-layer cache automatically.
Post::all();
Post::where('active', true)->get();
Post::find(1);
Post::paginate(20);Post::withoutCache()->get();Post::query()->remember(600)->get(); // cache this result for 10 minuteswithCount, withSum, withAvg, withMin, withMax, and withExists are cached automatically. Aggregate values are stored per model ID and invalidated when the related model changes.
Post::withCount('comments')->get();
Post::withSum('orders', 'total')->get();To skip aggregate caching for a specific query:
Post::withoutAggregateCache()->withCount('comments')->get();BelongsTo, BelongsToMany, MorphToMany, HasManyThrough, and HasOneThrough relationships are cached for eligible eager-loads. On a warm hit no SQL is executed.
// First load: runs SQL, caches pivot map + related models
Post::with('tags')->get();
// Subsequent loads: zero SQL
Post::with('tags')->get();attach, detach, sync, and updateExistingPivot automatically invalidate the relevant pivot cache.
php artisan normcache:flush --model="App\Models\Post"
php artisan normcache:flush # flush everythinguse NormCache\Facades\NormCache;
NormCache::flushModel(Post::class);
NormCache::flushAll();If you mutate cacheable model tables outside Eloquent, flush the affected model cache manually after the committed write. Normcache only observes writes that go through cacheable Eloquent models/builders; raw SQL and DB::table(...) calls bypass automatic invalidation.
DB::table('posts')->where('published', false)->update(['published' => true]);
NormCache::flushModel(Post::class);Normcache is optimised for Redis Cluster. Every key uses a hash tag derived from the model table name — {posts}, {users}, etc. If a model declares a connection name, that connection is included too, for example {analytics:posts}. This keeps fixed model classes on different database connections isolated while still placing all keys for a given model on the same cluster slot. This means:
MGETbatches across an entire result set are always single-slot and never cross node boundaries.- Lua scripts (
EVAL) that combine a version read + data fetch in one round trip are always operating on co-located keys. - Pipelines that write model attributes and register them in a member set never split across nodes.
Enable cluster mode in the config:
// config/normcache.php
'cluster' => env('NORMCACHE_CLUSTER', false),Note:
flushAll()is not supported in cluster mode. To perform a full flush on a cluster, useNormCache::getFlushPatterns()to get the key patterns and run your own per-node scan and delete:$patterns = NormCache::getFlushPatterns(); // ['query:*', 'model:*', 'ver:*', ...] // Scan and UNLINK each pattern on every master node using your preferred approach.
The following query types always hit the database directly:
| Query feature | Reason |
|---|---|
JOIN |
Result depends on joined table, not just this model |
GROUP BY / HAVING |
Aggregated results can't be mapped to individual model keys |
UNION |
Multi-model result set |
Raw ORDER BY |
Can't be applied to cached key list |
SELECT with expressions |
Computed columns aren't in the model cache |
Pessimistic locking (lockForUpdate / sharedLock) |
Must always read from DB |
| Inside a database transaction | Reads inside a transaction must see uncommitted data |
Raw SQL / DB::table(...) |
Bypasses cacheable Eloquent models and builders |
Invalidations inside a database transaction are deferred until commit. On rollback, nothing is touched — the version counter is never bumped and no model keys are evicted.
use NormCache\Events\{QueryCacheHit, QueryCacheMiss, ModelCacheHit, ModelCacheMiss};
Event::listen(QueryCacheMiss::class, fn($e) => Pulse::record('query_miss', $e->modelClass));
Event::listen(ModelCacheMiss::class, fn($e) => Pulse::record('model_miss', $e->modelClass, count($e->ids)));| Event | Fired when | Properties |
|---|---|---|
QueryCacheHit |
ID list served from Redis | modelClass, key |
QueryCacheMiss |
ID list not cached — DB queried | modelClass, key |
ModelCacheHit |
Model attributes served from Redis | modelClass, ids[] |
ModelCacheMiss |
Attributes not cached — DB queried | modelClass, ids[] |
// config/normcache.php
return [
'connection' => env('NORMCACHE_CONNECTION', 'cache'),
'enabled' => env('NORMCACHE_ENABLED', true),
'ttl' => env('NORMCACHE_TTL', 604800), // model keys: 7 days
'query_ttl' => env('NORMCACHE_QUERY_TTL', 3600), // query/pivot/through keys: 1 hour
'key_prefix' => env('NORMCACHE_PREFIX', ''),
'cooldown' => env('NORMCACHE_COOLDOWN', 0), // version bump debounce in seconds
'cluster' => env('NORMCACHE_CLUSTER', false),
'events' => env('NORMCACHE_EVENTS', true), // fire cache hit/miss events
'fallback' => env('NORMCACHE_FALLBACK', false), // fall back to DB on Redis error
'fire_retrieved' => env('NORMCACHE_FIRE_RETRIEVED', false),
];cooldown — Consecutive writes within the cooldown window only bump the version once. Useful for write-heavy models to avoid thrashing the version counter.
events — Set to false to disable all QueryCacheHit, QueryCacheMiss, ModelCacheHit, and ModelCacheMiss event dispatches. For production hot paths, prefer NORMCACHE_EVENTS=false unless you actively consume these events for observability.
fallback — When true, any Redis exception during a read is caught, reported via report(), the cache is disabled for the remainder of the request, and the query falls back to the database. When false (the default), Redis errors propagate normally. Enable this if you want your application to stay available during Redis outages.
fire_retrieved — When true, models hydrated from Redis fire Eloquent's retrieved event. It is disabled by default to avoid event overhead on cache hits.
- Single round trip on cache hit — version read + query ID fetch + model MGET are combined into one Lua
EVALcall. - Cached paginate count —
paginate()caches theCOUNT(*)query under a versioned key so navigating between pages never re-runs the count query. - Invalidation is O(1) — one
INCRon a version key, regardless of how many cached queries exist for that model. MGETfor bulk reads — all model attributes for a result set in one Redis call.- Pipelined writes — cache warm-up for missed models is batched in a single pipeline.
UNLINKfor deletes — non-blocking async deletion (Redis 4.0+), chunked at 1000 keys.- No scanning on invalidation — version shift makes stale keys unreachable without touching them. Eviction is handled by TTL.
- igbinary support — when the
igbinaryPHP extension is installed, model attributes are serialized with igbinary for faster serialization and smaller payloads. - In-process version cache — version numbers are cached in-process per request (with Octane support) to eliminate redundant Redis reads within the same request.
MIT