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 Eloquent Query Cache Laravel Package

affordablemobiles/laravel-eloquent-query-cache

Cache your Eloquent query results in Laravel to cut repeated database hits. This package wraps query builder calls with a cache layer, letting you store and invalidate results per model or query for faster responses.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm what actually got registered. The package is tiny (0 dependents, no ecosystem), so read the source rather than trusting the README — it will take ten minutes and saves you a debugging session later.

composer require affordablemobiles/laravel-eloquent-query-cache

Package discovery should register the service provider automatically. If you're on an older Laravel or have discovery disabled, add it to config/app.php manually, then:

php artisan vendor:publish --tag=query-cache   # tag name may differ — check the provider's publishes() call

Where to look first, in order:

  1. config/query-cache.php — the global TTL, cache store, and key prefix live here. Decide the store explicitly (Redis/Memcached for tags; avoid file in multi-server setups).
  2. The service provider — it registers the cache() macro on Illuminate\Database\Query\Builder. Confirm whether it's a macro or a custom builder, and confirm whether it hooks model events for invalidation.
  3. The key-generation code — this is the part you must understand, because it determines collision behaviour in multi-tenant or multi-connection apps.

First use case. Pick a genuinely hot, low-write read: a country list, a plan catalogue, a permissions matrix, a settings table. Something you already know is hammered on every request.

// Before
$plans = Plan::where('active', true)->orderBy('price')->get();

// After
$plans = Plan::where('active', true)->orderBy('price')->cache(3600)->get();

Then prove it works: enable query logging locally and watch the second identical call skip the database.

DB::listen(fn ($q) => logger($q->sql));

If the same SQL doesn't appear twice, the cache is live. If it does, the key is being regenerated per call — usually because a binding is non-deterministic (a Carbon instance, a float, an array with unstable ordering).

Implementation Patterns

Cache at the repository/service boundary, not in controllers. The macro is chainable and reads fine inline, but scattering ->cache() through controllers makes invalidation reasoning impossible six months later. Wrap it:

final class PlanRepository
{
    public function active(): Collection
    {
        return Plan::where('active', true)
            ->orderBy('price')
            ->cache(config('shop.cache_ttl.plans'))
            ->get();
    }
}

Now there is exactly one place that knows the freshness contract for plans.

TTL is a backstop, not your invalidation strategy. The package flushes on save, update, and delete of the affected model. Use a generous TTL (an hour, a day) for reference data and let the events do the correctness work. Use a short TTL only where you know writes bypass Eloquent.

Respect the boundary the events can see. Anything that reaches the database without going through the model will leave stale cache behind:

// None of these trigger model events — cached results go stale
DB::table('plans')->where('id', $id)->update(['active' => false]);
Plan::where('legacy', true)->update(['active' => false]);   // bulk update, no events
DB::statement('UPDATE plans SET price = price * 1.1');

If your codebase does any of this — or if a queue worker, an admin tool, or another app shares the database — you need an explicit flush after those writes, or a short TTL on the affected queries. Audit for DB::table, ->update() on builders, and raw statements before enabling the macro broadly.

Pair it with eager loading, not around it. Caching a relation-lazy-load per parent generates one key per parent row and quietly destroys your hit rate. Cache the outer query with its eager loads instead:

Order::with('customer', 'lines.product')->where('status', 'open')->cache(300)->get();

Use tags when your driver supports them and you want grouped invalidation:

Plan::where('region', $region)->cache(ttl: 3600, tags: ['plans', "region:{$region}"])->get();

Verify the signature in source — tags are driver-dependent and silently no-op on file and database stores. Don't build an invalidation strategy on tags unless you've confirmed the production store handles them.

Disable it in tests and in local dev. You want deterministic query counts and you want to see fresh data while developing:

// tests/TestCase.php or a service provider
config(['query-cache.enabled' => false]);

If there's no enabled flag exposed, bind a null/array cache store in the test environment — the array store resets per test anyway, which gets you isolation for free.

Assert the behaviour you think you have. A test that reads twice and counts queries is worth more than any amount of config review:

DB::enableQueryLog();
Plan::where('active', true)->cache(60)->get();
Plan::where('active', true)->cache(60)->get();
$this->assertCount(1, DB::getQueryLog());

Gotchas and Tips

The cache key is SQL + bindings — that's it. It typically does not include the connection name, database, or tenant. In a multi-tenant app with per-tenant connections, or a read/write split, two different tenants issuing structurally identical queries can share a key. This is the single most likely way to leak data across tenants with this package. Either verify the key includes the connection, or fold the tenant into the query itself (->where('tenant_id', $id)) so it lands in the bindings. The second is safer and you control it.

Bindings that serialize inconsistently fragment your cache. A Carbon with microseconds, a float, an array from whereIn built in different order — each produces a different key for a logically identical query. Keep cached query bindings scalar and stable. Cast dates to strings at the call site if you have to.

Pagination caches page-by-page and the total goes stale. ->paginate() typically includes the page number in the bindings, so each page is its own entry — fine — but if it runs a count() for the total, that count is cached too and won't reflect new rows until it expires. For paginated listings, keep TTLs short or cache only the underlying IDs.

cache() must precede the execution method. ->cache() after ->get() is a no-op on an already-materialised Collection; it needs to be on the builder. Similarly, confirm in source whether count(), exists(), pluck(), value(), and aggregates are intercepted at all — if only get()/first() are, don't assume your exists() guard is cached.

chunk, cursor, and lazy almost certainly bypass it (they stream the underlying cursor). Don't wrap them and assume you've cached anything. Wrap them in Cache::remember() yourself instead.

No lock means no stampede protection. On a popular key with a 5-minute TTL, expiry causes a simultaneous miss across every worker. On Laravel 11+ you can add jitter by varying TTLs per call, or wrap the hot path in Cache::flexible() at your repository level and treat the package's macro as a secondary layer.

Event-driven flushes can be blunt. If the flush is model- or table-scoped, a single unrelated save() may wipe the whole entry set, turning a write-heavy table into a cache that never hits. Watch hit rate, not just correctness. If flushes are too broad, narrow your TTLs or split cached queries so only the affected ones are invalidated.

Debugging checklist when a cached result looks wrong:

Cache::store(config('query-cache.store'))->flush();   // rule out the cache entirely
php artisan cache:clear                                // if the prefix lives in the default store

Then re-run with DB::listen to confirm whether the query actually goes to the database. If it does and the data is still wrong, the problem is upstream of the cache. If it doesn't, you have a stale entry — find the write path that bypassed the model.

Config and macro registration happen at boot. In Octane and long-running queue workers, run php artisan config:cache (or restart) after changing the TTL, store, or prefix. A stale boot-time config is a classic "I changed it and nothing happened."

Redis caveat: cache:clear does not clear tagged entries. If you adopt tags, invalidate via tags or flush() on the specific store — never assume the artisan command covered you.

Version-pin it and wrap it. With 0 dependents and no release cadence you can rely on for support, composer require with a caret constraint and no wrapper is a liability. Put every ->cache() call behind your own repository methods, as in the pattern above. Then, if you need to swap the package for a hand-rolled Cache::remember() using toSql() and getBindings() as the key, it's a two-file change rather than an archaeology project. The package is small enough that vendoring or forking is a realistic option if it goes unmaintained — check the license (Apache-2.0, permissive) and keep a copy of the source you depend on.

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
cgv/localized-resource