authentik/eloquent-cache
Eloquent Cache adds automatic query caching to Laravel models, storing results in your configured cache driver and invalidating them on create, update, and delete. Speed up repeated database reads with almost no code changes.
Install and enable a tag-capable cache store
composer require authentik/eloquent-cache
Before writing a single line of model code, confirm your cache driver supports tags (redis, memcached, array, dynamodb). The file and database drivers do not, and any tagged call will throw BadMethodCallException: This cache store does not support tagging. Set CACHE_STORE=redis in .env and add a dedicated connection/prefix so you do not collide with your app's other cached data.
Publish the package config if the repo ships one:
php artisan vendor:publish --tag=eloquent-cache
Where to look first
README.md — the trait names and the builder method (the public surface is small; the README is the contract).src/ — expect a Cacheable trait, a CacheableAttributes (or similarly named) trait, a custom query Builder, and a ServiceProvider. Read the Builder class first: it tells you exactly which query methods are overridden and which silently fall through to uncached Eloquent.tests/ — the fastest way to learn the intended call signatures for the 2023 release, since examples in the README sometimes lag the API.First use case: a hot slug lookup
use Authentik\EloquentCache\Cacheable;
class Post extends Model
{
use Cacheable;
}
Then, in a controller or repository:
$post = Post::cache()->where('slug', $slug)->first();
That single change is the lowest-risk win: read-heavy, rarely-changing, keyed by a unique column. Run it behind a feature flag, watch hit rate for a day, then expand to list endpoints. Do not start by wrapping your admin CRUD or anything that writes frequently — cache invalidation is where this package (and every cache layer) earns its keep.
Read-through caching on hot lookups
// Detail page
$post = Post::cache()->findOrFail($id);
// Home page list
$latest = Post::cache()
->where('published', true)
->latest()
->limit(10)
->get();
Keep a wrapper in a repository class so the cache call lives in one place:
class PostRepository
{
public function findBySlug(string $slug): ?Post
{
return Post::cache()->where('slug', $slug)->first();
}
}
This makes it trivial to remove the package later, or to swap it for Cache::remember() if you outgrow it.
Invalidate on write — and be explicit about it
The package typically hooks saved/deleted events to flush the model's tag, but you must verify this against the source. If it only exposes a manual flushCache(), drive it from a model observer or a booted() hook:
protected static function booted(): void
{
static::saved(fn (Post $post) => $post->flushCache());
static::deleted(fn (Post $post) => $post->flushCache());
}
Also flush on restored if you use soft deletes, and consider flushing related models' tags when a post changes (e.g. Cache::tags(['posts', 'home'])->flush()).
Tag strategy: per-model and per-record
Two levels of tags cover most needs:
posts — flushed when any post changes (list pages, feeds).post:{id} — flushed when that post changes (detail pages).Detail pages use the narrow tag; lists and aggregates use the broad tag. This keeps a single edit from nuking the whole app's cache.
Cache expensive computed attributes
If your model exposes a getStatsAttribute() that hits another table or an external API, cache the attribute rather than the whole row. Use the attribute trait the package provides and define which attributes are worth caching:
use Authentik\EloquentCache\Cacheable;
use Authentik\EloquentCache\CacheableAttributes;
class Product extends Model
{
use Cacheable, CacheableAttributes;
protected array $cacheableAttributes = ['stats', 'price_history'];
}
Only list deterministic, serializable values here. Anything depending on auth()->user() or the current request must not be cached.
TTL layering
Give volatile data a short TTL and stable reference data a long one. Override the package's TTL method per model:
public function cacheTtl(): int
{
return 300; // 5 minutes for a product detail
}
Pair a short TTL with tag flushing on write — TTL is a safety net, not the invalidation mechanism.
Testing
Use the array driver in phpunit.xml so each test starts cold, and assert both the cached and uncached path:
protected function setUp(): void
{
parent::setUp();
config(['cache.default' => 'array']);
Cache::flush();
}
Write a test that updates a model and asserts the next read reflects the change. That is the only test that reliably catches broken invalidation.
The license is GPL-3.0. This is the biggest risk for a commercial Laravel app. GPL is copyleft: if you distribute the application (SaaS is generally fine; shipping on-prem or a desktop/CLI binary is not), the GPL obligations may extend to the whole work. Get legal sign-off before adopting, or plan to reimplement the ~200 lines yourself. With 0 dependents and 57 stars, you are also the only maintainer of a fork if the upstream stalls.
Tag support is non-negotiable. Every cache() call goes through the tagged store. Redis or Memcached in production; array in tests. Never file or database.
Query-builder writes bypass events. Post::where('slug', $slug)->update([...]) fires no model events, so no automatic flush happens. Wrap bulk writes with an explicit Cache::tags(['posts', "post:{$id}"])->flush() or use the package's flush helper immediately after. This is the #1 source of "the cache is lying" bugs.
Serialization limits. Cached models are serialized and stored. Custom casts that produce non-serializable objects (closures, PDO handles, Guzzle clients, some Carbon-like value objects with resources) will throw on write or come back corrupted. Test with a model that has your most exotic cast.
Eager-loaded relations bloat the cache. If you cache Post::cache()->with('comments')->first(), every comment is baked into the cached payload. It never reflects new comments unless you flush the post's tag on comment creation. Either cache the model without relations and fetch relations live, or add a comments tag and flush it in the Comment observer.
Pagination is not cache-friendly. paginate() on a cacheable builder may cache a paginator whose total goes stale, or skip caching entirely. Cache the data (IDs or a get() of a page) and construct the paginator around it, or accept uncached pagination for list pages.
Use findMany over N× find. Repeated Post::cache()->find($id) inside a loop still makes N cache round-trips. Batch with a single whereIn and rely on the package (or a Cache::many) to reduce Redis chatter.
Debugging stale reads. Log the cache key you expect ($post->cacheKey() if exposed, or reconstruct the tag name) and hit Redis directly:
redis-cli --scan --pattern '*post*' | head
In development, register a cache listener to see hits/misses and the key that was requested:
Cache::listen(fn ($event) => logger()->debug(class_basename($event), (array) $event));
Version compatibility. Last release was April 2023. Verify it installs cleanly against Laravel 11/12 (composer why-not will tell you fast). If the constraint blocks you, pin it with a composer.json replace or fork — the codebase is small enough that a fork is usually cheaper than fighting version constraints.
Cache stampedes. A popular key expiring at the same moment causes thundering-herd spikes on your database. For genuinely hot keys, wrap the fetch in Cache::lock() or use a short jittered TTL to spread expiry.
Extension points to lean on. Override cacheKey(), cacheTags(), and cacheTtl() on the model to control behavior without touching the package. That is where you encode domain rules like "a post's detail cache is valid until its updated_at changes" or "reference data lives for a day."
Repeated reads within one request still round-trip to Redis unless the package memoizes per-instance. If you are calling .cache() several times in a single request for the same record, hoist the result into a local variable — cheap insurance.
How can I help you explore Laravel packages today?