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.

The rule

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
<?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 times

Try to register a service after boot and you get an exception, not a subtle bug:

PHP
<?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

LeakWhat stops it
Service registered mid-requestContainer::freeze() throws after boot
Per-request object cached process-wideAutowiring lives only on RequestScope, never on Container
Scope held past its requestRequestScope::close() makes later use throw ScopeClosed
Resource never releasedThe scope closes in a finally, so it runs on the error path too
Transaction left open on a shared connectionTransactionGuard rolls it back and logs
Upload temp files piling upThe kernel discards them when the scope closes
One user's session reaching anotherSessions are per-request objects, not $_SESSION
Render state shared between pagesTemplateEngine is config only; a Renderer is created per render

Three service lifetimes

PHP
<?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
    ) {
    }
}
Why autowiring is scope-only

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

Output
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, always

Routing 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
<?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();
This is enforced, not just documented

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
<?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 →