DEV Community

Md. Yousuf Hossain
Md. Yousuf Hossain

Posted on

Stop Serving Stale Data: Automatic Eloquent Query Caching on Any Cache Store

How I built AutoCache — a Laravel package that caches your reads, flushes on every write, and doesn't need Redis to do it.


Query caching in Laravel is one of those things that sounds simple until you actually ship it.

You cache a query. It's fast. Everyone's happy. Then a background job runs a Model::where(...)->update(...), your cache doesn't notice, and for the next hour your users stare at data that no longer exists. You spend an afternoon adding manual Cache::forget() calls in seven different places, and you still miss the one raw insert buried in a service class.

I got tired of this pattern, so I built AutoCache — an open-source package that makes Eloquent query caching automatic and, crucially, self-invalidating. You add one trait to a model, and from that point on reads are cached and every write flushes the cache for you. No Redis requirement, no manual cache keys, no stale data.

This post walks through the problem it solves, how it works, and how it compares to the other caching packages out there.

The two problems with query caching

Most caching setups run into one or both of these:

1. The Redis tax. A lot of caching libraries only give you automatic invalidation if you're on a taggable store — Redis, Memcached, and friends. If your app runs on the file or database cache driver (which is completely reasonable for smaller apps), you either can't use the package or you lose automatic invalidation entirely.

2. Invalidation that leaks. This is the subtle, dangerous one. Many packages hook Eloquent's model events to know when to clear the cache. That works for save() and delete() on a model instance — but it quietly misses:

  • Bulk writes: Post::where('published', false)->update([...])
  • Raw builder writes: insert(), upsert(), insertUsing(), updateFrom()
  • increment() / decrement() / truncate()
  • "Quiet" writes that intentionally suppress events: saveQuietly(), updateQuietly()

Every one of those bypasses model events, so the cache never hears about the change. The result is stale data that appears randomly and is miserable to debug.

AutoCache is designed to close both gaps.

The setup: one trait

Here's the entire integration:

use Wddyousuf\AutoCache\Traits\Cacheable;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use Cacheable;
}
Enter fullscreen mode Exit fullscreen mode

That's the whole setup. The service provider and AutoCache facade are auto-discovered, so there's nothing to register. Now watch what happens:

Post::where('published', true)->get();   // hits the DB, caches the result
Post::where('published', true)->get();   // served from cache
Post::count();                           // cached
Post::find(1);                           // cached (per-row)

Post::create(['title' => 'Hello']);      // flushes Post's cache

Post::where('published', true)->get();   // fresh from the DB again
Enter fullscreen mode Exit fullscreen mode

Reads are cached transparently. Writes flush automatically. You didn't write a single line of cache-management code.

How it works under the hood

The trait backs the model with a CachedQueryBuilder, and everything funnels through two choke points:

  • Every SELECT goes through the builder's runSelect() (and exists()), so get, first, find, pluck, value, count, sum, exists, and even the pagination count query are all cached from one place.
  • Every write goes through the builder's write methods, which flush the model's cache — catching bulk updates, raw inserts, increment, truncate, and quiet writes that bypass model events.

Because the hook lives at the query-builder layer rather than on model events, those event-bypassing writes I listed earlier can't slip through. That's the core design decision that makes the invalidation trustworthy.

For invalidation itself, AutoCache uses cache tags when the store supports them (immediate, targeted flushing) and falls back to a per-model version counter on every other store (file, database, array, …). Either way, it just works — which is what unlocks the "no Redis required" property.

The details that matter in production

A caching layer is only as good as its edge cases. AutoCache ships with the ones you'd otherwise have to build yourself:

Row-level find() caching. Canonical find($id) lookups are cached under a stable per-row key, so a single row's cache survives writes to other rows:

Post::find(1);   // cached
Post::find(2);   // cached

Post::find(2)->update(['title' => 'Changed']); // only row 2's cache drops

Post::find(1);   // still from cache
Post::find(2);   // refetched, fresh
Enter fullscreen mode Exit fullscreen mode

Transaction-aware invalidation. Flushes respect your transactions — they apply after commit, and a rollback leaves the cache untouched, so you never invalidate for a write that didn't actually happen.

Stale-while-revalidate (Laravel 11+): serve an expired value instantly while it recomputes in the background.

Stampede protection, TTL jitter, and a result-size guard to keep a cold cache or a huge result set from taking your database down.

Octane-safe: it resets its process-static state between requests, so a long-lived worker never carries cache state across requests.

Per-query and per-model control when you need to override the defaults:

Post::withoutCache()->get();     // skip the cache for this query
Post::cacheFor(60)->get();       // custom TTL for this query
Enter fullscreen mode Exit fullscreen mode
class Post extends Model
{
    use Cacheable;

    protected $cacheStore = 'redis';
    protected $cacheTtl   = 600;
    protected $cacheMode  = 'opt-in';   // cache only queries you mark
}
Enter fullscreen mode Exit fullscreen mode

Testing cache behavior without counting SQL

One of my favorite parts. Instead of asserting on query counts or poking at the cache store, you swap in a recording fake and assert on behavior directly:

use Wddyousuf\AutoCache\Facades\AutoCache;

public function test_publishing_flushes_the_cache(): void
{
    $fake = AutoCache::fake();

    Post::factory()->create();

    $fake->assertFlushed(Post::class);
}
Enter fullscreen mode Exit fullscreen mode

There are matching assertNotFlushed, assertHit, and assertMissed assertions too.

How it compares to other packages

There are several good caching packages in the ecosystem — laravel-model-caching, eloquent-query-cache, and lada-cache among them. They're all worth knowing. AutoCache's differentiator is narrow and deliberate: complete write-path coverage on any cache store.

  • Any cache store: laravel-model-caching needs Redis/Memcached/APC/DynamoDB; lada-cache is Redis-only; eloquent-query-cache caches anywhere but only gets automatic invalidation on a taggable store. AutoCache works on file, database, and array too.
  • Event-bypassing writes: the event-based packages leave stale entries after bulk builder writes, raw inserts, and quiet saves. AutoCache and lada-cache hook the builder layer instead.
  • Transaction-aware invalidation, row-level survival, stale-while-revalidate, stampede protection, TTL jitter, a test fake, and warm/clear/stats Artisan commands — this combination isn't something you'll typically find bundled together elsewhere.

If you're already all-in on Redis and happy with your current setup, you may not need to switch. AutoCache's sweet spot is teams that either don't run Redis or want invalidation that categorically cannot miss a write.

Who should reach for it

  • Read-heavy apps — blogs, catalogs, dashboards — where the same queries run constantly.
  • Projects without Redis that still want real query caching.
  • Anyone who's been burned by stale-data bugs from hand-rolled caching.
  • Codebases with lots of bulk updates or raw inserts, where invalidation coverage matters.
  • Anyone who wants to assert on caching in their test suite.

A couple of honest caveats: cursor() is intentionally never cached (it streams), and direct DB::table() writes bypass Eloquent, so you'd call AutoCache::flush(Model::class) yourself after those.

Getting started

Requirements are PHP 8.1+ and Laravel 10, 11, 12, or 13 (the latest release, v0.2.2, added Laravel 13 support). Install it with:

composer require wddyousuf/eloquent-autocache
Enter fullscreen mode Exit fullscreen mode

Optionally publish the config if you want to tune TTLs, modes, and the rest:

php artisan vendor:publish --tag=autocache-config
Enter fullscreen mode Exit fullscreen mode

That's it — every store works out of the box, with nothing to migrate and no external service to run.

Wrapping up

I built AutoCache because I wanted query caching that I could add and then stop thinking about — no manual invalidation, no Redis prerequisite, and no quiet stale-data bugs six months later. If that resonates, give it a try.

The project is open source and still young, so feedback genuinely helps. If it's useful, a star on GitHub is appreciated, and issues and contributions are very welcome.

GitHub: https://github.com/wddyousuf/eloquent-autocache

Thanks for reading — I'd love to hear how it works out in your projects.

Top comments (0)