Container

Kinetis’s dependency injection container is split into two classes with deliberately different lifetimes: AppScope, which lives for as long as the worker process does, and RequestScope, which lives for exactly one request. Understanding why it’s split this way — and what guarantee that split is actually enforcing — matters more than memorizing the API surface, so this page leads with the reasoning.

AppScope — the persistent container

use Kinetis\Container\AppScope;

$app = new AppScope();

$app->bind(Logger::class, fn () => new FileLogger('/var/log/app.log'));
$app->instance(Config::class, Config::fromEnv());

$app->boot();

Everything registered on AppScope is built once and lives for the worker’s entire lifetime — this is exactly where a classic singleton’s state should live, just reached through the container instead of a static accessor (more on that below).

bind() registers a factory; instance() registers an already-built object directly. Both accept a $shared flag (default true for bind()) controlling whether resolving the same id twice returns the same instance or builds a fresh one each time.

Registration is only allowed before boot(). Once booted, the binding set is locked — this isn’t a style preference, it’s what makes “the route table and service definitions are fixed at server startup” an enforced invariant instead of a convention someone could quietly violate three files away from where it matters.

boot() itself registers two bindings for you, and only if you haven’t already registered your own for either: Psr\Log\LoggerInterfacePsr\Log\NullLogger (see Logging), and Kinetis\Config\ConfigConfig::fromEnvironment() (see Configuration).

RequestScope — the ephemeral container

$scope = $app->createRequestScope();

// ... handle one request using $scope ...

$scope->dispose();

Kernel::handle() creates exactly one RequestScope per incoming request and disposes it in a finally block, so a thrown exception partway through dispatch still can’t leak that scope into the next request. You will almost never call createRequestScope()/dispose() yourself — this is Kernel’s job — but understanding what happens inside it is what the rest of this page is actually about.

Resolution order

When RequestScope::get($id) is asked for something it doesn’t have a local binding for, it doesn’t just fall back to constructing anything that happens to exist:

  1. Delegate to AppScope, but only if AppScope has an explicit registration for $id. AppScope::has() deliberately does not fall back to class_exists() — an unregistered class is never treated as “available on AppScope.”

  2. Otherwise, autowire it locally, via constructor-parameter reflection. The resulting instance is cached only for the remainder of this request — in RequestScope’s own binding table, which is wiped entirely on dispose() — and it is never promoted to AppScope.

That second point is the actual guarantee this whole design exists to provide: a stray, unregistered $container->get(SomeClass::class) call can never accidentally turn into a persistent, cross-request singleton. Without it, the most natural-looking code — just resolving something you need, without first explicitly registering it — would be a silent trap: the first request to touch that class would decide, by accident, whether its state is request-scoped or worker-lifetime-scoped for every request after it.

Dispose hooks

$scope->onDispose(function (): void {
    // runs when this request's scope is torn down
});

onDispose() is the generic mechanism the request lifecycle’s cleanup hangs off of — Kernel uses it, unconditionally, on every request, to register TransactionGuard::rollbackDangling() (see Persistence), so a transaction opened and never explicitly closed still gets rolled back before the scope disappears. RequestScope itself has no idea TransactionGuard or database transactions exist; it just runs whatever callbacks were registered, in registration order, when dispose() is called.

The singleton rewrite

This is the concrete pattern worth internalizing if you’re bringing PHP-FPM habits into a persistent-worker codebase. The classic singleton:

The PHP-FPM-safe pattern that becomes dangerous under a persistent worker
final class Metrics
{
    private static ?self $instance = null;

    public static function instance(): self
    {
        return self::$instance ??= new self();
    }

    private array $counters = [];

    public function increment(string $name): void
    {
        $this->counters[$name] = ($this->counters[$name] ?? 0) + 1;
    }
}

// called from anywhere, no constructor injection needed:
Metrics::instance()->increment('requests');

Under PHP-FPM this is completely safe — the process dies after the request, so self::$instance never survives to see a second one. Under a persistent worker, self::$instance is now shared, mutable state visible to every request the worker ever handles, reachable from anywhere in the codebase with zero indication at the call site that it’s touching shared state at all.

The Kinetis-idiomatic rewrite keeps the same one-instance-per-worker lifetime, but makes it reachable only through the container:

The same lifetime, reached through the container instead of a static accessor
final class Metrics
{
    private array $counters = [];

    public function increment(string $name): void
    {
        $this->counters[$name] = ($this->counters[$name] ?? 0) + 1;
    }
}

// registered once, at boot:
$app->bind(Metrics::class, shared: true);
Consumed via constructor injection, not a static call
use Kinetis\Http\Attributes\Post;

final readonly class OrderController
{
    public function __construct(
        private Metrics $metrics,
    ) {}

    #[Post('/orders')]
    public function store(): array
    {
        $this->metrics->increment('orders.created');
        // ...
    }
}

Nothing about Metrics itself changed — it’s still one instance for the worker’s whole lifetime. What changed is reachability: the only way to get an instance is through the container, via constructor injection, which is exactly what makes RequestScope’s isolation guarantee hold. A static accessor is, by construction, reachable from literally anywhere, bypassing any scoping the container tries to enforce; a constructor parameter is visible in exactly the places that actually receive it.

NoStaticPropertiesRule — the enforcement PHP itself can’t provide

Everything above is a convention: a fresh RequestScope per request, and services correctly registered on the right tier. PHP doesn’t sandbox memory per request, so nothing stops application code from reintroducing exactly the state-bleed problem RequestScope exists to prevent — just via a static property instead of a singleton accessor:

This bypasses RequestScope’s isolation entirely, from inside a request-scoped class
final class RequestLogger
{
    private static array $entries = [];  // ← survives every request, forever

    public function log(string $message): void
    {
        self::$entries[] = $message;
    }
}

This is what Kinetis\Linting\NoStaticPropertiesRule exists to catch. It ships as a PHPStan rule under the framework’s main autoload — not a dev-only tool — because it’s meant to run against your application code, added to your own project’s phpstan.neon:

phpstan.neon
rules:
    - Kinetis\Linting\NoStaticPropertiesRule

It flags exactly one thing — a static property declaration — and nothing else; a static method or a plain instance property is left alone, since neither one holds state across requests on its own.

private static array $entries = [];
Static properties hold state across every request a persistent worker
handles until it restarts — exactly the cross-request state bleed a
fresh RequestScope per request exists to prevent. Use AppScope for state
that should genuinely persist for the worker's lifetime, or RequestScope
for state scoped to one request.

The escape hatch

The rule is a warning you opt into, not a language-level ban, and there’s a real, standard PHPStan mechanism for the rare case where a static property is genuinely safe — for instance, a memoized pure computation with provably zero per-request variance (the same value would be computed identically regardless of which request triggers it first):

/** @phpstan-ignore-next-line kinetis.noStaticProperties */
private static array $memoizedPureLookup = [];

Use this deliberately and rarely, with a real reason stated inline — not a blanket exemption.

Summary

AppScope

RequestScope

Lifetime

Worker process

One request

Registration

Only before boot()

Any time before dispose()

Falls back to autowiring?

No

Yes, for anything not explicitly on AppScope

Autowired instances promoted upward?

Never

Analogous to

A correctly-scoped singleton

A fresh object graph per request