Search by

mj-zana / container

mjnable

Dependency injection container for Zana applications

Package info

github.com/mjnable/zana-container

pkg:composer/mj-zana/container

Statistics

Installs: 49

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v2.1.0 2026-10-25 20:32 UTC

This package is auto-updated.

Last update: 2026-10-25 20:42:12 UTC


README

Dependency injection container for the Zana framework. Registrations are validated as a whole when the container is built, so wiring errors surface at startup instead of on the first request that needs a service.

Requires PHP 8.5 or later. Implements PSR-11.

Install

composer require mj-zana/container

Usage

use Zana\Container\ServiceCollection;

$services = new ServiceCollection();
$services->singleton(LoggerInterface::class, FileLogger::class);
$services->scoped(UnitOfWork::class);
$services->transient(CalculateBmiHandler::class);

$container = $services->build(); // validates everything, then freezes the collection

$scope = $container->createScope(); // e.g. one per HTTP request
$handler = $scope->get(CalculateBmiHandler::class);
// ...
$scope->dispose();

Only registered services are created. get() of an unregistered class throws NotFoundException, even if the class exists.

Lifetimes

Lifetime Instances From the root container From a scope
singleton one per container created and cached in the root the root instance
scoped one per scope throws ScopeException created and cached in that scope
transient new on every resolution allowed allowed

A singleton's dependencies always come from the root container, so a singleton can never capture a scoped service. build() rejects a singleton that depends on a scoped service, directly or through transients.

Registration

$services->singleton(Clock::class);                                   // self-registration
$services->singleton(LoggerInterface::class, FileLogger::class);      // class
$services->transient(Connection::class, fn ($resolver) => new Connection($dsn)); // factory
$services->instance(Config::class, $config);                          // existing object, never disposed

$services->singleton(CacheInterface::class, RedisCache::class, key: 'redis'); // keyed
$services->trySingleton(LoggerInterface::class, NullLogger::class);   // only if absent (package defaults)
$services->replace(LoggerInterface::class, SyslogLogger::class);      // keeps tags, aliases, decorators
$services->remove(LoggerInterface::class);

$services->alias('logger', LoggerInterface::class);
$services->tag('notifiers', EmailNotifier::class, SmsNotifier::class);
$services->decorate(LoggerInterface::class, PrefixLogger::class);     // or a closure

Registering a service again appends: get() returns the last registration and getAll() returns every unkeyed one in order. origin: (e.g. module:Patients) labels a registration in error messages.

Resolution

Constructor parameters are resolved in this order:

  1. Psr\Container\ContainerInterface or ServiceResolverInterface: the owning resolver
  2. a single registered class or interface type: get(type)
  3. a variadic class or interface type: getAll(type) (may be empty)
  4. otherwise the declared default, then null if nullable. Anything else is a build error.

Scalars, such as a DSN, come from factory registrations.

$container->get(LoggerInterface::class);
$container->getKeyed(CacheInterface::class, 'redis');
$container->getAll(NotifierInterface::class);
$container->tagged('notifiers');

Attributes and scanning

#[Singleton(service: LoggerInterface::class, tags: ['loggers'])]
#[Profile('production')]
final class FileLogger implements LoggerInterface { /* ... */ }

$services = new ServiceCollection(activeProfiles: ['production']);
$services->addAttributed(FileLogger::class);                    // one class
$services->scan(__DIR__ . '/src/Services', 'App\\Services', origin: 'module:App'); // a PSR-4 directory

Scanning only registers classes that carry #[Singleton], #[Scoped] or #[Transient]. Scanned registrations come before explicit ones, so an explicit registration of the same service wins.

when($condition, fn ($services) => ...) and profile('testing', fn ($services) => ...) register services conditionally.

Injection attributes and values

final class ReportService
{
    #[Inject] private Clock $clock;                               // property injection

    public function __construct(
        #[Inject(key: 'redis')] private CacheInterface $cache,     // a keyed registration
        #[Tagged('exporters')] private iterable $exporters,        // every member of a tag
        #[Env('REPORT_TIMEOUT')] private int $timeout = 30,        // an environment variable
    ) {}

    #[Inject]
    public function useLogger(LoggerInterface $logger): void {}   // method injection
}

$services->addParameterProvider(new EnvValueProvider());

Values for #[Env] come from EnvValueProvider, which converts them to int, float, bool, string or a backed enum. Other packages add their own value attributes, such as #[Config], by implementing ParameterValueProviderInterface.

A union, intersection or DNF parameter resolves when exactly one registered service satisfies it. If several do, the build fails (B10); pick one with #[Inject].

call() invokes a callable with its parameters resolved:

$container->call([ReportController::class, 'show'], ['id' => 7]);

Capabilities

Packages declare features they provide. Others can require them, or register extra services only when they're present:

$services->provide('reporting', origin: 'package:mj-zana/reporting');
$services->requires('reporting', origin: 'module:Finance');           // build fails (B14) if missing
$services->whenCapability('reporting', fn ($s) => $s->tag('routes', ReportRoutes::class));

$container->get(CapabilitiesInterface::class)->has('reporting');

A capability is presence metadata only; it never resolves to an object.

Scopes, observers, interceptors and lazy services

$request = $container->createScope(ScopeMetadata::http($requestId)->withAttribute('correlation_id', $id));
$job = $request->createScope(ScopeMetadata::queue('send-mail')); // nested: owns its own scoped services
  • Scopes can nest. Disposing a scope disposes its open child scopes first.
  • ScopeMetadata is immutable and can be injected. Singletons receive the root metadata.
  • addScopeObserver() is notified when scopes are created and disposed.
  • addInterceptor() wraps the creation of every new instance, for example to time it or trace it. The first interceptor added is the outermost.
  • lazy: true (or #[Singleton(lazy: true)]) resolves a native PHP lazy proxy. The real instance is created when its state is first accessed. Lazy services break constructor cycles. A class without instance properties cannot be lazy (B12), because PHP would never run its constructor.

Build validation

build() reports every problem at once in a ContainerBuildException, whose errors() method returns a list of BuildError objects, each with a code:

Code Rule
B1 the implementation is an instantiable class
B2 the implementation (or instance) satisfies the service type
B3 every constructor parameter can be resolved
B4 no circular constructor dependencies
B5 no singleton depends on a scoped service
B6 aliases point to registered services, without cycles
B7 tagged services are registered
B8 decorated services exist and class decorators are valid
B9 #[Inject] targets are registered
B10 union and intersection types are not ambiguous
B11 attributes are used correctly, and value attributes have a provider
B12 lazy services are classes PHP can make lazy
B13 scan directories exist and their files declare the expected classes
B14 required capabilities are provided

Limitation: factory and instance registrations are opaque to B3–B5. Their dependencies are only checked when they are resolved.

Analysis, graph and compilation

$report = $services->analyze();              // validates without creating anything
$report->isValid();
$report->errors();                           // list<BuildError>
$report->graph()->toArray();                 // nodes, edges, capabilities; JSON-encodable

$container->graph();                         // the same graph from a built container

For production, compile the validated registrations into a class:

$source = (new ContainerCompiler())->compile($services, 'App\\CompiledContainer');
file_put_contents($path, $source);           // storing it is up to you

$container = $services->build(compiled: \App\CompiledContainer::class);

A compiled build skips validation, directory scanning and attribute reading. Closures, instances, providers, interceptors and observers still come from the live collection. A fingerprint check throws StaleCompilationException when the registrations no longer match. It cannot see changes inside class files, so recompile when a registered class's constructor changes.

Disposal

Services implementing Zana\Container\Contracts\Disposable are disposed by the resolver that owns them:

  • A scope disposes its scoped services.
  • The root container first disposes its open scopes, then its singletons.

Disposal runs in reverse creation order, and decorators are disposed before what they wrap. Transients and instance() objects are never disposed. A scope that is dropped without dispose() is garbage-collected and not disposed, so always dispose scopes explicitly.

Development

composer install
composer test       # PHPUnit, then the same suite against compiled containers
composer analyse    # PHPStan level 8

The specification lives in the Zana design docs (packages/container.md).

License

MIT — see LICENSE.