Container
Three service lifetimes, autowiring, publishing per-request values, and deterministic teardown.
There are two objects. Container holds process-lifetime definitions and is frozen when boot ends. RequestScope holds everything belonging to one request and is thrown away when it finishes.
Registering services
<?php
Application::boot(static function (Blueprint $app): void {
// Once per process, shared by every request.
$app->container->singleton(
Connection::class,
static fn (): Connection => Connection::sqlite($path),
);
// Once per request.
$app->container->scoped(
ShoppingCart::class,
static fn (RequestScope $scope): ShoppingCart => new ShoppingCart(
$scope->get(Session::class),
),
);
});It is shared by every request the worker ever serves. Configuration, connections and compiled tables are fine. Anything that remembers this request — the current user, a cart, a request id — must be scoped() or autowired, or it becomes one user's data shown to the next.
orbit make:class Name --singleton or --scoped writes the class with that constraint as its comment and prints the registration line above, so the choice is made while the file is being created rather than after a leak.
Scoped factories receive the scope
Note the parameter type: scoped() hands your factory the RequestScope, not the Container. That is deliberate — resolving a dependency through the scope keeps you in the same request. Handing it the container would tempt a factory into opening a second scope and quietly missing everything middleware published into the real one.
Autowiring
An unregistered class is built from its constructor signature:
<?php
final class ArticleRepository
{
public function __construct(private readonly Connection $database)
{
}
}
final class ShowArticle implements Handler
{
// Nothing registered. Both are built per request.
public function __construct(private readonly ArticleRepository $articles)
{
}
}Container::get() refuses anything unregistered. Only RequestScope::get() will autowire, and what it builds dies with the request. If the container cached an autowired instance, that instance would be shared by every later request in the worker — the exact leak the two-phase split exists to prevent.
Failures are explicit, and name the parameter that blocked resolution:
CannotAutowire: Cannot resolve "$timeout" of App\Mailer::__construct(): it has no
class type and no default. Register "App\Mailer" with an explicit factory.
CannotAutowire: Cannot resolve "App\Clock": it is an interface or abstract class,
so it must be registered with an explicit factory.
CannotAutowire: Circular dependency while resolving "App\A": App\A -> App\B -> App\AInterfaces
Bind the interface, depend on the interface:
<?php
$app->container->singleton(
Clock::class,
static fn (): Clock => new SystemClock(),
);
final class ExpiryCheck
{
public function __construct(private readonly Clock $clock)
{
}
}Swapping in a FrozenClock for tests is then a one-line change at boot.
Publishing per-request values
Middleware uses provide() to hand something to everything further in. This is how the session and the matched route reach your controller:
<?php
public function process(ServerRequest $request, RequestScope $scope, Closure $next): Response
{
$tenant = $this->tenants->forHost($request->uri->host);
$scope->provide(Tenant::class, $tenant);
return $next($request);
}<?php
// Anywhere downstream, including autowired constructors
final class Dashboard implements Handler
{
public function __construct(private readonly Tenant $tenant)
{
}
}provided() asks whether an optional collaborator exists, which is how a layer stays useful when something upstream did not run:
<?php
if (!$scope->provided(Session::class)) {
return Response::text('SessionMiddleware must run first.', Status::InternalServerError);
}What the framework publishes for you:
| Type | Published by | Available when |
|---|---|---|
RequestScope | Kernel | Always |
Route | Kernel | A route matched |
ServerRequest | Kernel | At handler dispatch |
Session | SessionMiddleware | That middleware is registered |
Authenticator | Registered as scoped() in bootstrap | Always |
Teardown
onClose() registers work to run when the request ends — including when the handler throws:
<?php
$scope->onClose(static function () use ($handle): void {
fclose($handle);
});Callbacks run in reverse order, and every one runs even if an earlier throws, so a single failing release cannot strand the rest. The scope then marks itself closed:
<?php
$stale = $scope;
// ... request ends ...
$stale->get(Session::class);
// ScopeClosed: This request scope has been closed. Holding a reference to it
// beyond the request it belongs to would expose one request's state to another.An exception is far kinder than silently serving the previous visitor's session.
Resolution order
For $scope->get(Foo::class):
- Already built or provided this request? Return it.
- A
scoped()definition? Build it, cache for this request. - A
singleton()definition? Delegate to the container. - Otherwise autowire it, per request.
The freeze
<?php
$app->container()->isFrozen(); // true, once boot() has returned
$app->container()->singleton(Foo::class, $factory);
// ContainerFrozen: Cannot register "Foo": 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.This is the structural half of worker safety: not a convention to remember, but an exception you cannot get past.