internal / container
Lightweight PSR-11 container for CLI tools: autowiring, scopes, inflectors and explicit destruction without a DI framework.
Fund package maintenance!
Requires
- php: >=8.2
- internal/destroy: ^1.0
- psr/container: 1 - 2
- yiisoft/injector: ^1.2
Requires (Dev)
- llm/skills: ^1.12
- revolt/event-loop: ^1.0
- spiral/code-style: ^2.3.1
- testo/codecov: ^0.2.1
- testo/fiber: ^0.1.3
- testo/testo: ^0.10.46
- vimeo/psalm: ^6.10 || ^7.0
Suggests
None
Provides
- psr/container-implementation: 1.0 - 2.0
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-07 21:11:22 UTC
README
Container
A lightweight PSR-11 container for CLI tools
A small PSR-11 container for command-line tools and other short-lived PHP processes: autowiring, a handful of
bindings and deterministic cleanup without a full-blown DI framework. No compilation, no config files, no lazy
proxies, nothing to set up beyond new ObjectContainer(). It was born in
Trap, grew up in DLoad and
Testo, and is extracted here so all of them can share one implementation.
What it does:
- Resolves classes by constructor autowiring through yiisoft/injector; resolved services are cached.
- Accepts bindings as a factory closure, an alias class name or predefined constructor arguments.
- Creates objects that implement
Factoriablethrough their staticcreate()method with autowired parameters. - Passes every resolved object through registered
Inflectors before it is cached. - Opens nested scopes: services resolved inside a scope live only until the scope closes.
- Destroys managed services on
destroy(), see internal/destroy.
Installation
composer require internal/container
Usage
use Internal\Container\Container; use Internal\Container\ObjectContainer; $container = new ObjectContainer(); // Autowired on first request, cached afterwards $downloader = $container->get(Downloader::class); // Factory binding $container->bind(Logger::class, static fn(Container $c) => new Logger($c->get(OutputInterface::class))); // Alias binding: an interface resolves to a concrete class $container->bind(ClockInterface::class, SystemClock::class); // Constructor arguments binding $container->bind(HttpClient::class, ['timeout' => 30]); // Register an existing instance; `destroy: true` hands its lifecycle over to the container $container->set($output, OutputInterface::class, destroy: true); // A fresh instance that is not cached $request = $container->make(Request::class, ['uri' => '/']);
Factoriable
A class that needs custom construction implements Factoriable and exposes a static create().
The container calls it with autowired parameters instead of the constructor.
use Internal\Container\Factoriable; final class Config implements Factoriable { private function __construct(private readonly Logger $logger) {} public static function create(Logger $logger): self { return new self($logger); } }
Inflectors
An Inflector sees every object right after it is resolved and may replace or configure it.
use Internal\Container\Container; use Internal\Container\Inflector; $container->addInflector(new class implements Inflector { public function inflect(object $object, Container $container): object { $object instanceof LoggerAwareInterface and $object->setLogger($container->get(Logger::class)); return $object; } });
Scopes
scope() runs a closure against a child state. Bindings are inherited, cached services are cloned into
the scope (readonly objects and enums are shared as is), and everything resolved inside is destroyed
when the closure returns. The parent state is left untouched.
$result = $container->scope(static function (Container $scoped): Result { $scoped->set(new SuiteConfig(...)); return $scoped->get(SuiteRunner::class)->run(); });
Scopes are fiber-aware: a scope opened outside an event loop is the active one for everything that runs on the loop under it. Opening a scope inside a loop-driven fiber and suspending within it is not supported.
Testing
composer test