laragear/cache-query
Cache your Laravel Eloquent queries and get fast, consistent results without hitting the database on every request. It hooks into the query builder, so you can cache models, counts, and relationships with a simple chainable call.
Install and publish
composer require laragear/cache-query
php artisan vendor:publish --provider="Laragear\CacheQuery\CacheQueryServiceProvider"
The package auto-registers via Laravel discovery; publishing gives you config/cache-query.php with the three knobs you actually care about: the cache store used for query results, the key prefix, and the default TTL.
Where to look first
->cache(), ->cacheForever(), and the CacheQuery facade.Laragear\CacheQuery\Facades\CacheQuery) — this is what you reach for to bypass caching in tests, admin panels, or post-write reads.First use case
A read-heavy listing endpoint is the ideal first target. Wrap the terminal call on the query builder:
use App\Models\Product;
$products = Product::query()
->where('is_active', true)
->orderBy('name')
->cache() // default TTL from config
->get();
That's it — the second identical request is served from cache. For a time-sensitive dashboard, pass a TTL:
$products = Product::where('is_active', true)
->cache(now()->addMinutes(5))
->get();
$categories = Category::orderBy('name')->cacheForever()->get();
Relationship queries work the same way, which makes per-parent caching trivial:
$user->posts()->where('published', true)->cache()->get();
Cache at the query layer, not the response layer. The package keys off the SQL and bindings, so two different controllers issuing the same query share one cache entry. That is a feature — put ->cache() on the query builder in a repository/scope and every caller benefits without knowing about caching.
Pick a TTL strategy per query class. Reference data (countries, categories, feature flags) → cacheForever() plus explicit invalidation. Dashboards and listings → short TTLs (1–15 minutes) where staleness is acceptable. Detail pages → skip caching entirely; the hit rate is low and staleness is most visible there.
Use a dedicated cache store. Point cache-query.store at a separate Redis DB or a named store. Then php artisan cache:clear against that store only nukes query results, not sessions, rate limiters, or your Cache::remember payloads. It also makes flushing on deploy safe.
Invalidate with tags where the store supports them. Redis, Memcached, and array support tags; file and database do not (they'll throw or silently no-op depending on version). If your store supports tags, tag cache entries by domain (products, users:42) and flush those tags from model observers after writes. If it doesn't, either switch stores or rely on short TTLs — there is no automatic model-aware invalidation.
Bypass deliberately. Wrap requests that must see fresh data — post-checkout redirects, admin "view as user", anything that just wrote and re-reads:
use Laragear\CacheQuery\Facades\CacheQuery;
CacheQuery::avoid(); // this request skips the query cache
// ... your reads ...
// CacheQuery::allow(); // restore if needed
Same trick in setUp() of feature tests if tests share a real cache store — otherwise a cached row from one test leaks into the next and you chase ghosts for an afternoon.
Pagination is naturally per-page. paginate() adds limit/offset to the SQL before execution, so each page gets its own key. That's correct behavior, but it means changing perPage invalidates everything.
Watch the fan-out on bindings. ->where('email', $request->email)->cache() produces one cache entry per email. That's fine on a profile page and disastrous on a search endpoint. Cache queries whose bindings come from a small, closed set (tenant ID, category ID, status enum), not from free-form user input.
Combine with eager loading, not against it. Put ->with([...]) on the query before get(). The serialized collection includes the loaded relations, so cache hits return fully-hydrated graphs. Lazy-loaded relations are not cached and will still hit the database per request — the usual N+1, now hidden behind a cache hit.
saved/deleted hooks are your invalidation layer — plan them in the same PR that adds ->cache().null) until the TTL expires. When you ship a migration that adds columns the cached rows need, bump the cache-query.prefix (e.g., v2:) — old entries become orphans and age out naturally. Same applies when changing accessors, since accessor logic never appears in the SQL.DB::afterCommit().->cache() last in the chain. It works earlier in most cases, but the readable, defensible convention is ...->orderBy(...)->cache()->get(). It prevents arguments about whether a ->where() added after ->cache() was included in the key.Illuminate\Cache\Events\CacheHit / CacheMissed and dump the key — that tells you instantly whether the miss is due to a different binding, a different connection, or an avoid() left on from a middleware. CacheQuery::avoid() is also the fastest bisect: if disabling caching fixes a bug, the bug is staleness, not SQL.terminate() or an Octane listener.cacheForever() really means forever. There's no garbage collector. Pair it with tags, or accept that the entry lives until someone flushes the store.inRandomOrder()->cache()->get() returns the same "random" order for every hit. Usually not what you want for a "surprise me" feature; usually exactly what you want for a randomized-but-stable A/B assignment.How can I help you explore Laravel packages today?