Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Query Cache Laravel Package

juampi92/laravel-query-cache

View on GitHub
Deep Wiki
Context7

Getting Started

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

  • The README examples — they're short and cover 90% of the API.
  • config/query-cache.php — confirms which store/TTL/prefix you're actually using.
  • The service provider — to see how the macro is registered and whether invalidation listeners are auto-wired.

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.


Implementation Patterns

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.


Gotchas and Tips

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:

  • Appends/$casts that change between deploys (cached models hydrate with the current casts, which can throw if a column was renamed).
  • Relationships eagerly loaded — they serialize too, so cache size balloons on deep with() chains. Cache IDs and re-hydrate if payload size matters.
  • Anything bound to the current app container at hydration time.

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.

  • Bind array cache in phpunit.xml so tests are isolated.
  • Add a test that asserts the second call issues zero queries (DB::enableQueryLog() / expectsDatabaseQueryCount(1)).
  • Add a test that saving the model invalidates the cached read.
  • Add a test that two different users get different results for a per-user cached query.

Maintenance reality check. This is a ~25-star, MIT, infrequently updated package (last release April 2023). Before adopting:

  • Check composer.json for allowed Laravel versions against your target (L10/L11).
  • Read the macro + invalidation code — it's small enough to audit in ten minutes, and you should know exactly how the key is built.
  • Have a fallback plan: the pattern (key = hash of SQL + bindings, flush on model events) is easy to reproduce with 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager