Core Concepts¶
Boot-and-die and persistent workers¶
A boot-and-die runtime — PHP-FPM here — runs one request per script
execution, not one per process. An FPM worker process serves many
requests in sequence, but PHP discards the request’s userland state at
request shutdown and re-enters public/index.php from a clean slate for
the next one, so a static property set during one request is gone before
the next starts. The cost is that bootstrap runs again on every request:
the autoloader’s class map is rebuilt in memory, every registration
re-runs, and the container is constructed from scratch. OPcache keeps
those files’ compiled bytecode between requests; what executing them
builds does not survive.
A persistent worker — FrankenPHP’s worker mode, RoadRunner — runs
public/index.php once and feeds that one execution request after
request. Bootstrap runs once per worker and everything it built stays in
memory. That makes one class of bug possible that boot-and-die cannot
express: state leaking from one request into the next.
A warm worker still waits¶
A warm worker removes bootstrap cost, not waiting. A synchronous database query or HTTP call occupies the worker for as long as the response takes to arrive, and nothing else that worker could be doing gets a turn in the meantime.
Kinetis\Async is the other half of the picture: PHP Fibers, scheduled
by a Revolt event loop, let a request that’s waiting on one slow
operation hand control back so the worker makes progress on something
else instead of sitting idle. concurrently() uses this to run several
independent operations — a database query, an HTTP call, a cache read —
side by side, completing in roughly the time of the slowest one rather
than their sum. See Concurrency for the full picture.
HttpStartup owns the boot¶
An application’s public/index.php is the Composer autoloader plus one
call:
require dirname(__DIR__) . '/vendor/autoload.php';
Kinetis\Runtime\HttpStartup::run(__DIR__);
Kinetis\Runtime\HttpStartup is the startup program itself, owned by the
framework rather than copied into each project. Everything it does runs
once per execution of that entry script — once per FrankenPHP worker
thread, for that thread’s whole life, and once per request under PHP-FPM,
which re-enters the script every time. Nothing in it is request-specific;
the Kernel it builds is what handles requests. In order, it:
Loads
.env, then detectsAPP_ENV. That order matters:APP_ENVmay be defined for the first time in.envrather than already set in the process environment.Resolves routes, middleware and event listeners. Development discovers them live from source on every boot; production reads the
.kinetis-cache/compiled.phpartifact, falling back to one fresh compile when there is none this build can use — see Caching & AOT Compilation.Binds
FormLimitsandTrustedProxies, then runs the package and application bootstrap chain and callsAppScope::boot(). Both bindings land before the chain, sobootstrap.phpcan replace either one; the last write beforeboot()locks the container wins.Detects the runtime adapter, handing it the proxy policy read back out of the booted container rather than the one built in step 3 — so a
bootstrap.phpthat narrowed it decides this request’s scheme and client address.Constructs the
Kernelwith the adapter’s ownisPersistent(), and handsKernel::handle()to the adapter’s request loop.Disposes the application once that loop returns — a worker shutting down, or the end of the one request a boot-per-request SAPI served — so an app-scoped resource opened at boot is closed rather than abandoned. A loop that throws ends the worker with that exception instead; nothing here intercepts it. See Appendix: Container Lifecycle.
The request body takes no part in this. An adapter hands the body on as
raw PSR-7 bytes, and RequestBodyMiddleware bounds and parses it inside
the Kernel under whatever FormLimits the container holds — see
Middleware.
A deployment that wants one specific adapter instead of detection calls
HttpStartup::assemble() with its own factory and serves from the
result; see Runtime Adapters.
The Kernel — runtime-agnostic by design¶
Everything in Kinetis’s request-handling path converges on one class:
namespace Kinetis\Http;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class Kernel
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
// ...
}
}
Kernel::handle() takes a pure PSR-7
ServerRequestInterface and returns a pure ResponseInterface. It never
touches $_GET, $_SERVER, frankenphp_handle_request(), or any other
runtime-specific primitive — that boundary is deliberate and total. Every
execution environment bridges in through a small
RuntimeAdapterInterface (see Runtime Adapters), and Kernel
neither knows nor cares which one is driving it.
This matters for more than just testability. It means the same Kernel,
constructed identically, behaves identically whether it’s handling the
one request a PHP-FPM script execution serves or request #40,000 of a
FrankenPHP worker that’s been running for three days. Nothing in Kernel
itself is aware of which situation it’s in — except one flag,
$isPersistent, which exists for exactly one purpose: deciding whether to
force a gc_collect_cycles() call at the end of the request (see
below).
The request lifecycle¶
Every call to Kernel::handle() follows the same shape:
Any streamed response left unsettled by the previous request on this Kernel is released, before anything of this request runs.
The global PSR-15 middleware pipeline runs, resolved from
AppScope. It wraps everything below, so a global middleware that answers outright — a CORS preflight, an over-limit body, a rate limit — never reaches step 3 and noRequestScopeis created for it. See Middleware.The pipeline’s innermost handler creates a fresh
RequestScopefrom the persistentAppScopecontainer (see Appendix: Container Lifecycle).Creating the scope runs every initializer registered by installed packages through
AppScope::onRequestScopeCreated()(see Appendix: Container Lifecycle). Withkinetis/database-bridgeinstalled, one of them binds a lazyTransactionGuard: the first resolution in the scope builds the guard and registersrollbackDangling()on the scope’s disposal. If a request opens a transaction through the guard and something goes wrong before it’s explicitly committed or rolled back, this is the safety net that closes it anyway; a request that never resolves the guard builds none.kinetis/frameworkalone has no database concept. See Database.The router matches the request; the matched route’s own
#[Middleware]pipeline runs, resolved from thatRequestScope; aDispatcherresolves the controller’s parameters and invokes it.The
RequestScopeis disposed — whether the request succeeded, threw, or hit a 404/405.If
$isPersistentis true,gc_collect_cycles()runs.
A response that streams its own body is the one exception to steps 6 and
7. Its body is written after handle() has returned, by an adapter,
against code that resolves from that same scope — so disposal is deferred
onto a lease the response carries, and runs on whichever settlement
reaches that lease first: the emitter finishing, the response being
abandoned by an adapter that cannot stream it, the wrapper being replaced
by something else on the way out of the global pipeline, or the next
request finding it still pending. See Appendix: Container Lifecycle.
PHP’s garbage collector reclaims most memory through reference counting,
but circular references — two objects each holding a reference to the
other, a Fiber caught in one included — need the cycle collector to run
before they are freed, and that collector runs on its own heuristic
schedule rather than per request. Under boot-and-die that does not
matter: PHP releases the request’s memory at request shutdown,
uncollected cycles included, whether or not the process itself exits. In
a worker whose one script execution spans days, cycles accumulating between
the collector’s own runs are a slow memory leak, so collection is forced
at the request boundary. Skipping the same call under boot-and-die avoids
paying for a collection pass over memory that is about to be released
wholesale.
Why state isolation is enforced rather than advised¶
The classic singleton — a private static ?self $instance property — is
the shape of code that leaks state, and it is safe under boot-and-die: a
static property PHP discards at the end of the request is
indistinguishable from a request-scoped variable. The same property in a
worker whose script execution spans days is a value every subsequent
request can read and mutate.
That is why the container is split into two tiers, and why a PHPStan rule
banning static properties ships as part of the framework rather than as
advice in a README. Appendix: Container Lifecycle covers both, including the rewrite
of “singleton via static property” into “singleton via the container.”
What persistent workers do not change¶
Superglobal state ($_GET, $_POST, $_SERVER) needs no reset step
between requests. FrankenPHP repopulates every superglobal on each call
into the worker, and Kinetis\Runtime\SuperglobalsBridge reads them on
that basis. RoadRunner has none to reset: RoadRunnerAdapter builds the
PSR-7 request from RoadRunner’s own protocol and never touches one.
What is left is narrower than “any global state”: state your own
application code introduces outside the container’s request-scoping
mechanism, which is what the NoStaticPropertiesRule in Appendix: Container Lifecycle
catches.
See also¶
Appendix: Container Lifecycle —
AppScope,RequestScope, and the enforcement mechanism behind everything above.Concurrency —
concurrently(),Async\Socket, and the non-blocking database/Redis clients that make a warm process pay off during I/O, not just at boot.Middleware — the two PSR-15 pipelines wrapping every request through this lifecycle, and the built-in
ExceptionHandlerMiddlewarethat guarantees an uncaught exception still becomes a response.Runtime Adapters — exactly how
Kernelgets driven by FrankenPHP, PHP-FPM, RoadRunner, and AWS Lambda, and what each one is actually responsible for.Caching & AOT Compilation — what the production AOT artifact holds, and what it changes about this lifecycle.