Skip to content

Container

A runtime, reflection-backed, PSR-11 dependency-injection container with auto-wiring, generic-typed resolution, fluent bindings, attribute autowiring, contextual bindings, tagged services, lazy services, decorators, and isolated child scopes.

Composer package: univeros/container Root namespace: Altair\Container


The Container is the wiring backbone of the Altair framework. Every package that builds object graphs — Http, Cache, Filesystem, Happen, Courier, Persistence, Messaging — delegates construction here.

It resolves typed constructor dependencies by reflection (cached), so most classes need no registration at all: ask for a class and you get a fully wired instance. Registration is for the cases reflection cannot infer — binding an interface to an implementation, supplying scalars, sharing singletons, factories, and so on.

Three principles shape the design:

  • Runtime, no build step. Bindings are registered at runtime and reflection is cached in memory (optionally on disk). There is no compile/warmup phase; the container can be reconfigured between requests.
  • Typed. make()/get()/call() carry generic @template types, so $container->make(Foo::class) is statically known to return Foo — no PHPDoc gymnastics at the call site.
  • Fail loud, fail safe. Unresolvable dependencies throw with the full resolution path (A -> B -> C); cycles are detected; and a failed resolution never corrupts the container’s internal state.
Terminal window
composer require univeros/container

It ships with the framework meta-package; standalone it requires only psr/container.

use Altair\Container\Container;
$container = new Container();
// Zero-config autowiring:
$service = $container->make(App\Service\Mailer::class); // typed as Mailer
// Bind an interface to an implementation:
$container->alias(App\Contracts\Clock::class, App\System\SystemClock::class);
// Share a singleton:
$container->singleton(App\Service\Mailer::class);
// A factory for a class the container can't autowire:
$container->factory(App\Db\Connection::class, fn(App\Config $config) => new App\Db\Connection($config->dsn()));
$mailer = $container->get(App\Service\Mailer::class);

Resolution vs. construction. get($id) returns a service — honouring a shared singleton if one exists. make($class, $params) always constructs a fresh instance (applying call-time parameter overrides). call($target, $params) invokes a callable with its parameters autowired. has($id) reports whether an id is explicitly registered (bindings, instances, or the container’s own self-binding) — it does not probe every autowireable class.

Definitions are fluent. bind($id) returns a Definition you configure: ->to(Concrete::class), ->using($factoryClosure), ->withParameters(['name' => $value]), ->shared(), ->lazy(), ->tag('...'). The sugar methods singleton(), factory(), instance(), value() and alias() cover the common cases. (Registration methods return a Definition, not the container, so configure one binding per fluent chain.)

Self-binding. The container resolves Container, Psr\Container\ContainerInterface, FactoryInterface and InvokerInterface to itself — a service-locator dependency receives the real container, never a fresh empty one.

Reflection is cached. Each class is reflected once into immutable metadata held in an ArrayReflectionCache (default) or a cross-request FileReflectionCache.

$container->bind(Report::class)->withParameters(['format' => 'pdf']); // raw param by name
$container->singleton(Clock::class, SystemClock::class); // shared, interface->impl
$container->factory(Connection::class, fn(Config $c) => new Connection($c->dsn())); // factory; params autowired
$container->instance(Config::class, $config); // pre-made, shared
$container->value('app.locale', 'en-GB'); // a raw value
$container->alias(LoggerInterface::class, FileLogger::class); // interface -> implementation

make() accepts call-time overrides by parameter name:

$report = $container->make(Report::class, ['format' => 'csv']);

Declarative wiring without a Configuration class:

use Altair\Container\Attribute\{Inject, Autowire, Factory, Lazy, Tag};
final class Handler
{
public function __construct(
#[Inject(FileLogger::class)] private LoggerInterface $logger, // resolve a specific id
#[Autowire(param: 'app.locale')] private string $locale, // a registered value()
) {}
}
#[Factory(WidgetFactory::class)] // build via WidgetFactory::__invoke()
final class Widget { /* ... */ }
#[Lazy] // defer construction until first use
final class HeavyService { /* ... */ }
#[Tag('reporters')] // collect under a tag
final class SalesReporter implements ReporterInterface { /* ... */ }

#[Autowire(service: X::class)] resolves a specific service for a parameter.

Give different consumers different implementations of the same dependency:

$container->when(NightlyJob::class)->needs(LoggerInterface::class)->give(FileLogger::class);
$container->when(ApiController::class)->needs(LoggerInterface::class)->giveValue($nullLogger);
$container->bind(SalesReporter::class)->tag('reporters');
$container->bind(StockReporter::class)->tag('reporters');
foreach ($container->tagged('reporters') as $reporter) { /* ... */ } // resolved lazily

extend() runs after an id resolves; decorators stack and run in registration order. A decorator that returns an object replaces the instance; returning nothing keeps the original (a side-effect hook).

$container->extend(FormattedResponder::class, fn(FormattedResponder $r) => $r->withFormatter(JsonFormatter::class));

A ->lazy() binding (or #[Lazy]) returns a placeholder that constructs the real instance on first use. On PHP 8.4+ this uses native lazy objects (ReflectionClass::newLazyProxy); on PHP 8.3 it resolves eagerly — behaviour is identical, only the deferral differs.

$container->call(fn(Clock $clock) => $clock->now()); // closure
$container->call([$controller, 'show'], ['id' => 42]); // [object, method] + overrides
$container->call(JobRunner::class); // invokable class (resolved, then __invoke)
$container->call('App\Support\helper'); // function name / 'Class::method'
$request = $container->createScope(); // inherits parent definitions
$request->instance(RequestContext::class, $context); // scoped: invisible to the parent

A child shares the parent’s definitions but keeps its own singleton store and may override bindings without mutating the parent.

Configuration — the ConfigurationInterface wiring pattern

Section titled “Configuration — the ConfigurationInterface wiring pattern”

Packages register their services through Altair\Configuration\Contracts\ConfigurationInterface::apply(Container $container):

final class MailerConfiguration implements ConfigurationInterface
{
public function apply(Container $container): void
{
$container->singleton(Mailer::class);
$container->alias(MailerInterface::class, Mailer::class);
$container->factory(Transport::class, fn(Env $env) => Transport::fromDsn($env->get('MAIL_DSN')));
}
}

Container defaults to a CachedReflector over an in-memory ArrayReflectionCache. For a cross-request cache, inject a FileReflectionCache (it serializes the extracted metadata — never live Reflection* objects, so it round-trips safely):

use Altair\Container\Reflection\CachedReflector;
use Altair\Container\Cache\FileReflectionCache;
$container = new Container(new CachedReflector(cache: new FileReflectionCache('/var/cache/altair')));

Implement Altair\Container\Contracts\ReflectorInterface or ReflectionCacheInterface to plug in your own.

$container = new Container();
// Swap an implementation:
$container->alias(MailerInterface::class, FakeMailer::class);
// Inject a pre-built mock:
$container->instance(Clock::class, $frozenClock);
self::assertInstanceOf(FakeMailer::class, $container->get(MailerInterface::class));

make() returns fresh instances; bind a singleton() (resolved via get()) when a test needs identity.

  • Custom reflector / cache: implement ReflectorInterface / ReflectionCacheInterface and pass them to the constructor.
  • Custom resolution: the container composes a Resolver, ParameterResolver, Invoker and ResolutionStack under Altair\Container\Resolution — small, single-purpose classes you can study or replace in a fork.

All extend Altair\Container\Exception\ContainerException (which implements PSR-11 ContainerExceptionInterface):

  • NotFoundExceptionget() for an unknown id (PSR-11 NotFoundExceptionInterface).
  • AutowireException — a parameter cannot be satisfied (renders the resolution path).
  • CircularDependencyException — a dependency cycle (renders the chain).
  • has() covers explicit registrations only — bindings, instances, and the self-binding; not every autowireable class. Don’t use it as a “can I make this?” probe.
  • No compile step. Resolution is reflection-driven at runtime (cached). For extreme hot paths, supply a FileReflectionCache; there is deliberately no Symfony-style compiled container.
  • Lazy deferral needs PHP 8.4+. On 8.3 a lazy binding resolves eagerly (still correct, just not deferred).
  • Unions/intersections are auto-wired best-effort (each union member is tried; an intersection needs a binding satisfying all members). Ambiguous cases should be bound explicitly.
  • Scopes inherit definitions, not parent singletons’ identity for child overrides — a child resolves inherited shared services from the parent; rebind in the child to scope them locally.