Getting started
Create a container
use Dirthara\Container\Container;
$container = new Container();
A new container already holds itself under Container::class, Psr\Container\ContainerInterface, and each interface
in depend on the interfaces, so a class that needs the container can ask for it in its
constructor.
The registration methods, bind(), singleton(), scoped(), and instance(), return the container, so calls can be
chained.
Depend on the interfaces
The container implements one interface for each part of its work:
| Interface | Methods | Use it for |
|---|---|---|
Psr\Container\ContainerInterface | get(), has() | Resolving entries. |
Dirthara\Container\Contract\ContainerConfigurator | bind(), singleton(), scoped(), instance(), when(), delegate(), extend(), afterResolving(), lazy(), tag(), tagByAttribute() | Registering entries, contextual bindings, delegates, decorators, callbacks, lazy services, and tags. |
Dirthara\Container\Contract\InstanceFactory | make() | Building a new instance with some constructor parameters given. |
Dirthara\Container\Contract\Invoker | call() | Calling a method or a closure with its parameters filled in. |
Dirthara\Container\Contract\TagResolver | tagged() | Resolving every entry with a tag. |
Dirthara\Container\Contract\Inspector | registered(), describe() | Finding out what is registered and where an entry comes from. |
Dirthara\Container\Contract\Scope | scopedInstance(), resetScope() | Providing per-request values and ending a scope. |
Type against the interface that matches what the code does, rather than against Container:
use Dirthara\Container\Contract\ContainerConfigurator;
final readonly class MailServices
{
public function register(ContainerConfigurator $configurator): void
{
$configurator
->singleton(TransportInterface::class, SmtpTransport::class)
->when(Mailer::class)
->needs('$retries')
->giveValue(5);
}
}
Through ContainerConfigurator, a contextual binding is typed against interfaces in the same namespace as well:
when() returns a Contract\ContextualBindingBuilder, its needs() a Contract\PendingContextualBinding, and
give() and giveValue() a ContainerConfigurator again, as do the registration methods. A chain of registrations
never reaches a concrete class.
None of the interfaces extends another. Code that both registers and resolves entries asks for both.
Resolve an entry
get() returns the entry for an identifier, and has() says whether the container can find one.
if ($container->has(Mailer::class)) {
$mailer = $container->get(Mailer::class);
}
An identifier is any string. A class or interface name is the usual choice, because autowiring looks entries up by the
type of a constructor parameter, but a name such as 'config' works as well.
has() returns true for a registered instance, a binding, and any class the container could autowire. It does not
build anything, so get() can still fail for an entry has() reports: a binding can point at something that does not
exist, and a class can have a dependency the container cannot resolve. Error handling lists what
get() throws in each case.
When the identifier is a class or interface name, static analysers read the return type of get() as that class, so
$container->get(Mailer::class) is typed as a Mailer.
Entries named after a type
The container keeps that promise at runtime: when an identifier names an existing class or interface, the value it resolves to is an instance of it. Whatever cannot hold is refused as early as it can be found out:
| What breaks the rule | Refused | With |
|---|---|---|
instance() or scopedInstance() with a value of another type | When it is registered | InvalidRegistrationException::incompatibleInstance() |
bind(), singleton(), or scoped() to a class that does not extend or implement the type | When it is registered | InvalidRegistrationException::incompatibleConcrete() |
| A factory, an entry bound to another identifier, an extender, or a delegate that returns something else | When the entry is resolved | ResolutionException::incompatibleType() |
An identifier that does not name a class or interface, such as 'config', can hold any value.
Register an instance
instance() stores a value that the container returns as it is, every time. Under an identifier that is not a class
or interface name the value can be anything, including an array or null; under one that is, it has to be an instance
of it.
$container->instance('config', ['debug' => true]);
$container->instance(Clock::class, new SystemClock());
Bind an entry
bind() tells the container how to build an entry. The container builds it again on every get().
use Psr\Container\ContainerInterface;
// A class or interface resolved as another entry, which can itself be bound or autowired.
$container->bind(LoggerInterface::class, FileLogger::class);
// A factory, called with the container and the parameters given to make(), which get() leaves empty.
$container->bind(Connection::class, static fn(ContainerInterface $container): Connection => new Connection(
$container->get('config')['dsn'],
));
// A class bound to itself, autowired. This only changes behaviour together with singleton().
$container->bind(Mailer::class);
| Parameter | Type | Default | Meaning |
|---|---|---|---|
abstract | string | The identifier to register. | |
concrete | string, Closure, or null | null | Another entry or class to resolve instead, a factory that receives the container and an array<string, mixed> of parameters, or null to autowire abstract itself. |
A factory can declare only the container, as above, and ignore the parameters. See making new instances for where they come from.
Share an entry
singleton() takes the same arguments as bind(), but builds the entry once, on the first get(), and returns that
same value afterwards.
$container->singleton(Connection::class, static fn(ContainerInterface $container): Connection => new Connection(
$container->get('config')['dsn'],
));
$container->get(Connection::class) === $container->get(Connection::class); // true
Only the entry that was registered as a singleton is shared. After
$container->singleton(LoggerInterface::class, FileLogger::class), every LoggerInterface is the same object, but a
get(FileLogger::class) still builds a new one, because FileLogger itself is not shared.
Scope an entry
A long-running process, such as a worker that handles many requests, needs entries that are shared while one request is
handled and built again for the next. scoped() takes the same arguments as bind() and shares the entry like
singleton() does, until resetScope() ends the scope:
$container->scoped(UnitOfWork::class);
$container->get(UnitOfWork::class) === $container->get(UnitOfWork::class); // true
$container->resetScope();
$container->get(UnitOfWork::class); // a new UnitOfWork
A value that is created outside the container, such as the request itself, goes in with scopedInstance():
use Dirthara\Container\Contract\Scope;
function handle(Scope $scope, Request $request): void
{
$scope->scopedInstance(Request::class, $request);
try {
// handle the request
} finally {
$scope->resetScope();
}
}
resetScope() forgets every scoped instance and every value a scoped() entry built. Singletons, instances, and
bindings stay as they are. A scoped instance takes precedence over whatever is registered for the identifier, and does
not replace it: after resetScope(), a binding or an instance for the same identifier applies again.
A singleton that depends on a scoped entry receives it once, when the singleton is built, and keeps it after the scope ends. Make everything that depends on a scoped entry scoped as well, or give it the container and resolve the entry when it is needed.
There is one scope per container, not one per request or per Fiber. A scoped instance or a
value a scoped() entry built is visible to everything that uses the container, and resetScope() ends the scope for
all of them. Handle one request at a time per container: a server that handles requests concurrently in Fibers and
shares one container lets one request receive another's scoped values, such as its Request or its logged-in user.
Lock the container
Once an application has registered everything it needs, lock() stops the container from accepting more. After it,
every method of ContainerConfigurator throws a ContainerLockedException, and so do give() and giveValue() on a
contextual binding that was started before the lock.
$container->singleton(Connection::class, PdoConnection::class);
$container->scoped(UnitOfWork::class);
$container->lock();
$container->bind(Clock::class, SystemClock::class); // throws a ContainerLockedException
A registration made while the application is already handling requests would change entries that other classes have already received, so the lock turns that mistake into an error at the call that made it.
Resolving is unaffected, and so is the scope: scopedInstance() and resetScope() keep working, because a locked
container still has to take in each request and forget it afterwards. Locking twice does nothing, and there is no way to
unlock.
lock() is only on Container, not on any of the interfaces: the code that creates the container decides when
registration is over, and code that received a ContainerConfigurator cannot close it for everyone else.
Replace an entry
Registering an identifier again replaces what was there. bind(), singleton(), and scoped() discard a registered
instance, a scoped instance, and any value the entry already built, and instance() discards a binding and a scoped
instance.
$container->singleton(Clock::class, SystemClock::class);
$container->get(Clock::class); // builds and keeps a SystemClock
$container->instance(Clock::class, new FrozenClock('2026-01-01'));
$container->get(Clock::class); // the FrozenClock from now on
This also applies to the container's own entries: binding ContainerInterface::class to something else changes what
autowired classes receive when they ask for it.