Skip to content

Container

Ray Fung edited this page Feb 26, 2026 · 3 revisions

DI Container

Razy includes a full-featured dependency injection container with auto-wiring, scoped/contextual bindings, tags, extenders, resolving hooks, rebind support, and PSR-11 compliance. It manages service lifecycles (transient, singleton, scoped, instance) and provides hierarchical parent/child isolation for the module system.


Table of Contents


PSR-11 Compliance

The Container implements PSR-11 compatible interfaces without requiring psr/container as a Composer dependency. Razy defines its own matching interfaces:

PsrContainerInterface              ← get(), has()
  └── ContainerInterface            ← bind(), singleton(), scoped(), instance(), make(), tag(), ...
        └── Container (concrete)
use Razy\Contract\Container\PsrContainerInterface;

// PsrContainerInterface defines:
//   get(string $id): mixed
//   has(string $id): bool

// ContainerInterface extends with:
//   bind(), singleton(), scoped(), instance(), make(),
//   tag(), tagged(), when(), alias(), factory(), ...

Binding Services

Transient Binding

A new instance is created on every make() / get() call:

// Explicit factory
$container->bind(MyService::class, function (Container $c) {
    return new MyService($c->make(Config::class));
});

// Auto-wiring (Container resolves constructor deps automatically)
$container->bind(MyService::class);

// Concrete-to-concrete (must satisfy instanceof / LSP)
$container->bind(LoggerInterface::class, FileLogger::class);

Singleton Binding

A shared instance — created on first access, reused for all subsequent calls:

$container->singleton(DatabasePool::class, function () {
    return new DatabasePool(['host' => 'localhost']);
});

// Auto-wired singleton
$container->singleton(CacheService::class);

Scoped Binding

A request-scoped singleton — shared within a single request scope, cleared between requests via forgetScopedInstances(). Designed for worker mode (Caddy, Swoole, RoadRunner):

$container->scoped(RequestContext::class, function () {
    return new RequestContext($_SERVER);
});

$container->scoped(UserSession::class);

// Between requests in worker mode:
$container->forgetScopedInstances(); // clears scoped caches, not definitions

Lifecycle comparison:

              PHP-FPM (per-request)        Worker Mode (persistent)
              ─────────────────────        ────────────────────────
Transient     New every make()             New every make()
Singleton     Created once, reused         Created once, lives forever
Scoped        ≡ Singleton (same effect)    Created once per request,
                                           cleared by forgetScopedInstances()
Instance      Pre-built object             Pre-built object

Instance Registration

Register an already-existing object directly:

$container->instance(ContainerInterface::class, $container);
$container->instance('config', $configObject);

Conditional Binding

Register only if the abstract is not already bound — prevents accidental overrides:

$container->bindIf(MyService::class, ConcreteImpl::class);
$container->singletonIf(CacheService::class);
$container->scopedIf(RequestContext::class);

Aliases

Map a short name to an existing abstract binding. Alias chains are resolved up to 10 levels deep:

$container->singleton(CacheService::class);
$container->alias('cache', CacheService::class);

$cache = $container->get('cache'); // resolves CacheService singleton

Resolving Services

make() — Resolve with Parameters

// Auto-wired resolution
$service = $container->make(UserRepository::class);

// With explicit constructor parameters
$service = $container->make(UserRepository::class, [
    'tableName' => 'users',
]);

Resolution order in make():

1. Resolve alias chain (up to 10 levels)
2. Return cached singleton/scoped instance (if exists)
3. Delegate to parent container (if no local binding and parent has it)
4. Circular dependency check (throws ContainerException)
5. Fire beforeResolving hooks
6. Build instance (factory closure or auto-wire)
7. Apply extenders (decorator chain)
8. Cache instance (if shared: singleton/scoped)
9. Fire resolving hooks
10. Fire afterResolving hooks
11. Return instance

get() — PSR-11 Resolve

$service = $container->get(UserRepository::class);
// Throws ContainerNotFoundException if not bound

has() — PSR-11 Check

if ($container->has(UserRepository::class)) {
    $repo = $container->get(UserRepository::class);
}

bound() — Local Check Only

// Checks local bindings/instances only (no parent, no auto-wire)
if ($container->bound(MyService::class)) {
    // ...
}

Auto-Wiring

The Container automatically resolves constructor dependencies using PHP reflection. For each constructor parameter, the resolution order is:

1. Explicit $params (by name)
2. Contextual binding (when/needs/give)
3. Global make() resolution (type-hint)
4. Default value
5. Nullable → null
6. Throw ContainerException
class UserService {
    public function __construct(
        private UserRepository $repo,    // Auto-resolved via make()
        private CacheService $cache,     // Auto-resolved via make()
        private string $prefix = 'usr'   // Uses default value
    ) {}
}

// Container resolves UserRepository and CacheService automatically
$service = $container->make(UserService::class);

⚠️ Warning: Circular dependencies are detected and throw a ContainerException.


Contextual Binding

Contextual bindings let you provide different implementations of the same interface depending on which class is consuming it:

use Razy\ContextualBindingBuilder;

// When UserService needs LoggerInterface, give FileLogger
$container->when(UserService::class)
    ->needs(LoggerInterface::class)
    ->give(FileLogger::class);

// When PaymentService needs LoggerInterface, give DatabaseLogger
$container->when(PaymentService::class)
    ->needs(LoggerInterface::class)
    ->give(DatabaseLogger::class);

// With factory closure
$container->when(ReportService::class)
    ->needs(LoggerInterface::class)
    ->give(function (Container $c) {
        return new FileLogger('/var/log/reports.log');
    });

How it works: During auto-wiring, the Container checks contextualBindings[consumer][abstract] before falling back to the global binding. This is resolved per-parameter during constructor injection.

// Direct registration (equivalent to fluent API)
$container->addContextualBinding(
    UserService::class,        // consumer
    LoggerInterface::class,    // abstract needed
    FileLogger::class          // concrete to give
);

// Check if a contextual binding exists
$binding = $container->getContextualBinding(
    UserService::class,
    LoggerInterface::class
); // returns FileLogger::class or null

Tagged Bindings

Group related services under a tag name and resolve them all at once:

// Register services
$container->singleton(FileLogger::class);
$container->singleton(DatabaseLogger::class);
$container->singleton(StderrLogger::class);

// Tag them
$container->tag([
    FileLogger::class,
    DatabaseLogger::class,
    StderrLogger::class,
], 'loggers');

// Resolve all tagged services
$loggers = $container->tagged('loggers');
// Returns [FileLogger, DatabaseLogger, StderrLogger] instances

// Use case: aggregate pattern
class LogAggregator {
    public function __construct(private array $loggers) {}

    public function logToAll(string $message): void {
        foreach ($this->loggers as $logger) {
            $logger->log($message);
        }
    }
}

$container->singleton(LogAggregator::class, function (Container $c) {
    return new LogAggregator($c->tagged('loggers'));
});

Extenders (Decorator Pattern)

Extenders wrap resolved instances with additional behavior. Multiple extenders are applied in registration order:

// Add logging decorator to any CacheService resolution
$container->extend(CacheService::class, function (object $cache, Container $c) {
    return new LoggingCacheDecorator($cache, $c->make(Logger::class));
});

// Add metrics on top
$container->extend(CacheService::class, function (object $cache, Container $c) {
    return new MetricsCacheDecorator($cache);
});

// Resolution: CacheService → LoggingCacheDecorator → MetricsCacheDecorator
$cache = $container->make(CacheService::class);

Note: If extend() is called on a singleton that's already cached, the extender is applied immediately and the cache is updated in-place. Otherwise, the extender is queued for the next resolution.


Resolving Hooks

Three lifecycle hooks fire during resolution. Each supports type-specific (by class name) and global (wildcard *) callbacks:

// Before resolving — fires before the instance is built
$container->beforeResolving(UserService::class, function (string $abstract, Container $c) {
    echo "About to resolve: {$abstract}\n";
});

// During resolving — fires after build + extenders, before afterResolving
$container->resolving(UserService::class, function (object $instance, Container $c) {
    // Inject runtime config, set up listeners, etc.
    $instance->setDebugMode(true);
});

// After resolving — fires last, good for audit or logging
$container->afterResolving(UserService::class, function (object $instance, Container $c) {
    $c->make(Logger::class)->info('Resolved: ' . get_class($instance));
});

// Global hooks (fire for ALL resolutions)
$container->beforeResolving(function (string $abstract, Container $c) {
    // Fires for every make() call
});

$container->resolving(function (object $instance, Container $c) {
    // Post-build hook for every instance
});

Execution order:

beforeResolving (type-specific) → beforeResolving (global *)
    → build() → extenders
        → resolving (type-specific) → resolving (global *)
            → afterResolving (type-specific) → afterResolving (global *)

Method Injection

The call() method invokes any callable with auto-resolved parameters:

// Closure
$result = $container->call(function (UserRepository $repo, CacheService $cache) {
    return $repo->findAll();
});

// Instance method
$result = $container->call([$controller, 'handleRequest']);

// Static method
$result = $container->call([MyService::class, 'staticMethod']);

// Invokable object
$result = $container->call($invokable);

// With explicit overrides
$result = $container->call(function (UserRepository $repo, string $role) {
    return $repo->findByRole($role);
}, ['role' => 'admin']);

Parameter resolution order per argument:

1. Explicit $params (by name)
2. Type-hint resolution via make()
3. Default value
4. Nullable → null
5. Throw ContainerException

Factory Closures

Get a reusable factory closure that produces instances on demand:

$factory = $container->factory(UserService::class);

// Each call creates (or returns cached) instance
$service1 = $factory();
$service2 = $factory();

// Transient binding: $service1 !== $service2
// Singleton binding: $service1 === $service2

Rebinding (Hot-Swap)

Atomically replace a binding at runtime and notify interested parties. Designed for worker mode hot-reload:

// Register a callback when a binding changes
$container->onRebind(CacheService::class, function (string $abstract, ?object $old, Container $c) {
    echo "CacheService was replaced!\n";
    // $old is the previous cached instance (if any)
});

// Replace the binding — fires onRebind callbacks, returns old cached instance
$oldInstance = $container->rebind(CacheService::class, function () {
    return new RedisCacheService();
});

// Track rebind statistics (for worker stability monitoring)
$count = $container->getRebindCount(CacheService::class); // per-abstract count
$total = $container->getTotalRebindCount();                // sum of all
$shouldRestart = $container->exceedsRebindThreshold();     // >= 50 by default

// Configure threshold (PHP class table bloat detection)
$container->setMaxRebindsBeforeRestart(100);

Note: Rebind counts intentionally survive reset() to track cumulative class table bloat across the worker process lifetime. When exceedsRebindThreshold() returns true, a graceful worker restart is recommended.


Parent/Child Container Hierarchy

Razy uses a hierarchical container architecture. Each module receives its own child container that delegates to the Application container (parent) for unresolved bindings:

┌─────────────────────────────────────────────┐
│            Application Container            │  ← Root: core services
│  (ContainerInterface, PluginManager, ...)   │
├──────────┬──────────┬───────────────────────┤
│ Module A │ Module B │ Module C              │  ← Child containers
│ Container│ Container│ Container             │
│          │          │                       │
│ Local    │ Local    │ Local bindings        │
│ bindings │ bindings │ (isolated per module) │
└──────────┴──────────┴───────────────────────┘

Resolution priority chain:

1. Instance (pre-built object)
2. Contextual Binding (when/needs/give for the consumer)
3. Local Binding (module's own container)
4. Parent Container (Application container)
5. Auto-wire (reflection-based construction)

Creating child containers:

// No createChild() method — instantiate directly with parent reference
$child = new Container($parentContainer);

// Child inherits parent bindings but can override locally
$child->singleton(MyService::class, CustomImpl::class);

// Resolution: child first → parent fallback
$service = $child->make(SomeService::class);
// If not bound in child, delegates to parent

Module integration (Module::initializeContainer()):

// Inside Module class (framework-internal)
private function initializeContainer(): void
{
    $parentContainer = $this->distributor->getContainer();
    $this->container = new Container($parentContainer);

    // Auto-bind ModuleInfo for this module
    $this->container->bind(ModuleInfo::class, fn() => $this->moduleInfo);

    // Load services from package.php 'services' key
    foreach ($this->package['services'] ?? [] as $abstract => $config) {
        // Register each service binding...
    }
}

Framework Integration

Application Level

The Application creates the root Container and registers core framework services:

$app = new Application();
$container = $app->getContainer();

// Pre-registered services:
$container->get(ContainerInterface::class);  // Container itself
$container->get(PluginManager::class);       // Singleton

Distributor Level

Each Distributor receives the Container and registers itself:

$distributor = $app->getDistributor();
$container = $distributor->getContainer();

Module Level — package.php Services

Define services in your module's package.php to auto-register them in the module's child container:

// package.php
return [
    'name' => 'vendor/my-module',
    'services' => [
        PaymentGateway::class => [
            'type' => 'singleton',
            'concrete' => StripeGateway::class,
        ],
        ReportGenerator::class => [
            'type' => 'transient',
        ],
    ],
];

Controller Level

Controllers access the Container via convenience methods:

class MyController extends Controller
{
    public function __onInit(Agent $agent): bool
    {
        // Resolve a service
        $service = $this->resolve(MyService::class);

        // Check availability
        if ($this->hasService(CacheService::class)) {
            $cache = $this->resolve(CacheService::class);
        }

        return true;
    }
}

Lifecycle Management

// Forget scoped instances between requests (worker mode)
$container->forgetScopedInstances();

// Forget a specific binding
$container->forget(MyService::class);

// Reset everything (bindings, instances, aliases, tags, hooks, extenders)
// Note: rebindCounts are preserved for class-table bloat detection
$container->reset();

// Inspect current bindings
$bindings = $container->getBindings(); // string[] of registered abstract names

Exception Handling

The Container uses PSR-11 compliant exceptions:

Exception When Interface
ContainerNotFoundException get() called for an unbound service NotFoundExceptionInterface
ContainerException Circular dependency, resolution failure, invalid callable ContainerExceptionInterface
use Razy\Exception\ContainerException;
use Razy\Exception\ContainerNotFoundException;

try {
    $service = $container->get('unknown.service');
} catch (ContainerNotFoundException $e) {
    // Service not found
} catch (ContainerException $e) {
    // Circular dependency or build failure
}

Complete API Reference

Registration

Method Description
bind(string $abstract, string|Closure $concrete) Transient binding
bindIf(string $abstract, string|Closure $concrete) Bind only if not already bound
singleton(string $abstract, string|Closure|null $concrete = null) Shared singleton
singletonIf(string $abstract, string|Closure|null $concrete = null) Singleton if not bound
scoped(string $abstract, string|Closure|null $concrete = null) Request-scoped singleton
scopedIf(string $abstract, string|Closure|null $concrete = null) Scoped if not bound
instance(string $abstract, object $instance) Pre-built object
alias(string $alias, string $abstract) Alias mapping (chain depth ≤ 10)

Resolution

Method Description
make(string $abstract, array $params = []): mixed Primary resolution with optional params
get(string $id): mixed PSR-11 resolve (throws ContainerNotFoundException)
has(string $id): bool PSR-11 check (includes parent)
bound(string $abstract): bool Local-only check
factory(string $abstract): Closure Returns fn() => $this->make($abstract)
call(callable|array $callback, array $params = []): mixed Method injection

Tags

Method Description
tag(array $abstracts, string $tag) Assign abstracts to a tag group
tagged(string $tag): array Resolve all tagged instances

Contextual Binding

Method Description
when(string $consumer): ContextualBindingBuilder Start fluent contextual binding
addContextualBinding(string $consumer, string $abstract, string|Closure $concrete) Direct registration
getContextualBinding(string $consumer, string $abstract): string|Closure|null Lookup

Extenders & Hooks

Method Description
extend(string $abstract, Closure $extender) Decorator: fn(object, Container): object
beforeResolving(string|Closure $abstract, ?Closure $callback = null) Pre-build hook
resolving(string|Closure $abstract, ?Closure $callback = null) Post-build hook
afterResolving(string|Closure $abstract, ?Closure $callback = null) Final hook

Rebinding

Method Description
rebind(string $abstract, string|Closure $concrete): ?object Atomic replace, returns old instance
onRebind(string $abstract, Closure $callback) fn(string, ?object, Container): void
getRebindCount(string $abstract): int Per-abstract rebind count
getTotalRebindCount(): int Sum of all rebind counts
exceedsRebindThreshold(): bool true if total ≥ max (default 50)
setMaxRebindsBeforeRestart(int $max) Configure threshold (min 1)

Cleanup

Method Description
forgetScopedInstances() Clear scoped caches (worker mode)
forget(string $abstract) Remove specific binding
reset() Clear everything except rebind counts
getBindings(): array List all bound abstract names
getParent(): ?ContainerInterface Get parent container

Common Mistakes

Problem Cause Fix
Circular dependency A requires B and B requires A Introduce an interface or lazy factory ($c->factory(...))
Stale data in worker mode Singleton persists across requests Use scoped() or call forgetScopedInstances() each cycle
Binding works locally but fails elsewhere Bound in child container, resolved in parent Bind in the root container or pass the correct scope
Interface not resolved Bound interface has no concrete class Call $container->singleton(Interface::class, Concrete::class)
Rebind threshold exceeded Too many re-bindings trigger restart guard Raise setMaxRebindsBeforeRestart() or fix redundant bindings

Decision Guide — Lifecycle Types

Lifecycle Registration When Resolved Best For
Transient bind() New instance every call Stateless services, value objects
Singleton singleton() Same instance forever Config, logger, DB connections
Scoped scoped() Same instance per request cycle; reset via forgetScopedInstances() Per-request auth, tenant context
Instance instance() Pre-built object returned as-is Test doubles, existing objects

Agent Routing

Clone this wiki locally