Skip to main content

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:

InterfaceMethodsUse it for
Psr\Container\ContainerInterfaceget(), has()Resolving entries.
Dirthara\Container\Contract\ContainerConfiguratorbind(), singleton(), scoped(), instance(), when(), delegate(), extend(), afterResolving(), lazy(), tag(), tagByAttribute()Registering entries, contextual bindings, delegates, decorators, callbacks, lazy services, and tags.
Dirthara\Container\Contract\InstanceFactorymake()Building a new instance with some constructor parameters given.
Dirthara\Container\Contract\Invokercall()Calling a method or a closure with its parameters filled in.
Dirthara\Container\Contract\TagResolvertagged()Resolving every entry with a tag.
Dirthara\Container\Contract\Inspectorregistered(), describe()Finding out what is registered and where an entry comes from.
Dirthara\Container\Contract\ScopescopedInstance(), 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.

note

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.

tip

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 ruleRefusedWith
instance() or scopedInstance() with a value of another typeWhen it is registeredInvalidRegistrationException::incompatibleInstance()
bind(), singleton(), or scoped() to a class that does not extend or implement the typeWhen it is registeredInvalidRegistrationException::incompatibleConcrete()
A factory, an entry bound to another identifier, an extender, or a delegate that returns something elseWhen the entry is resolvedResolutionException::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);
ParameterTypeDefaultMeaning
abstractstringThe identifier to register.
concretestring, Closure, or nullnullAnother 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.

caution

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.

danger

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.

note

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.