Query caches
Propulsion has three independent, opt-in caches. Two of them cache result rows at different scopes, and the third caches a query’s compiled SQL string:
| Cache | Scope | Stores | Opt in with |
|---|---|---|---|
| L1 result cache | One request | The formatted result — collection, object, or count | setQueryCache() |
| L2 result cache | Across requests, and (driver permitting) across processes and hosts | Raw pre-hydration rows | setQueryCache() plus a configured backend |
| Compiled-query cache | The worker process | The SELECT SQL text | setCompiledQueryCache($key) |
Everything here is off by default. An uncached read is always correct; a cached one is a trade you make deliberately, per query.
The result cache
Section titled “The result cache”L1 catches a query repeated within one request — a lookup called from a loop, or from several unrelated call sites that don’t know about each other. L2 catches a query repeated across requests, which is what makes caching worth having in a worker-mode deployment where the process outlives the request.
Turning it on
Section titled “Turning it on”L1 needs nothing but the per-query opt-in:
$books = BookQuery::create() ->setQueryCache() ->filterByTitle('War And Peace') ->find();The first call executes the query and stores its formatted result — the PropulsionCollection, object, or count, exactly as find()/findOne()/count() would normally return it. A second call producing identical SQL and bound parameters returns the stored result without touching the database:
// Same generated SQL + params as above -> served from cache, no query.$books = BookQuery::create() ->setQueryCache() ->filterByTitle('War And Peace') ->find();L2 needs two gates, both required: a configured backend, and the same per-query opt-in above.
return [ 'datasources' => [ /* ... */ ], 'cache' => [ 'query' => [ 'enabled' => true, 'driver' => 'apcu', 'ttl' => 300, ], ],];Configuring a backend is not by itself consent to serve stale data across requests, which is why enabled defaults to false and turning it on is a config-file change — a deliberate, reviewable, deployment-level act. See Query cache configuration for every option, the driver comparison, and how to register your own PSR-16 pool.
doSelect()/doCount() on a generated Peer honor the same flag, so code that queries through the Peer layer directly gets the same caching behavior as ModelCriteria:
$c = new Criteria();$c->setQueryCache();$c->add(\Map\BookTableMap::TITLE, 'War And Peace');$books = \Map\BookPeer::doSelect($c);Hand-written SQL gets the same tiering, invalidation, and overload protection through Propulsion::rawQuery().
setQueryCache() in full
Section titled “setQueryCache() in full”public function setQueryCache(bool $b = true, ?int $ttl = null, bool $shared = true)$b— enable or disable caching for this query.$ttl— overridecache.query.ttlfor this query, in seconds. Only meaningful for L2; L1 is bounded by the request itself.$shared— whether this query’s result may reach L2 at all. Passfalseto keep a query in the request-scoped tier only.
shared: false is what you want for a query whose SQL text is stable but whose correct answer isn’t — anything built on NOW(), CURRENT_DATE, RANDOM(), or a LIMIT over an unstable ORDER BY. Such a query has an identical cache key every time, so its first result would otherwise freeze for the whole TTL, across every process sharing the backend rather than merely for the rest of one request:
$expiring = BookQuery::create() ->setQueryCache(true, shared: false) ->where("Book.ExpiresAt < NOW() + INTERVAL '1 hour'") ->find();Propulsion does not try to detect these by scanning SQL. That gives false positives on ordinary columns like now_at and false negatives on user-defined functions and views, and a confidently wrong detector is worse than none.
How entries are keyed
Section titled “How entries are keyed”The cache key is an xxh128 digest of the datasource, the formatter, the exact SQL string, and the serialized bound parameters. Nothing about how the query object was assembled contributes, so two differently-built Criterias that compile to the same SQL and parameters share an entry, and a single-character difference in a filter value is a miss.
The formatter is part of the key because the same SQL formatted as arrays and formatted as objects are different results. Hashing rather than concatenating matters for the same reason bounded caches matter: a joined query with a sizeable IN (...) list produces several kilobytes of SQL, and holding that live as an array key costs memory on every lookup.
L2 keys additionally embed a version token per table the query reads — see below.
Invalidation
Section titled “Invalidation”Every table has a version token in the shared backend, and a query’s cache key embeds the tokens of every table it reads. A write replaces that table’s token with a fresh random value, which makes every key derived from the old one unreachable in a single write — no index to maintain, no scan, no cross-process coordination.
Writes through the ORM bump the right tokens for you: save(), delete(), ModelCriteria::update()/delete()/deleteAll(), and direct BasePeer::doInsert()/doUpdate()/doDelete()/doDeleteAll()/doUpsert() calls. Invalidation is unconditional — a write to book drops every cached query that reads book, regardless of whether the write and the cached read otherwise appear related:
$books = BookQuery::create()->setQueryCache()->find(); // cached
$book = new Book();$book->setTitle('Anna Karenina');$book->save(); // invalidates every cached query that reads the `book` table
$books = BookQuery::create()->setQueryCache()->find(); // cache miss, re-queriesTokens are random values rather than incrementing counters, deliberately. PSR-16 has no atomic increment, so a read-add-write counter would race: two writers both reading v=7 both write v=8, and a result cached in between stays live and stale for its whole TTL. A blind random write has no read step, so the race cannot happen. It also means losing a token always fails toward a miss rather than toward staleness — an evicted token is reseeded to a never-used value, whereas a counter reseeded to 1 would resurrect orphaned entries.
Transactions
Section titled “Transactions”A write inside an open transaction buffers its token bump and publishes it on commit; a rollback discards it. And no query issued inside a transaction is ever published to L2, because such a SELECT can see your own uncommitted rows, and publishing those to a cache other processes read would leak them. In-transaction reads still use L1, so read-your-own-writes holds inside the transaction.
Writes Propulsion cannot see
Section titled “Writes Propulsion cannot see”Raw SQL, another application sharing the database, a migration, a DBA at a console — none of these invalidate anything, because Propulsion never sees them. Tell it:
$con->exec('UPDATE book SET ... /* hand-written */');Propulsion::invalidateQueryCacheForTables(['book']);Otherwise those entries stay served until their TTL lapses. TTL is the only backstop against writes Propulsion cannot see, which is why it defaults to a finite 300 seconds rather than “never expire”.
One driver-specific case belongs here too: with the apcu driver, a CLI process has its own shared-memory segment and therefore cannot invalidate the web tier’s entries. If anything writes to your database from CLI, don’t use that driver — see the driver comparison.
Which tables a query depends on
Section titled “Which tables a query depends on”Propulsion derives the dependency list from the query itself: both sides of every join, the primary table, the select columns, and any withColumn() expression — the last of those because such an expression reaches the SELECT list without contributing a FROM entry, so it can name a table appearing nowhere else. Every name is resolved through the alias map first, so a query built with setModelAlias('b', true) depends on book, which is what write paths bump, not on b.
The list does not descend into subqueries, CTEs, or set-operation branches. A query whose only reference to a table is inside one of those will not be invalidated by writes to it. Express such a query through rawQuery() with an explicit dependsOn(), or leave it uncached.
A query that ends up with no dependencies at all is refused rather than stored: an entry nothing can evict would be served until its TTL lapsed, including by invalidateQueryCacheForTables(). Such a query degrades to an ordinary uncached read.
What is never cached
Section titled “What is never cached”Some results can’t be cached correctly, and Propulsion declines rather than getting it wrong:
FORMAT_STATEMENTandFORMAT_ON_DEMANDresults, at either tier. Both are tied to a live statement, so a second hit would hand back an exhausted cursor.PropulsionFormatter::supportsRowCaching()reports this, and a cache-enabled query using either formatter simply runs uncached.- Results carrying
BLOBstream resources skip L2 and use L1 only — a stream cannot be serialized. Every row of the result set is checked, not just the first:serialize()doesn’t fail on a resource, it quietly writesi:0, so a partial check could publish an entry with its blob columns replaced by the integer0. - Queries with no table dependencies, as above.
- Queries issued inside a transaction, at L2 only.
Correctness caveats
Section titled “Correctness caveats”- Authorization decisions. Don’t cache a query whose result gates access.
- Object identity. An L1 hit returns the same object instances, so mutating a cached
find()result mutates what the cache holds. An L2 hit returns a freshly hydrated graph and behaves exactly like a real database read, instance pool included. - Read-your-own-writes is guaranteed within a process. Across processes it’s bounded by commit-to-publish latency plus the per-request token memo: a bump published by another process midway through your request isn’t observed until the next one.
- Non-deterministic SQL — use
shared: false, as above.
Bounds
Section titled “Bounds”L1 caps at QueryResultCache::MAX_ENTRIES (500 entries) with oldest-first eviction. Evicting is always safe — the worst consequence of a miss is re-running the query — and the cap is what stops a request that runs a cached query in a loop with varying parameters from retaining every result set it ever built. Note that the bound is on entry count, not bytes: 500 wide result sets is still a lot of memory.
L2’s bound belongs to its backend: max_entries for array, apc.shm_size for apcu, max_bytes plus a pruning cron for file, and maxmemory with an LRU policy for a third-party pool, which only you can set. See Sizing.
The compiled-query cache
Section titled “The compiled-query cache”A separate cache stores the compiled SQL string rather than the rows. It’s for the case where the same query shape is rebuilt over and over with only bound values differing: the SQL text is identical every time, so re-walking joins, columns, and criterions to re-derive it is wasted work.
$books = BookQuery::create() ->setCompiledQueryCache(__METHOD__) ->filterByTitle($title) ->find();Unlike setQueryCache()’s plain boolean, this takes a key you supply, and the key must uniquely identify the query’s shape. __METHOD__ inside a generated Query or Peer method is the natural choice, since “the same query shape rebuilt every request” is exactly what this targets.
Benchmarked at roughly 1.5× faster SQL construction for a join plus two WHERE conditions plus ORDER BY and LIMIT/OFFSET. The saving scales with how much FROM/JOIN/WHERE text there is to skip re-deriving — a single-table query sees less, a heavily-joined one more.
What “the same shape” means
Section titled “What “the same shape” means”Everything except ordinary bound scalar values must be identical between calls sharing a key:
- the same joins, and the same
WHERE/HAVINGcomparisons in the same order - the same number of elements in any
IN (...)list - the same
LIMIT/OFFSETvalues — these are written as literal integers into the SQL text on every platform, not bound - the same literal text for any
Criteria::CUSTOMraw expression
Only plain bound values may vary. This is your responsibility to get right: there’s no way to auto-derive a shape fingerprint that’s both cheaper than just building the SQL and safe against every case above, which is why the key is explicit rather than inferred.
As a safety net against the most common mistake — reusing a key for a query with a different number of bound parameters — a hit whose freshly-collected parameter count doesn’t match the count recorded when the entry was built throws a PropulsionException rather than returning mismatched SQL. That catches the common error, not every possible one: the same count with different structure slips through.
The compiled-query cache is process-scoped, and lives on ServiceContainer. Session::reset() deliberately does not clear it: an entry is a pure function of the datasource and the query shape — no bound values, no request data, nothing identifying who asked — and clearing it every request would make a worker recompile the same SQL forever, defeating the one deployment the cache exists for.
Two things follow from process scoping:
- The datasource is part of the key. SQL text depends on the adapter for identifier quoting and
LIMIT/OFFSETdialect, so two datasources running the same generated method with the documented__METHOD__key would otherwise serve each other’s SQL. The parameter-count guard can’t catch that: the shapes match and only the dialect differs. - It is bounded by
CompiledQueryCache::MAX_ENTRIES(1000, oldest-first), since it’s no longer emptied every request, and it’s cleared byPropulsion::setConfiguration()so a reconfiguration can’t leave the previous adapter’s SQL reachable.
Reach it through Propulsion::getServiceContainer()->getCompiledQueryCache(). Session::getCompiledQueryCache() is deprecated but still works, returning the same instance.
It has no invalidation to worry about — SQL text doesn’t go stale when a row changes.
Only the plain-SELECT path is covered. A query carrying common table expressions, set operations, or FROM-clause subqueries (addSelectQuery()) falls back to an uncached build every time — each of those recurses into further Criteria objects with their own parameters, which the fast parameters-only path this cache relies on doesn’t mirror.
Which one do I want?
Section titled “Which one do I want?”setQueryCache() | setCompiledQueryCache($key) |
|---|---|
| Caches rows | Caches the SQL string |
| Skips the database entirely on a hit | Still queries the database |
| Keyed automatically from datasource + formatter + SQL + params | Keyed by you, by query shape |
| Needs invalidation on write (handled for you) | Needs no invalidation |
| Request-scoped, plus a cross-process tier if configured | Process-scoped |
| Risk: serving a stale result | Risk: a wrong key serving mismatched SQL |
They’re independent and can both be enabled on the same query.
Related
Section titled “Related”- Query cache configuration — the
cache.querysection, drivers, admission control, stampede protection, and operations. - Caching hand-written SQL —
Propulsion::rawQuery(). - Running under a persistent worker — the request boundary these caches depend on.