helixdigital-io/laravel-eloquent-query-cache
A Laravel package that caches Eloquent query results to cut database load and speed up repeated reads. It integrates with the query builder to store and reuse results, with cache invalidation support to help keep data fresh.
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?