PHP's defining operational property has always been that every request starts from nothing and ends with everything discarded. It is why PHP applications are so forgiving of sloppy state and why bootstrapping a framework happens tens of thousands of times a day to produce the same result.
Persistent application servers invert that. The framework boots once, the worker handles thousands of requests, and the per-request cost drops to the work you actually wanted to do. On a moderately large Laravel application that removed roughly 40ms of pure bootstrap from every request. It also removed the guarantee that made a whole category of bugs impossible.
Where the time actually goes
Before changing anything, measure the split between bootstrap and work. If your requests spend 180ms in database queries and 30ms booting, a persistent server is a fifteen percent improvement on a problem you should fix elsewhere first. The applications that benefit most are the ones with heavy service container wiring, many providers, and comparatively light per-request work — which is to say, most mature enterprise Laravel codebases.
FrankenPHP is the deployment shape I now prefer, because it collapses the web server and the PHP runtime into a single binary with a worker mode, and it removes an entire tier from the diagram. One process, one image, HTTP/2 and TLS handled, no separate FPM pool to size and monitor.
The state leaks, in order of likelihood
Anything that survives between requests is now shared. Singletons registered in a service provider hold data from whoever they served first. Static properties accumulate. A container binding that captured the request object serves a stale one forever. Global configuration mutated at runtime stays mutated.
// Leaks: the singleton captures the FIRST request's user and keeps it.
$this->app->singleton(ReportBuilder::class, function ($app) {
return new ReportBuilder($app['auth']->user()); // resolved once, forever
});
// Correct: resolve per request, or pass state in at call time.
$this->app->bind(ReportBuilder::class, fn ($app) => new ReportBuilder());
// Static accumulation is the other classic. This grows until the worker restarts.
class TenantRegistry {
private static array $loaded = []; // never cleared between requests
}
// Octane gives you the hooks; use them rather than hoping.
Octane::tick('flush-tenant-state', fn () => TenantRegistry::flush())->seconds(1);
// config/octane.php
'flush' => [TenantRegistry::class, CurrencyCache::class],
'warm' => [...Octane::defaultServicesToWarm()],The leak that costs real money is memory. A worker that grows by a few hundred kilobytes per request looks healthy for an hour and then gets killed. Set a max request count per worker so they recycle on a schedule, and graph worker memory over time — a sawtooth is fine, a staircase is a leak you have not found yet.
What to check before you switch
- Audit every singleton binding for captured request or user state. This is the highest-yield review you will do.
- Grep for static properties that accumulate, especially caches keyed by tenant or user.
- Confirm your third-party packages support a persistent runtime; older ones assume a fresh process per request.
- Set max requests per worker and alert on memory growth per worker, not just per container.
- Load test with a realistic tenant mix. Cross-tenant state bugs only appear when consecutive requests belong to different tenants.
A persistent worker is a long-lived process pretending to be a short-lived one. Every assumption you made about a clean slate is now a test case.
Used deliberately, the gain is real and the migration is a few days on a well-structured codebase. The failure mode to respect is that the bugs it introduces are not loud — they are one tenant occasionally seeing a value belonging to another, which surfaces as a confusing support ticket rather than an exception. Audit first, deploy behind a flag, and keep the old runtime available for a week.