helixdigital-io/laravel-eloquent-query-cache
helixdigital-io/laravel-eloquent-query-cache sits between Eloquent and your cache store: it hashes the SQL + bindings of a query built through a model using the trait, stores the result, and invalidates those entries automatically when that model is written to.
Install and publish config
composer require helixdigital-io/laravel-eloquent-query-cache
php artisan vendor:publish # pick the package's service provider / tag from the list
Use a taggable cache store. Invalidation is tag-based, so the default file/database drivers won't work out of the box. Set Redis, Memcached, DynamoDB or array as the package's store in config/eloquent-query-cache.php (or whichever store key the published config exposes), independent of your app's default store.
Opt in per model — the trait is not global:
use HelixDigital\LaravelEloquentQueryCache\Traits\QueryCacheable;
class Article extends Model
{
use QueryCacheable;
protected static $cacheFor = 3600; // seconds; required
}
First real use case: a read-heavy public listing page.
$articles = Article::query()
->cacheFor(300)
->with('author')
->where('published', true)
->latest()
->paginate(20);
Where to look first in the source: the QueryCacheable trait (the whole public API lives there: cacheFor, cacheTags, cachePrefix, dontCache, flushCache), then the custom builder it returns, then the model event hooks that perform invalidation. Those three files explain 95% of the behaviour you'll debug.
Start with one or two high-traffic, low-write models. Don't blanket-apply the trait across the domain on day one.
Per-model default TTL, per-query override. Set a sane $cacheFor on the model, then shorten or lengthen where it matters: Article::cacheFor(60)->where(...) for volatile feeds, Article::cacheFor(86400) for reference data. Chain it before the terminal method — it returns the builder.
Tag by tenant/user when results are scoped. A cache key is only as specific as the SQL. If you scope by tenant_id in a global scope the key already differs; if you scope in PHP or via a container-resolved "current user", it does not — pass ->cacheTags(['tenant:'.$tenant->id]) (or set $cacheTags statically) so you can flush per tenant.
Invalidation is event-driven — keep writes going through models. save(), create(), update() on an instance, delete(), restore() and forceDelete() fire the events that flush the model's tags. Anything that bypasses events (see Gotchas) needs a manual Article::flushCache().
Manual flush after batch operations. Imports, bulk reassignments and console commands should end with a targeted flushCache() on the affected model class, not php artisan cache:clear.
Relationships need the trait too. Eager loading works, but the related model's queries are only cached if that model also uses QueryCacheable. If Author isn't opted in, ->with('author') still hits the DB for the author rows on every cached article query's first build — cached, but the related fetch isn't independently reusable.
Bypass deliberately, not accidentally. ->dontCache() is the per-query escape hatch — use it for admin screens, exports, and anywhere a user just wrote data and expects to see it. In local/staging, either point the package store at array or disable caching in a service provider so stale results don't confuse debugging.
Pagination is keyed per page. ->paginate() caches each page separately, which is usually what you want; just remember the key includes the page number, so a 50-page listing costs 50 entries the first time it's crawled. Bound it with a short TTL on paginated queries.
Aggregates and counts are cached like any other query. count(), exists(), sum(), and withCount() all flow through the cached builder — cheap wins for dashboards and badges.
Testing. Configure the package store to array in phpunit.xml, and Cache::flush() in setUp() if you have tests that assert fresh reads across test methods. For tests asserting write-then-read behaviour inside a single test, ->dontCache() is less surprising than relying on invalidation timing.
Warm the cache after deploy rather than letting the first request per key take the hit — a small artisan command that runs the top few listing queries with cacheFor() set does the job, or just accept the cold-start.
$cacheFor throws. The package needs a TTL; there is no safe default. Every model using the trait must declare it (or you override the resolution).file or database you'll get failures or unbounded staleness. Either move to Redis/Memcached or enable the plain-key mode the config exposes (usually cache_use_plain_key), which disables tag-based invalidation — meaning you must flush manually. Prefer Redis.DB::table(), raw queries and ->toBase() bypass the cache entirely. So do DB::select() and anything from a repository that drops to the query builder.Model::where(...)->update() and ->delete() do not fire model events. This is the number one source of stale cache in production. Either iterate (->each->update() / ->each->delete()) or call flushCache() immediately after. Same for insert(), upsert(), and increment() on the builder.DB::afterCommit).orderByRaw('RAND()'), NOW(), CURRENT_TIMESTAMP comparisons, and session-dependent SQL produce keys that look stable but return stale/incorrect data. Use dontCache() or move the randomness into PHP.allkeys-lru), and use the package's cache_prefix so keys are identifiable. redis-cli --scan --pattern '*<prefix>*' | head is your friend when verifying what was actually stored.$cacheFor → confirm the query is built from the model (not DB) → inspect keys in Redis → check whether the write that "should" have invalidated went through a model event.cache_prefix per app, otherwise flushes cross boundaries.flushCache() and cacheTags() are the two you'll wrap most often.How can I help you explore Laravel packages today?