elipzis/laravel-cacheable-model
Add automatic select-query caching to any Laravel Eloquent model with a single trait. Configure TTL, prefixes, and unique identifiers per model, skip manual cache calls, and rely on automatic flushing for inserts, updates, and deletes. Works with Redis, memcached, and more.
composer require elipzis/laravel-cacheable-model
php artisan vendor:publish --tag="cacheable-model-config"
Check the version you land on before writing code: 0.6.x targets Laravel 13 / PHP 8.5, 0.5.x targets Laravel 12 / PHP 8.4, 0.4.x targets Laravel 11 / PHP 8.3. Pin it explicitly in composer.json rather than floating ^ if your app straddles a Laravel upgrade, since the package overrides the query builder and minor bumps can change builder behaviour.
Then add the trait to a single model:
use ElipZis\Cacheable\Models\Traits\Cacheable;
class Currency extends Model
{
use Cacheable;
}
That is the whole integration. Existing Currency::query()->where('code', 'EUR')->first() calls now hit the cache on the second call.
Where to look first: config/cacheable-model.php (published) for ttl, prefix, identifier, and the logging block — turn logging on in staging to actually see hits/misses and understand the generated keys. Then the README's "Note on using caching" section, which is the honest warning: every query on a Cacheable model is intercepted, so this is a targeted tool, not a global switch.
First use case: pick a genuinely read-heavy, low-write lookup table — currencies, countries, categories, feature flags, static permission lists. Something read on nearly every request and written a handful of times a week. Benchmark one endpoint before/after (query count via Telescope or DB::listen) so you have a number to justify extending it to a second model.
Per-model tuning via the trait hook. Global config is usually wrong for at least one model, so override locally rather than editing the published file:
public function getCacheableProperties(): array
{
return [
'ttl' => 3600, // stable lookup data, long TTL
'prefix' => 'ref.currency',
'identifier' => 'code', // match the column your app filters on
];
}
Give every model a distinct prefix. Two models sharing the default cacheable prefix is the fastest way to serve the wrong rows.
Fresh reads on demand. After an admin save, a webhook handler, or anywhere you need read-after-write consistency inside the same request:
Currency::query()->withoutCache()->find($code);
A common pattern is a small repository/service method that takes a $fresh = false flag and appends withoutCache() when set — keeps the escape hatch out of controllers.
Explicit flushing after out-of-band writes. The trait hooks Eloquent inserts/updates/deletes. It cannot see DB::table('currencies')->update(...), raw SQL, upsert, or writes performed by a separate service against the same database. Wire those up yourself:
DB::table('currencies')->insert($rows);
Currency::query()->flushCache();
Same applies to artisan commands, seeders, and queue workers that mutate data outside the model. A CurrencyObserver or a saved/deleted event listener that calls flushCache() is a reasonable belt-and-braces layer if your team writes to the table from many places.
Cache-store choice matters. The package uses tagged cache entries internally (see the remove expired items from tagged cache fix in 0.5.0). Tags are supported by Redis and Memcached; the file and database drivers are not fully tag-capable. Configure a tag-capable store for anything you actually care about, and treat file caching in local dev as "works, but not representative".
Warm on deploy. Rather than letting the first production request eat the miss, run a small warmup step in your deploy pipeline or a scheduled command that touches the hot queries (Currency::query()->get(), etc.). This avoids a stampede on a cold cache after a release that clears Redis.
Testing. In feature tests with RefreshDatabase, cached results can leak between tests if the store outlives the transaction. Either force the cache store to array in phpunit.xml (<env name="CACHE_STORE" value="array"/>) or call flushCache() / Cache::flush() in setUp. Assert the cache path explicitly — one test that queries twice and asserts a single DB::listen entry is worth more than ten that just pass.
Cacheable model goes through an interception layer, including the ones you'd rather not cache (counts, exists(), pagination). If you apply the trait broadly "just in case", you pay serialisation and key-building cost on hot write paths. Apply it to two or three models and measure.DB::table(), raw statements, insertOrIgnore, or a second app sharing the database, before blaming the package.identifier must match reality. The default is id. If your code queries by slug or code and you leave identifier as id, cache invalidation on update may not target the entries your application actually reads. Set it to the column your where clauses use.whereIn('id', [1,2,3]) and whereIn('id', [3,2,1]) are distinct entries; user-scoped queries (where('user_id', $id)) can produce an unbounded key space. Do not make a model Cacheable if its queries are mostly per-user filters — you'll create a cache of thousands of near-useless entries.whereNull threw an "Undefined array key 'values'" error before 0.6.0; whereIn with an Expression was broken before 0.4.1; expired tagged entries were not removed before 0.5.0. If you're on an older tag and hit odd behaviour with null comparisons or subqueries, upgrade first.logging.enabled and point logging.channel at a dedicated channel (dynamic channel support landed in 0.5.0). Logs show key construction and hit/miss, which is usually faster than guessing why a query isn't cached.Category does not invalidate a cached Product query that joins or eager-loads it. If a query's result depends on another table, flush explicitly in that table's write path.withoutCache() / flushCache() calls, rather than subclassing the builder. Keep the package's surface area small in your codebase so swapping it out later is a one-file change.How can I help you explore Laravel packages today?