Caching Strategy
Cache-aside architecture, CacheService tags-or-fallback helper, cache map, TTLs, invalidation, and query-budget sentinels.
Binding
No generic model-cache / cache-through-Eloquent layer. Invalidation risk in a financial system is unacceptable; N+1s are fixed at the query level. Strengthen explicit cache-aside instead.
Drivers
| Driver | Tags | Multi-get | Notes |
|---|---|---|---|
redis (production primary) |
Yes | MGET / pipeline |
db0=cache, db1=queues, db2=sessions, db3=reverb |
database (self-host fallback / some test envs) |
No | WHERE IN |
Cache reads count as SQL queries in perf audits |
array (tests) |
Yes (Laravel 13) | In-memory | Process-local only |
file |
No | Sequential | Dev only |
All tags-dependent call sites go through App\Services\CacheService, which degrades to untagged keys when Cache::supportsTags() is false and logs a once-per-process warning. Tag flushes on untagged warehouses use an explicit key list or callback; otherwise entries expire on TTL.
Request-scoped memoisation
Prefer $this->app->scoped() bindings for services that memoize in-memory within a request (Octane-safe — reset each request/job):
CustomFieldDefinitionResolver,WidgetDataCache,RateResolverAvailabilityService,ShortageDetector, status registries, and similar
Use once() for pure in-request closures. Use Cache::memo() only when the same durable cache key is read multiple times in one request and you still want the store hit on the first call.
Do not use singletons for request-memoising services under Octane.
Cache map
| Domain | Keys / tags | TTL | Invalidation | Stats domain |
|---|---|---|---|---|
| Settings | settings:all / tag settings |
Indefinite | Write via SettingsService::flush |
settings |
| Navigation | navigation:{userId} / tag navigation |
1h (default; tunable) | Registration, role, module changes | navigation |
| Dashboard widgets | dashboard:widget:… + generation key |
5 min (default; tunable) | Generation bump (WidgetDataCache::flush) |
widgets |
| Custom field schema | schema:document.* / tag schema-registry |
1h | Schema / CF definition writes | custom-fields |
| Status / API registries | *:statuses:all, api:endpoints, … / tags list-entries, … |
Indefinite or 1h | List-value / catalogue writes | registries |
| Account insights | accounts:insights:{id} / tag account-insights |
5 min (tunable) | Account financial mutations | insights |
| Line-item picker catalogue | catalogue-items:catalogue:{warehouseId|none} / tag product-catalogue |
5 min | CatalogueItem, accessory, or product-rate write (CatalogueItemSearchService::flushCatalogue) |
registries |
| i18n strings | i18n:strings:{locale} |
Indefinite | Language import / create | i18n |
| Exchange rates | exchange-rates:{base} |
24h (tunable) | Rate write | (via CurrencyService) |
| Permissions | Spatie built-in | Spatie | Role/permission writes | — |
| Plugin registry | plugin cache keys | Indefinite | Plugin lifecycle | — |
Hit / miss instrumentation
CacheService maintains cheap forever counters:
cache-stats:{domain}:hitscache-stats:{domain}:misses
Domains: settings, navigation, widgets, custom-fields, registries, insights, i18n. Exposed on the Admin → System → Caching panel.
Dev vs prod query counts
Perf-harness numbers (signals:perf-audit) on CACHE_STORE=database count every cache get/put as a SQL query. Redis-warm baselines are materially lower for the same pages. Query budget sentinels encode both:
| Sentinel | Database driver | Redis driver | Duplicates |
|---|---|---|---|
dashboard |
≤40 queries | ≤25 queries | ≤19 |
Index pages (accounts, rentals, catalogue_items, invoices) |
≤60 | ≤60 | ≤19 |
Enforced by App\Services\PerfAudit\QueryBudgetGuard and the perf Pest group (tests/Feature/PerfAudit/QueryBudgetSentinelTest.php) for the deferred gate.
What we deliberately do not cache
- Eloquent models via a generic observer / trait layer
- Per-row financial snapshots beyond the named domain caches above
- Anything whose invalidation trigger cannot be named in this map