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 |

← Previous: Agent

Routing

Clone this wiki locally