Cache#
When a read comes back from Redis instead of the database, how it is invalidated,
and what to do when you need fresh data. Core: app/Core/Cache/, configuration in
config/Shared/cache.neon.
What actually gets cached#
Not the query — the FINISHED response
What is cached is the wire payload — what leaves the Api presenter (the
output of extractAll() / extractWithReferences()). Not SQL, not entities.
A hit therefore skips building the query, hydration and serialisation alike —
but it also means the cache holds data already assembled for one specific call.
The boundary is the Api presenter. Both the front end and the administration read through it, so both hit the same cache.
Three switches, stacked#
cache:
config:
enabled: false # the master switch
enabledServices: [] # allowlist of services (FQCN); empty = nothing
writeOnly: false # write but do not read (measuring the warm-up)
queryTtl: 300 # 5 minutes
| Layer | What it does |
|---|---|
enabled: false |
nothing is read or written, the cache is out of the picture |
enabledServices |
switching on service by service — only what is listed gets cached |
writeOnly: true |
writes but reads from the database — to see what would be stored |
enabled: true with an empty enabledServices caches NOTHING
Both must hold. That is deliberate: turning the cache on globally with one switch would turn it on where it has not yet been verified that invalidation actually happens.
The * wildcard only after every service is verified
enabledServices: ['*'] switches everything on at once. Until every service has
its non-ORM writes (raw DBAL) covered, that means serving stale data — and
nobody notices, because the page looks perfectly normal.
How the cache is invalidated#
A key carries tags = the FQCNs of the entities the response is built from. The tags are derived automatically from what the query used:
root entity + includes (relations) + fields in filters + fields in ordering
└───────────────── DependencyTagResolver ─────────────────┘
A write then merely bumps the tag's version (bumpFor() / bumpForClass()).
Old keys stop being valid at a stroke — nothing is deleted, they simply stop matching.
That is why nothing has to list what to delete
Saving an article bumps the Article tag, and every response that contained the
article — listings, details, related boxes — is out at once. With no list of keys
that nobody would keep up to date anyway.
A write OUTSIDE the ORM does not move the tag
A raw DBAL UPDATE, updateColumn(), a database trigger — none of them fires a
hook, so no version is bumped and the cache keeps serving stale data until the
TTL runs out. Whoever writes raw SQL has to bump the tag themselves.
Procedure: I need fresh data for one call#
The most common case. It touches no configuration, only that one call:
$payload = $this->bridge->call('api/blog/article/get-all', [
'filters' => $filters,
ApiBridge::PARAM_USE_CACHE => false, // ← this call always goes to the DB
], null, 'POST');
| Value | Meaning |
|---|---|
false |
do not read from the cache and do not write into it |
true |
cache it even if the caller would not otherwise |
null / omitted |
"no opinion" — the configuration decides |
The parameter is stripped from the call and never reaches the Api method
The bridge handles it, not the endpoint. The Api method does not have it in its
signature and must not — otherwise you get Unknown named parameter.
Procedure: I am adding a read that should be cached#
- Verify every write goes through a manager — that is, that the bump really fires.
- Hunt down non-ORM writes in the same module (raw DBAL,
updateColumn, triggers) and addbumpForClass()to them. - Switch on
writeOnly: trueand walk the page — writing into Redis starts, reads still come from the database. - Compare what got stored with what the page is supposed to show.
- Add the service to
enabledServices— only now is the cache read from. - Click through the write → read scenario: save, load immediately, and verify the new value is there.
Step 6 cannot be skipped
Missing invalidation does not surface as an error but as an old value — and an old value looks like the truth. The cheapest moment to catch it is right now.
What is deliberately not cached#
| Where | Why |
|---|---|
| The administration | the whole-request policy is switched off in startup() — an administrator must see what they just saved |
| Elasticsearch search | the result changes without a database write (reindex, score changes), so the cache would have nothing to invalidate on |
Queries with a NonCacheableFilter |
the key would have to contain the entire list of ids |
The administration switches the cache off for front-end managers too
When a read through a front-end manager runs inside an admin request, it applies to that as well. Otherwise an administrator would see fresh data in one place and stale data in another.
Other caches in the system#
| Cache | Where | Note |
|---|---|---|
| Route table | app/Core/Cache/Route/ |
its own routerEnabled switch, independent of the master one |
| Session | Redis | a different mechanism, a different lifetime |
| Doctrine, DI container, Latte | temp/cache/ |
files, not Redis |
The translation catalogue does not refresh itself
A new or changed language .neon has no effect until temp/cache/translation/
is deleted. Nette regenerates almost everything else on its own — not this — and
it shows up as raw keys printed instead of texts.
When the cache seems to be lying#
Work from the cheapest check up
- Repeat the call with
PARAM_USE_CACHE => false. If the result is correct, the problem is invalidation, not the query. - Find how that value is written — through a manager, or raw SQL?
- Check that the tag matches the entity the change happened on; it is derived from includes and filters, so an unexpected relation may be missing.
- Only then touch the TTL. A TTL is a safety net for holes, not a fix.
A Redis outage switches the cache off silently
The first connection error takes it out for the whole request and the database takes over. That is deliberate — the site must work without Redis — but it means "the cache does not seem to do anything" may mean "Redis is down".