spiritix/lada-cache
Lada Cache is a Redis-based, fully automated query cache layer for Laravel. It transparently caches Eloquent and Query Builder queries, invalidates only the affected rows or tables, requires no code changes, and scales horizontally with Debugbar support.
Version gate first. Lada Cache 6.x targets Laravel 12/13 and PHP 8.3+. If you're on Laravel 9–11, you'll install the 5.x line, and the behavior described below differs in places (no DB::extend() connection integration, no auto-flush on migrations). Check the compatibility table before composer require, because the major version you get is dictated by your framework, not by preference.
composer require spiritix/lada-cache
php artisan vendor:publish --provider="Spiritix\LadaCache\LadaCacheServiceProvider"
Redis is not optional. The package builds directly on Laravel's Redis client and does not support file, database, or memcached stores. 6.x adds a lada-cache.redis_connection setting (default "cache"), so confirm that connection exists in config/database.php and is reachable from every app server and queue worker. If Redis is down, expect query failures rather than silent fallback.
Add the trait once. The package no longer ships a base model class (that changed in 2.0). Add Spiritix\LadaCache\Database\LadaCacheTrait to a shared BaseModel that every model extends:
abstract class BaseModel extends Model
{
use \Spiritix\LadaCache\Database\LadaCacheTrait;
}
Where to look first: config/lada-cache.php. Read it end-to-end before you touch anything else — the defaults are tuned for a typical app, but include_tables, exclude_tables, key prefix, and expiration are the four knobs you'll actually turn. Then enable LADA_CACHE_DEBUGBAR=true locally and open a page: the Debugbar panel shows hits, misses, and invalidations per query, which is the fastest way to build intuition for what is and isn't being cached.
First use case: a read-heavy index or dashboard page that repeats the same Eloquent queries across users — permission lookups, category trees, settings tables, withCount() relations. Install, add the trait, reload. You should see the second load of that page drop its DB query count in Debugbar without a single line of application code changed. That's the whole pitch, and it's worth verifying on day one before you plan anything else.
Treat it as a read-path optimization, not a data layer. Lada intercepts select statements, caches results in Redis keyed on SQL + bindings + database, and invalidates automatically on insert/update/delete/truncate from any model carrying the trait. The correct mental model is "read-through cache over the query builder," and the correct scope is queries whose results are identical for many requests.
Start with per-request redundancy. The biggest wins are queries executed repeatedly inside one request or across many users — nav menus, feature flags, taxonomy, belongsTo lookups inside loops, aggregate counts on list pages. These are cheap to cache, rarely invalidated, and their staleness is invisible. Query-heavy report pages benefit next. Write-heavy CRUD screens benefit least, because every write invalidates and the next read misses.
Use withoutCache() deliberately, not defensively. The escape hatch skips both read caching and write invalidation for that query:
$balance = Account::where('id', $id)->withoutCache()->firstOrFail();
DB::table('counters')->where('id', $id)->withoutCache()->increment('value');
Reach for it on freshness-critical reads (auth token checks, checkout totals, stock counts immediately after a write within the same request), on diagnostics, and on ad-hoc busting. Do not sprinkle it across write paths casually — a write that bypasses invalidation leaves stale rows cached for other readers until expiration.
Exclude the volatile tables. sessions, jobs, failed_jobs, cache, cache_locks, Telescope and Horizon tables are read constantly and written constantly. Caching them produces near-zero hit rates while generating invalidation traffic and Redis churn. Put them in exclude_tables on day one. The inverse — include_tables — is useful when you want to scope the cache to a known set of hot reference tables during a gradual rollout.
Multi-connection apps need the connection integration. 6.x registers a Lada-aware connection via DB::extend() for core drivers, and models declaring $connection pick it up automatically. Queries issued through a connection the package didn't wrap won't be cached — verify per-connection with Debugbar rather than assuming.
Deployment and lifecycle. php artisan lada-cache:flush belongs in your deploy script (or rely on the 6.0 auto-flush on MigrationsEnded, but flushing explicitly is cheap and unambiguous). lada-cache:disable / lada-cache:enable are handy for a live incident where you suspect stale data — they toggle global state without a redeploy. For CI, set LADA_CACHE_ACTIVE=false in phpunit.xml so tests aren't silently served stale reads between test cases, or flush in setUp if you're specifically testing cache behavior.
Instrument before you tune. Run with Debugbar on a representative page, record the hit ratio, then flip expiration or move tables into exclude_tables and re-measure. The README's "10–30% faster responses" is a population average, not a promise; your number depends entirely on how redundant your query patterns are.
Raw SQL is invisible to the cache — in both directions. DB::select(), DB::statement(), and any DB::raw() write executed outside the query builder are neither cached nor used to invalidate. This is the single most likely source of "why is my data stale" in production. If you have raw writes anywhere — bulk imports, scheduled jobs, admin tools, an INSERT ... ON DUPLICATE KEY — follow them with lada-cache:flush or refactor to the builder.
Row-level invalidation is best-effort. It depends on standard single-column primary keys. Composite or unconventional PKs silently degrade to table-level invalidation — still correct, just coarser, meaning one write evicts every cached query touching that table. Same fallback applies to UNION/INTERSECT/advanced expressions and to whereHas/whereDoesntHave edge cases. Correctness holds; hit rate suffers. Watch Debugbar's invalidation counts to spot tables that thrash.
Third-party packages with custom query builders bypass caching entirely. Anything that ships its own Builder subclass — some admin panels, search packages, multi-tenancy layers — will issue uncached queries, and if it writes, it may not invalidate either. Audit your dependency list before assuming full coverage.
Pessimistic locks correctly bypass the cache. lockForUpdate() and sharedLock() skip caching, which is what you want. exists() reads through the cached select path when not locked. Don't be surprised when locked queries show as misses — that's the design.
Pin at 6.1.1 or later. 6.1.1 fixes a cache key collision for deferred query mutations and a query handler performance issue; 6.0.2 fixed row-level invalidation and returning created IDs; 6.0.3 fixed nested join clause bugs. The 6.x line had a dense cluster of small correctness fixes on release — running 6.0.x is a false economy.
Set an expiration even though invalidation is automatic. Invalidation is event-driven, and events are the thing most likely to be missed (raw SQL, external writers, a second app writing to the same database). A bounded TTL converts a class of silent-staleness bugs into a bounded delay. It costs little on hot keys.
Redis memory is your new production metric. Cached result sets live in memory, and a query returning a large payload costs more to move and encode than it saves — the README's 500MB example is real. Monitor used_memory, watch for eviction, and consider excluding tables whose result sets are large and rarely re-read. Since 6.0, Cache::flush() prefers non-blocking UNLINK, which helps, but doesn't change the steady-state footprint.
Debugbar integration in production is a footgun. Keep LADA_CACHE_DEBUGBAR local-only. It's invaluable for diagnosis and adds overhead plus data exposure you don't want on a live request path.
Flush is global. lada-cache:flush clears the entire cache, not a table or model. There's no built-in targeted purge beyond letting the invalidation events do their work — plan targeted busting around withoutCache() writes or a manual Redis key deletion using the configured prefix.
The extension points are the trait, the config, and the reflector. If you need custom cache-key semantics or handling for a SQL construct the reflector mis-parses, that's a library-level change (fork or PR), not an application-level one. Budget accordingly — most teams won't need it, but the ones who do will discover it in staging rather than by reading the docs.
How can I help you explore Laravel packages today?