Install and publish config
composer require juampi92/laravel-query-cache
php artisan vendor:publish --tag=query-cache
The package is intentionally minimal: one service provider, one config file (config/query-cache.php), and a query-builder macro. There is no migration, no table, no required trait to get started. Check the config first — the main knobs are the default cache store, default TTL, and the prefix used to build keys. If your app uses Redis/Memcached in production, point store at that; leaving it on file will make invalidation slow and unreliable at scale.
First use case
Pick a genuinely read-heavy, low-write query — countries, categories, feature flags, a settings table, a "top products" list. Wrap it:
$countries = Country::query()
->where('active', true)
->orderBy('name')
->cache(3600) // seconds, or omit to use the config default
->get();
The second call returns the hydrated collection from cache without touching the DB. That's the whole loop: build the query exactly as you normally would, then append ->cache($ttl) right before the terminal call. Anything you can chain on a builder (where, with, orderBy, limit, scopes) works, because the key is derived from the compiled SQL plus bindings.
Where to look first
config/query-cache.php — confirms which store/TTL/prefix you're actually using.First invalidation setup
Caching is worthless without invalidation. Add the package's cacheable trait to the models whose tables back your cached queries, so that saved / deleted events bust the relevant keys:
use Juampi92\QueryCache\Traits\Cacheable; // verify exact FQCN against installed version
class Country extends Model
{
use Cacheable;
}
If the trait isn't available in your version, wire invalidation manually (see below) — do not ship cached queries without a bust path.
1. Treat ->cache() as a terminal-adjacent decorator, not a wrapper
The idiomatic pattern is always the same shape — full query, then cache, then execute:
$posts = Post::query()
->published()
->with('author:id,name')
->latest()
->limit(20)
->cache(300)
->get();
Avoid splitting this across variables or applying cache() early, because the key is computed from the SQL at execution time. Keep the chain intact so the key is stable.
2. Scope keys with a prefix per logical "cache group"
The package builds keys from SQL + bindings + a configured prefix. In practice you should treat that prefix as your namespace boundary. If you cache per-user results (e.g., a dashboard feed), build the user into the query rather than into prose:
DashboardMetric::query()
->where('user_id', auth()->id()) // becomes part of the key via binding
->where('period', 'mtd')
->cache(60)
->get();
Because user_id is a binding, each user gets a distinct key for free. Do not cache a query whose result depends on the authenticated user unless that user (or tenant) is represented in the SQL — otherwise user A gets user B's data.
3. Invalidate via model events for single-table queries
For a cached query on countries, saving a Country should flush. Use the package's trait if your version ships one; otherwise wire a model observer that calls the package's flush/forget API:
Country::saved(fn () => QueryCache::forget()); // verify namespace + method
Country::deleted(fn () => QueryCache::forget());
This is coarse (it clears everything in scope) but correct. For a table that mutates hourly, this is fine. For one that mutates every second, don't cache it.
4. Cache expensive joins and aggregates, invalidate from the "owner" model
Joins are the highest-value but riskiest target, because a write to either table changes the result but only one model fires events. Pattern:
$stats = Order::query()
->join('customers', 'customers.id', '=', 'orders.customer_id')
->where('orders.created_at', '>=', now()->subDay())
->selectRaw('customers.country, count(*) as total')
->groupBy('customers.country')
->cache(600)
->get();
Attach invalidation to both Order and Customer events. If your version supports tag-based stores, tag the query with both table names and flush by tag; otherwise accept the broader flush. Prefer short TTLs (5–10 min) on join caches as a safety net.
5. Cache in the service/repository layer, not in random controllers
Centralize cached queries in dedicated methods so invalidation logic has a clear owner:
class CountryDirectory
{
public function all(): Collection
{
return Country::query()->where('active', true)->orderBy('name')->cache(3600)->get();
}
public function flush(): void
{
QueryCache::forget(); // or tagged flush
}
}
This makes it trivial to add a scheduled cache warmer, and to grep for every cached query when you debug a stale-data report.
6. Use it for "reference data" before anything else
The highest ROI, lowest risk: enum tables, settings, plans, currencies, locales, permissions catalogs, blog categories. These mutate on deploy/admin action, cache perfectly, and have obvious invalidation points (saved on the model, or an admin controller calling forget() after a form submit).
7. Warm caches on deploy or schedule
For dashboards and landing pages, add a scheduled job that just calls the cached service methods, so users never pay the cold-cache penalty:
// app/Console/Kernel.php
$schedule->call(fn () => app(CountryDirectory::class)->all())->hourly();
8. Combine with withoutCache() in admin/authoring contexts
If your version exposes a bypass (or you read from the cache store directly), use it in the admin panel so editors always see the true DB state. As a fallback, simply don't append ->cache() in admin controllers — the macro is opt-in per call.
Key derivation is only as stable as your SQL. Two queries that mean the same thing but produce different SQL generate different keys:
User::where('role', 'admin')->get(); // key A
User::where('role', '=', 'admin')->get(); // key B (extra operator)
User::query()->where('role', 'admin')->get(); // key C (explicit query())
Standardize query construction (a named scope per cached read) so the key doesn't drift between call sites. Otherwise you'll cache the same data twice and only invalidate one copy.
Bindings and cache() order matter. Bindings are part of the key. If you sort, paginate, or mutate the query after it was cached, you're keying on something different than you think. Always inspect the exact chain in the file that owns the query.
toSql() doesn't include bindings — but the package does. Trust the package to hash bindings, but be alert to any raw value that doesn't round-trip cleanly: DateTime objects, Carbon instances, JSON columns, and enums will be stringified. If you cache a query bound to now() or a request-derived date, every request is a cache miss. Round dates to a stable window first:
$start = now()->startOfDay()->toDateString(); // stable across the day
Query::where('created_at', '>=', $start)->cache(300)->get();
Per-user caching without the user in the key is a data-leak bug. This is the single most common mistake and it's silent in tests (single user) and invisible in dev (cache flushed often). If any part of the result depends on auth()->id(), auth()->user()->tenant_id, or request headers, make sure it's a binding in the query.
paginate() and first() behavior varies by version. Pagination typically caches the page slice; it may or may not cache the total count consistently. Verify with DB::listen or Telescope that (a) the second call hits cache, and (b) the paginator still reports the correct total. If not, cache get() and count separately.
Choose the right cache store. file driver writes a file per key — fine for a small catalog, painful at scale and hard to flush selectively. array driver in tests is what you want so you don't pollute a real store. Redis/Memcached unlock tag-based flushing; if your version relies on tags and you're on file, invalidation may be a full-store flush or silently broader than expected. Check the config before shipping.
Model event invalidation is coarse and synchronous. Every save() on a cached model triggers a flush. On a bulk import of 10,000 rows you'll wipe the cache 10,000 times. Use Model::withoutEvents(...) during imports and flush once at the end:
Model::withoutEvents(function () use ($rows) {
foreach ($rows as $row) { Country::updateOrCreate(...); }
});
QueryCache::forget();
Joins don't auto-invalidate across tables. Repeating here because it's the highest-risk gotcha: the package can only know about models that fire events. A write to the joined-away table won't bust the cache. Either keep TTLs short on join caches or explicitly flush after any write to either side.
Serialization is real. Eloquent collections get serialized into the cache store. This is usually fine, but watch for:
$casts that change between deploys (cached models hydrate with the current casts, which can throw if a column was renamed).with() chains. Cache IDs and re-hydrate if payload size matters.Flush on deploy if you ship schema or model changes. A cached collection from the previous deploy can break ::hydrate() after a migration. Add QueryCache::forget() (or php artisan cache:clear for the scoped store) to your deploy script, at least while the package is in early adoption.
Testing checklist.
array cache in phpunit.xml so tests are isolated.DB::enableQueryLog() / expectsDatabaseQueryCount(1)).Maintenance reality check. This is a ~25-star, MIT, infrequently updated package (last release April 2023). Before adopting:
composer.json for allowed Laravel versions against your target (L10/L11).Cache::remember + a saved observer if the package ever stops working. Don't build a dependency you can't remove.When not to use it. Skip it for: tables with high write rates relative to reads, per-request queries already protected by eager loading, anything where a 5-minute stale window is unacceptable (auth, billing balances, inventory counts at checkout), and queries whose result must reflect the current transaction. For those, cache the shape of the data outside the query (denormalized read model, Redis counter) rather than the query result.
How can I help you explore Laravel packages today?