Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Cache

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#

  1. Verify every write goes through a manager — that is, that the bump really fires.
  2. Hunt down non-ORM writes in the same module (raw DBAL, updateColumn, triggers) and add bumpForClass() to them.
  3. Switch on writeOnly: true and walk the page — writing into Redis starts, reads still come from the database.
  4. Compare what got stored with what the page is supposed to show.
  5. Add the service to enabledServices — only now is the cache read from.
  6. 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

  1. Repeat the call with PARAM_USE_CACHE => false. If the result is correct, the problem is invalidation, not the query.
  2. Find how that value is written — through a manager, or raw SQL?
  3. 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.
  4. 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".