Pushword runs fine under classic per-request PHP-FPM. It is also safe to run in worker mode (a long-running process that reuses one kernel across many requests), which removes the per-request kernel boot.
Worker mode is provided by FrankenPHP's worker directive (or any Symfony Runtime worker). In the skeleton Caddyfile:
php_server {
root public/
worker index.php 1
}
Between requests the runtime resets every service tagged kernel.reset (services_resetter), exactly as a fresh FPM process would start clean.
Pushword is multi-site (host-driven) and multi-locale, so the concern with a reused kernel is one request's state bleeding into the next. It does not, because:
RequestContextListener resolves the current site/host and locale from the incoming request on kernel.request; the translator locale is reset by Symfony's kernel.reset.PageRepository (slug existence set, redirect maps) and MediaRepository (filename index) are onClear Doctrine listeners; the worker boundary clears the EntityManager, which cascades to onClear. The media filename index is additionally invalidated on every Media write via a cache.app version counter, so it can never serve a stale hit — in worker mode or across FPM requests.LinkCollectorService is reset at the start of every request.These invariants are guarded by tests in the worker group (packages/core/tests/Worker/), which replay two requests around the real worker reset and assert the second request never sees stale slug/redirect/media data or a leaked host/locale. CI runs them as a dedicated "Worker-mode safety guard" step.
In production (APP_ENV=prod, debug off) the heap is flat across a long mixed request stream — verified over hundreds of requests spanning multiple hosts and locales. Note that in dev/test (debug on) memory grows ~50 KB/request: that is the profiler-style collectors and the test harness's deprecation accumulator, none of which exist in prod. Always benchmark worker memory with debug off.
Worker mode's win is amortizing the kernel boot. For a light page that boot is the dominant cost, so worker mode can be several times faster; for pages doing heavy work (large renders, many queries) the relative gain shrinks. Measure your own workload — see WorkerVsFpmBenchmarkTest (benchmark group) for the in-process boot-amortization figure.
PageListener keeps a few static properties (the pending-redirect queue used during slug changes, plus skip/reentrancy flags). They are process-global, so it implements ResetInterface and clears them at the worker boundary — a slug-change redirect orphaned by a flush that threw before postUpdate can't replay in a later request, and a leftover skip flag can't disable redirects globally. If you add your own process-global static state on the save path, follow the same pattern.
The public front is the proven-safe path. The EasyAdmin back office reuses the same kernel under a worker, and its services were audited for cross-request state:
AdminFormFieldManager resolves the current user lazily from Security on each call (it does not capture it at construction), so the authenticated identity — and the ROLE_SUPER_ADMIN gate in UserRolesField — is always the current request's user.AdminExtension implements ResetInterface; its per-host tag cache is cleared at the worker boundary so a newly created tag is never hidden by a stale cache.These are guarded by packages/admin/tests/Worker/AdminWorkerStateResetTest. As with the front, audit any custom admin service that captures the request, the user, or the host at construction, or caches per-request data in a property — make it lazy or ResetInterface.
The benchmark suite is opt-in (excluded from CI via the benchmark group):
vendor/bin/phpunit --group benchmark
It covers static generation, repository cache warmup, search reindex, and the worker-vs-FPM comparison. The structural query-count guards (packages/core/tests/Perf/, packages/admin/tests/Perf/) run in normal CI and fail if a hot path (page render, admin list) starts issuing queries that scale with the corpus — i.e. an N+1 regression.