Architecture
The two process models, the boot/request split that follows from them, and the request lifecycle.
Almost every design decision in phporbit follows from one fact. It is worth ten minutes.
Two incompatible process models
The four deployment targets are really two:
- Per-request. Apache and nginx+FPM start a process, serve one request, and tear it down. Global state is free — a leak has nothing to leak into, because nothing survives.
- Long-lived worker. The built-in server and FrankenPHP boot once and serve thousands of requests in the same process. Anything mutable that outlives a request is visible to the next user.
A cached authenticated user, a request-scoped binding left in the container, a static property, an open transaction — under FPM these are invisible. Under a worker they are a security bug.
Worker-safe code is automatically correct on per-request SAPIs. The reverse is false. So assume the worker model everywhere, and treat worker safety as a correctness invariant rather than a deployment concern.
The boot / request split
phporbit separates the two phases by construction, not by convention.
<?php
use PhpOrbit\Kernel\Application;
use PhpOrbit\Kernel\Blueprint;
$app = Application::boot(static function (Blueprint $app): void {
// BOOT PHASE — runs once per process.
// Register services, middleware and routes here.
$app->container->singleton(Clock::class, static fn (): Clock => new SystemClock());
$app->loadRoutes(__DIR__ . '/routes.php');
});
// The moment boot() returns:
// * the route table is compiled
// * the container is frozen
// * every property of $app is readonly
//
// There is nothing mutable left to accumulate into.
$response = $app->handle($request); // REQUEST PHASE — runs many timesTry to register a service after boot and you get an exception, not a subtle bug:
<?php
$app->container()->singleton(Clock::class, $factory);
// PhpOrbit\Container\Exception\ContainerFrozen:
// Cannot register "Clock": the container was frozen when the application
// finished booting. Registering during a request would leak the definition
// into every later request served by this worker process.How each leak is closed
| Leak | What stops it |
|---|---|
| Service registered mid-request | Container::freeze() throws after boot |
| Per-request object cached process-wide | Autowiring lives only on RequestScope, never on Container |
| Scope held past its request | RequestScope::close() makes later use throw ScopeClosed |
| Resource never released | The scope closes in a finally, so it runs on the error path too |
| Transaction left open on a shared connection | TransactionGuard rolls it back and logs |
| Upload temp files piling up | The kernel discards them when the scope closes |
| One user's session reaching another | Sessions are per-request objects, not $_SESSION |
| Render state shared between pages | TemplateEngine is config only; a Renderer is created per render |
Three service lifetimes
<?php
// Once per process. Shared by every request. Must be stateless.
$app->container->singleton(Connection::class, static fn (): Connection => $database);
// Once per request. Disposed when the request ends.
$app->container->scoped(Cart::class, static fn (RequestScope $scope): Cart => new Cart());
// Not registered at all: autowired per request from the constructor signature.
final class ReportController implements Handler
{
public function __construct(
private readonly Connection $database, // the singleton
private readonly Session $session, // published by middleware
) {
}
}If the container cached an autowired instance, that instance would be shared by every later request in a worker. So Container::get() still refuses anything unregistered; only RequestScope::get() will build it, and what it builds dies with the request.
The request lifecycle
enterRequest() a fresh RequestScope is opened
│
├─ provide(RequestScope) so handlers can inject the scope
├─ schedule upload cleanup runs even if something throws
│
├─ router->match(request) routing happens BEFORE middleware
│ └─ provide(Route) so middleware can see which route matched
│
├─ global middleware ─────────┐ outermost first
│ route middleware ─────┐ │
│ handler ─┘ │
│ ◄────────────────────────── response travels back out
│
└─ finally: scope->close() teardown, alwaysRouting before the pipeline is a deliberate choice. It means a middleware can read the matched route — that is how CSRF honours a route's exemption — while still running for requests that matched nothing, so logging and auditing see 404s.
Where environment-specific code is allowed
Only in src/Sapi/ and the entrypoints (orbit, public/index.php). Above that boundary the request object is identical on all four targets.
That includes CLI-only constants:
<?php
// WRONG — STDERR is defined only under the CLI SAPI. This is a fatal error at
// boot under FPM, Apache and php -S.
$logger = new StreamLogger(STDERR);
// RIGHT — the php://stderr wrapper exists on every SAPI, and lands in the
// terminal under CLI, the pool log under FPM, the server log under Apache.
$logger = StreamLogger::standardError();tests/Unit/PortabilityTest.php tokenises the whole source tree and fails the build on CLI-only constants, superglobals outside the SAPI boundary, and any use of PHP's session extension. It tokenises rather than pattern-matches so the prose explaining a rule does not itself trip it. The test suite runs under the CLI, so nothing else would catch this class of bug.
Testing the invariant
State-leak bugs are invisible under per-request execution, so they get their own suite. Every test in tests/Worker/ boots once and handles at least twice:
<?php
public function test_a_scoped_service_starts_fresh_for_each_request(): void
{
$app = Application::boot(static function (Blueprint $app): void {
$app->container->scoped(Counter::class, static fn (): Counter => new Counter());
$app->routes->get('/count', static fn (ServerRequest $r, RequestScope $scope): Response =>
Response::text((string) $scope->get(Counter::class)->increment()));
});
// If the scope leaked, the second call would return "2".
self::assertSame('1', $app->handle(Requests::get('/count'))->body);
self::assertSame('1', $app->handle(Requests::get('/count'))->body);
}When you add framework-level behaviour, test it under both process models. More on testing →