DI
A strict PSR-11 container
The dependency injection is provided by the standalone lekoala/kaly-di package.
Introduction
The component is split in three small pieces:
Definitions— declares the object graph (bindings, factories, constructor parameters, callbacks);Injector— creates fresh objects and calls methods, resolving object dependencies through PSR-11;Container— a PSR-11 container that resolves and caches services.
In Kaly, these declarations belong in the owning module’s config.php. Definitions expresses the composition directly; it does not load a separate application configuration tree. The container then builds the declared objects on demand. Controllers and services receive their dependencies through constructors; container lookups belong at the composition boundary.
Usage
The container only exposes has and get (PSR-11). The basic usage is:
use Kaly\Di\Container;
use Kaly\Di\Definitions;
$definitions = new Definitions();
$definitions->bind(UserRepositoryInterface::class, UserRepository::class);
$container = new Container($definitions);
$container->has(UserRepositoryInterface::class);
$container->get(UserRepositoryInterface::class);
Any dependency of a resolved class is injected automatically from the container.
Definitions
Definitions is final. The main methods are:
bind(string $abstract, string $concrete)— map an abstract id to a concrete class (note the abstract → concrete order);set(string $id, string|object $value)— register an object, a class name, or a factory closure;rebind(string $id, string|object $value)— intentionally replace an existing definition (set()on an already defined id throws aDefinitionException);alias(string $alias, string $target)— make an id resolve to another entry (same shared instance, no second callback run);parameter(string $id, string $name, mixed $value)— supply one constructor argument;parameters(string $id, mixed ...$params)— supply constructor arguments by name;callback(string $id, callable $callback)— run a callback once the service is instantiated;merge(Definitions $other),has(string $id),lock().
Merging is additive: an id defined on both sides is a conflict, even with the same value. A failed merge leaves the object unchanged and reports every collision with its provenance.
$definitions
->bind(MailerInterface::class, SmtpMailer::class)
->callback(Translator::class, static function (Translator $translator): void {
$translator->setDefaultLocale('fr');
});
Factories run when their service is first requested and receive the container. Use it to resolve object dependencies when construction needs a factory:
use Psr\Container\ContainerInterface;
$definitions->set(ReportExporter::class, static function (ContainerInterface $container): ReportExporter {
return new ReportExporter($container->get(ReportRepository::class), format: 'csv');
});
Factories return an object or a class name. A closure that needs no dependencies may omit the container argument. Services resolved through the same id are shared; lazy construction does not mean a new instance on every request, nor does it delay the construction of a dependency once its consumer needs it.
The definitions are locked once the application has loaded them: no service can be registered afterwards. Constructing a standalone Container also locks the supplied definitions.
Constructor values
The container resolves objects, not scalar configuration entries. A string passed to set() denotes a class name: set('appName', 'my-app') is invalid. Supply scalars with parameter() / parameters(), or pass them directly in a factory.
For example, an application’s SearchClient implements SearchInterface and takes string $url and string $apiKey constructor arguments:
use Kaly\Di\Definitions;
use Kaly\Util\Env;
$di
->bind(SearchInterface::class, SearchClient::class)
->parameters(
SearchClient::class,
url: Env::getString('SEARCH_URL'),
apiKey: Env::getString('SEARCH_API_KEY'),
);
Object dependencies are still autowired. Parameters configured for a concrete class apply when it is resolved through a binding too; parameters for the requested service id take precedence over the class parameters. Parameter names must match the constructor. These parameters configure container construction; Injector::make() does not read them.
Here Env is read during registration. To read a value only when the service is built, supply a parameter closure, for example:
$di->parameter(SearchClient::class, 'url', static fn() => Env::getString('SEARCH_URL'));
A parameter closure receives the container and returns the argument value. Choose the timing deliberately: values captured during boot stay fixed, while a lazy read uses the environment at first resolution. Neither approach provides per-request configuration for a shared service. For .env loading and precedence, see App.
Keep these reads in the composition root. Introduce a policy or options object only when it is a meaningful dependency, as described in Modules.
Stateful services and request lifetime
Shared services must be re-entrant: in a worker the container outlives the request, so a service kept between cycles must never carry request state. Keep request state in HttpContext, action locals, or a value passed along — never in a shared singleton.
A stateful resource (an entity manager, a unit of work, a transaction) belongs to the operation, not to the request. Inject a shared factory and open a fresh resource per operation:
$entityManagerFactory->with(function (EntityManager $em) use ($order): void {
$em->transactional(static function () use ($em, $order): void {
// ...
});
});
After a failed transaction, throw the resource away and open a clean one — even inside the same request. The owner opens, the owner closes: Kaly never flushes or closes resources automatically at the end of a response.
Do not register a transient factory with Definitions::set() and expect a new instance per resolution: factories are shared, set() caches its result. There is deliberately no request-scope container: until a second real use case proves that one instance per HTTP cycle is needed, the per-operation factory above is the whole pattern.
Injector
Injector::make() creates a class and Injector::invoke() calls a callable, resolving the arguments from the container:
$injector = new Injector($container);
$controller = $injector->make(MyController::class, request: $request);
$result = $injector->invoke([$controller, 'myAction'], ...$params);
make() creates a fresh instance of the concrete class, independently of its container definition. It resolves object dependencies through the container, but scalar arguments must be supplied explicitly, have a default, or be nullable. Use Container::get() when you want the configured, shared service.
Strict definitions
The container is permissive by default: it can instantiate any existing class. Bind an interface to be able to resolve it — has() only returns true for an interface once it has been defined.
Strictness applies to configuration mistakes, not to autowiring:
- defining the same id twice, merging two definitions that own the same id, or rebinding an unknown id throws a
DefinitionException(unconditional, it also implements PSR-11ContainerExceptionInterface); - parameters configured for a constructor that does not declare them are rejected when the service is resolved — a typo’d parameter name fails fast instead of being ignored;
Injector::make()/Injector::invoke()validate the argument list first: unknown named arguments, the same parameter given twice, surplus positionals and positional-after-named throw anInvalidArgumentException. Only pass what the constructor declares;- a union parameter with several available candidates (
Foo|Bar) is ambiguous and fails with anUnresolvableParameterException: pass the dependency explicitly.
Exceptions
The component throws PSR-11 exceptions (NotFoundExceptionInterface, ContainerExceptionInterface) as well as a CircularReferenceException.