Running under a persistent worker
Propulsion is built to run under persistent-worker SAPIs: FrankenPHP worker mode, RoadRunner, Swoole, frankenphp run, and long-lived CLI consumers. One process serves many requests.
Classic PHP-FPM gives you a correctness guarantee for free: the process dies at the end of the request, so anything left behind is discarded. A worker has no such boundary. Every static property, every open connection, every memoised value survives into the next request, serving a different user. That single difference is the source of everything on this page.
If you’re deploying under PHP-FPM or running ordinary CLI scripts, none of this is required — the process exit does it for you.
The one hard requirement
Section titled “The one hard requirement”Call Session::reset() at every request boundary, in a finally.
// once, at worker startPropulsion::init('/path/to/runtime-conf.php');
// then, per requesttry { handle($request);} finally { Propulsion::getSession()->reset();}The finally is not a stylistic preference. A request that threw is the one most likely to have left an open transaction behind, and on PostgreSQL an uncommitted transaction poisons the connection for everything that reuses it — every subsequent statement fails with current transaction is aborted until an explicit ROLLBACK.
If you use Propulsion through a framework integration — Quiote’s own adapter, for instance — that integration calls reset() at its own request boundary and application code needs to do nothing.
reset() does seven things, in this order:
- Force-rolls-back any dangling transaction on every connection Propulsion has open.
- Clears every generated Peer’s instance pool, so one request’s hydrated objects can’t be handed to the next.
- Resets
forceMasterConnectiontofalse, so a request that opted into forcing master reads doesn’t leak that choice. - Clears the request-scoped (L1) query result cache and the shared tier’s per-request bookkeeping.
- Drops any outstanding instance-pooling suspension, so an abandoned
FORMAT_ON_DEMANDiteration can’t leave pooling off for every later request. - Zeroes each open connection’s debug counters — query count and last executed query.
- Clears the correlation id, so a request that set one doesn’t stamp it onto the next request’s queries.
What reset() does not do is flush buffered OpenTelemetry spans, because span export isn’t request-scoped state — it’s an outbound side effect with its own batch timer. Under a worker runtime that timer is the only thing that ever fires, so call Propulsion::flushTelemetry() alongside reset() if telemetry is on. Query observers themselves are process-scoped by design and deliberately survive the boundary.
Two things are deliberately not reset:
-
Propulsion::disableInstancePooling(), the explicit switch. That reads as deployment configuration — a batch worker that turns pooling off at boot means it for the life of the process. Only the transient, scoped suspension in step 5 is request state.That suspension is
Session::suspendInstancePooling()/resumeInstancePooling(), withisInstancePoolingSuspended()to read it, and it is a counter rather than a boolean — suspensions nest, so an on-demand iteration inside another one doesn’t have the innerresume()re-enable pooling out from under the outer one. An unbalancedresume()floors at zero rather than going negative, which is why step 5 can drop an outstanding suspension without having to know how deep it got. -
The compiled-query cache, which is process-scoped by design. See its scope.
What is shared, and what isn’t
Section titled “What is shared, and what isn’t”| Bucket | Home | Lifetime | Examples |
|---|---|---|---|
| Process-scoped | ServiceContainer, Propulsion’s statics | The worker process | Connections, adapters, table maps, the runtime configuration, the PSR-3 logger, the PSR-14 dispatcher, the L2 cache pool, the compiled-query cache |
| Request-scoped | Session | One request | Instance pools, the L1 result cache, per-request cache-version bookkeeping, forceMasterConnection, instance-pooling suspensions |
Propulsion::init() belongs at worker start, once — not per request. Connections follow the same rule and are deliberately reused; that reuse is most of the point of worker mode.
If you write your own code that memoises anything process-wide alongside Propulsion, the test is: is this value identical for every request this process will ever serve? If not, it belongs on the request-scoped side. Hydrated model objects always do — one escaped reference retains a subgraph of FK-related objects, referrer collections, and the Criteria that built them for the life of the process.
Threaded workers share the process, not just the request
Section titled “Threaded workers share the process, not just the request”Under FrankenPHP with worker <n> above 1, Propulsion::$session and every connection it reaches are one instance shared by every thread in the process. This is measured, not assumed.
There is no per-thread isolation to lean on, so what makes concurrent requests safe is the request-boundary reset — not separation between threads. Anything that would only be safe “because each thread has its own copy” is not safe here.
It also means per-process caches share less than they look like they do: the array cache driver is per-process and per-thread, and apcu is per-host but gives CLI its own segment. Both are covered in Choosing a driver.
Diagnostics are per-request figures
Section titled “Diagnostics are per-request figures”getQueryCount() and getLastExecutedQuery() live on the connection, which is process-scoped and survives the boundary — but they’re read as per-request numbers, which is what they were under PHP-FPM where the connection died with the request. Session::reset() zeroes them on every open connection via PropulsionPDO::resetDebugCounters(), which leaves the debug mode untouched (unlike useDebug(false), which clears the same two fields as part of switching mode).
If you implement the PropulsionPDO interface directly rather than getting it from PropulsionPDOTrait, you have to provide resetDebugCounters() yourself.
Everything long-lived is bounded
Section titled “Everything long-lived is bounded”In PHP-FPM an unbounded array is bounded by the request. In a worker it’s bounded by nothing, so every long-lived structure in Propulsion has an explicit cap:
| Structure | Cap |
|---|---|
| L1 query result cache | QueryResultCache::MAX_ENTRIES — 500 entries, oldest-first |
| Compiled-query cache | CompiledQueryCache::MAX_ENTRIES — 1000 entries, oldest-first |
| Per-connection prepared-statement cache | MAX_CACHED_PREPARED_STATEMENTS — 256, oldest-first (with PROPEL_ATTR_CACHE_PREPARES on) |
| L2 cache pool | Whatever you configure — max_entries, max_bytes, apc.shm_size, or Redis’s maxmemory |
Note that entry-count caps are not byte caps: 500 wide result sets is still a lot of memory. If your workload has very large result sets and you cache them, size on measurement rather than on the entry count.
Reclaiming the memory the legacy aliases cost
Section titled “Reclaiming the memory the legacy aliases cost”Propulsion installs 102 global class_alias() calls at load, so that historic bare names — Criteria, PropulsionPDO, PropelException, and friends — resolve without a namespace. In a worker that cost is paid by every process. You can skip it:
define('PROPULSION_SKIP_LEGACY_CLASS_ALIASES', true);Define it before Propulsion is loaded. Measured effect: 176 loaded classes and interfaces down to 1, and 3,208 KB down to 115 KB, per process.
It’s opt-out rather than opt-in because code using the bare names breaks silently without the aliases. PHP consults the autoloader for only some references to a missing class:
| Context | Autoloads? | Failure mode without the alias |
|---|---|---|
new Foo / Foo::bar() / class_exists('Foo') | yes | — |
catch (Foo $e) | no | the catch silently doesn’t match |
$x instanceof Foo | no | silently false |
is_a($x, 'Foo') | no | silently false |
A parameter or return type check against Foo | no | TypeError |
So the failures are wrong answers rather than errors, which is why this can’t be made lazy behind an autoloader and why it’s off by default.
Safe to set when nothing references the bare names:
- Namespaced schemas — generated code has always imported the runtime classes properly, so it never needed the aliases.
- Flat (global-namespace) schemas — newly generated code imports them too, as of 2.0. Regenerate first; existing generated code predates the change and still relies on the aliases.
- Your own code is the part to check. Grep for the bare names, paying attention to
catch,instanceof,is_a(), and type declarations.
This is not a PSR-4 or autoloading concern: Composer already autoloads Propulsion\… properly. The alias table exists to create global symbols, which no autoloader configuration can substitute for.
Worth measuring alongside this: opcache preloading. Preloaded classes live in shared memory instead of being materialised per process, which is the standard way to stop this class of cost scaling with worker count.
Known gaps
Section titled “Known gaps”Two things worth knowing before you build on them:
- Reconfiguring a live process keeps the schema maps, and only those.
initialize()andsetConfiguration()both drop the connection map, the adapter map and the memoised default datasource name — a registration made withsetDB()is superseded too — so the next access rebuilds all three from whatever configuration is now current.DatabaseMaps are the one thing deliberately left alone: they describe the generated model classes rather than the DSN, each one is registered once from a statement at the bottom of its own class file that never runs again, andDatabaseMap::getTable()has only a table name to resolve from — clearing it would leave every already-loaded model unable to find its own table for the rest of the process. Emptying the connection map also cannot close the connections in it: PHP ends a PDO connection when its last reference goes, and an adapter that cached the handle still holds one.close()andinitialize()therefore roll each pooled connection back before dropping it — onerollBack()per open nesting level — so its locks are released now rather than whenever the last holder happens to be collected. Under a per-request SAPI process death hid this; under a worker it did not, and the visible symptom was idle-in-transaction backends holding locks against which later work blocked. - Dropped-connection detection does not cover persistent connections. Everywhere else — every prepared statement, plus
exec()andquery()— a dropped connection is detected and evicted from the pool; underPDO::ATTR_PERSISTENT, PDO refuses the custom statement class that does it, so a dropped connection surfaces as a plainPDOExceptionwith no eviction. See Connection resilience, which also covers the two opt-in recovery features that matter most here: pre-checkout liveness pings and transaction retry.
Verifying it
Section titled “Verifying it”Propulsion’s own test suite includes a FrankenPHP worker harness (composer test:worker) that boots the ORM once and serves many requests from one process, calling Session::reset() between them — the same wiring a real deployment uses. It asserts that no pooled object, open transaction, or forceMasterConnection setting survives a boundary, that the same connection is reused, that memory stays flat under sustained load, and that the L2 cache survives a boundary while L1 does not. It runs single-threaded and at WORKER_THREAD_COUNT=4, against SQLite and PostgreSQL. See Working with the test suite.
Related
Section titled “Related”- The instance pool — what step 2 of
reset()clears, and why pools moved off the Peer classes. - Query caches — which tier survives the boundary and which doesn’t.
- Replication —
forceMasterConnectionis request-scoped for this reason.