Skip to content

Services and DI

Precious Lyson edited this page Nov 11, 2026 · 1 revision

A comprehensive guide to the service container, provider lifecycle, and dependency injection architecture of the Machinjiri Core framework.


Table of Contents

Part I — Beginner's Guide

  1. What Is Dependency Injection?
  2. The Three Pillars
  3. Your First Service Provider
  4. Resolving Services
  5. Configuration Basics

Part II — Intermediate 6. Binding Types Compared 7. Auto-Wiring Deep Dive 8. Aliases & the Registry 9. The Provider Lifecycle 10. Deferred Providers 11. Boot Priority & Ordering 12. Environment & Configuration Loading

Part III — Advanced 13. Container Freezing & Immutability 14. Scoped Bindings for Long-Running Workers 15. Provider Manifest Caching 16. Error Codes Reference 17. Performance Considerations 18. Testing Strategies 19. Common Pitfalls & Anti-Patterns 20. Architecture Diagrams


Part I — Beginner's Guide

1. What Is Dependency Injection?

Dependency Injection (DI) is a design pattern where an object receives its dependencies from an external source instead of creating them itself.

Without DI — tightly coupled, hard to test:

class UserController
{
    public function __construct()
    {
        $this->db = new DatabaseConnection('mysql:host=localhost'); // hard-coded
        $this->logger = new Logger('/var/log/app.log');
    }
}

With DI — dependencies handed in:

class UserController
{
    public function __construct(
        private DatabaseConnection $db,
        private Logger $logger,
    ) {}
}

The service container is the machinery that figures out what to hand in. This framework's container does that automatically through reflection-based auto-wiring — you never have to manually call new UserController(...).


2. The Three Pillars

This system is built from three cooperating classes:

┌──────────────────────────────────────────────────────────────┐
│                         Container                            │
│  (facade — paths, config, providers, request, storage)       │
│                                                              │
│   ┌──────────────────────────┐   ┌──────────────────────┐    │
│   │    ServiceContainer      │   │   ProviderLoader     │    │
│   │  (DI engine — bindings,  │◄──┤  (manifest, cache,   │    │
│   │   aliases, instances,    │   │   deferred, boot)    │    │
│   │   reflection resolution) │   │                      │    │
│   └──────────────────────────┘   └──────────────────────┘    │
└──────────────────────────────────────────────────────────────┘
Class Responsibility
ServiceContainer Pure DI: stores bindings, aliases, instances; resolves classes via reflection. No knowledge of files or config.
Container Facade/orchestrator: owns path registry, configuration loader, HTTP request, logger, and the provider loader. Delegates DI to ServiceContainer.
ProviderLoader Discovers providers from manifest, runs register()/boot(), caches results, and loads deferred providers on demand.

And the abstract ServiceProvider base class is what you extend to declare services.


3. Your First Service Provider

Create app/Providers/ReportServiceProvider.php:

<?php

namespace App\Providers;

use Mlangeni\Machinjiri\Core\Providers\ServiceProvider;
use App\Reports\ReportGenerator;
use App\Reports\PdfRenderer;

class ReportServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Stateless — new instance on every resolve
        $this->bind(PdfRenderer::class);

        // Shared — same instance for the whole request
        $this->singleton(ReportGenerator::class, function ($app) {
            return new ReportGenerator(
                $app->resolve(PdfRenderer::class),
                $app->config('app.app_name'),
            );
        });
    }

    public function boot(): void
    {
        // Runs after ALL providers have registered.
        // Use this for wiring that depends on other services.
    }
}

Register it in config/services/providers.php:

<?php

return [
    'providers' => [
        \App\Providers\ReportServiceProvider::class,
    ],
    'deferred' => [],
];

Resolve it anywhere:

$generator = app(ReportGenerator::class);
// or
$generator = $container->make(ReportGenerator::class);

Note: The closure passed to singleton() / bind() receives the Container instance as its first argument ($app), exactly like Laravel.


4. Resolving Services

There are several equivalent ways to ask the container for something:

// 1. make() — the primary method
$db = $container->make('db.kernel.connection');

// 2. get() — PSR-11 style
$db = $container->get('db.kernel.connection');

// 3. resolve() — auto-wires constructor params
$worker = $container->resolve(\App\Jobs\Worker::class);

// 4. __get — magic property (only for bound services)
$db = $container->db;

// 5. Global helper
$db = app('db.kernel.connection');

How resolution works, step by step

Given make(Foo::class) where Foo has constructor __construct(Bar $bar, string $name = 'x'):

  1. Check alias table for Foo → redirect if found.
  2. Check bindings for Foo:
    • If binding is shared and an instance is cached → return it.
    • Else build the concrete (closure or class).
  3. If not bound but class_exists(Foo::class) → auto-wire:
    • Reflect on the constructor.
    • For each parameter: resolve by type-hint → else use default value → else use null if nullable → else throw BindingResolutionException (code 116).

5. Configuration Basics

Configuration flows through ConfigurationLoader:

$configs = $container->getConfigurations();
// returns ['app' => [...], 'database' => [...]]

Access with dot-notation:

$name = $container->config('app.app_name');
$env  = $container->config('app.app_env', 'production'); // default

Configuration is loaded from config/core/app.php and config/core/database.php, validated, and memoised in $configurationCache. Providers can merge extra files into the runtime registry:

public function boot(): void
{
    $this->mergeConfigFrom(
        $this->app->coreConfig . 'queue.php',
        'queue'
    );
}

Merge semantics: array_replace_recursive($fileValues, $existingRuntimeValues) — the runtime registry always wins. This makes merges idempotent and means values injected by tests survive provider boot.

Environment variables are loaded once per base path and cached statically (ConfigurationLoader::$envCache), exposed via env() and ConfigurationLoader::loadEnv().


Part II — Intermediate

6. Binding Types Compared

Method Signature Lifetime Use when
bind() bind($abstract, $concrete, $shared = false) New instance per resolve Stateless services
singleton() singleton($abstract, $concrete = null) One instance per container Shared state, connections, managers
scoped() scoped($abstract, $concrete = null) One instance per request/worker cycle Long-running runtimes
instance() instance($abstract, $instance) Pre-built instance Bootstrapping, testing
alias() alias($abstract, $alias) Redirects a name Short names, interface aliases

The concrete argument can be:

  • A class string: $this->singleton(Foo::class, Bar::class);
  • A closure: $this->singleton(Foo::class, fn($app) => new Foo($app->config(...)));
  • A pre-built object: $this->instance(Foo::class, $foo);
  • null — defaults to the abstract itself (auto-wire).

bindMany / singletonMany / aliasMany

Bulk helpers accept either [Abstract::class => Concrete::class] or a list of class strings:

$this->singletonMany([
    CacheManager::class,          // list style → CacheManager::class → itself
    'cache.store' => RedisStore::class,
]);

$this->aliasMany([
    'cache' => CacheManager::class,
]);

7. Auto-Wiring Deep Dive

ServiceContainer::buildClass() uses reflection only when no parameters are passed to make(). This preserves legacy behavior:

// Explicit parameters → legacy `new $class(...$parameters)`
$container->make(Report::class, ['January', 2026]);
// → new Report('January', 2026)

// No parameters → auto-wire
$container->make(Report::class);
// → reflects on Report::__construct and resolves each param

Parameter resolution order

For each constructor parameter resolveParameter() tries, in order:

  1. Type-hinted class (ReflectionNamedType and not built-in) → $this->resolve($type->getName())
  2. Default value → $parameter->getDefaultValue()
  3. Nullable → null
  4. Otherwise → throws BindingResolutionException (code 116) with the parameter name and owner class.

⚠️ Limitation: Only named, non-builtin types are auto-resolved. int, string, array, etc. fall back to defaults/nullability — there's no scalar injection from config. Bind closures for that.

Interfaces require explicit binding

// This will fail — interfaces can't be instantiated
$container->make(CacheInterface::class);

// Do this instead
$container->singleton(CacheInterface::class, RedisCache::class);

8. Aliases & the Registry

Aliases are a flat map alias => abstract. Resolution happens first, before bindings are consulted:

$this->alias(CacheManager::class, 'cache');
$this->make('cache');        // → resolves CacheManager

Aliases are cleaned up automatically by unbind() — any alias pointing to the removed abstract is dropped.

Aliases can chain through one level only (no recursive alias resolution):

$this->alias(Foo::class, 'bar');
$this->alias('bar', 'baz');  // ⚠️ 'baz' → 'bar' → Foo — only two lookups

Use them for:

  • Short service names ('cache', 'logger', 'db')
  • Interface-to-implementation names

9. The Provider Lifecycle

 Container::__construct()
        │
        ├── initialize()       → validate paths, set $coreConfig
        ├── ConfigurationLoader → ready for config('…')
        ├── dbConnect()        → DatabaseConnection::setConfig()
        │
        └── loadServiceContainer()
                │
                └── new ProviderLoader($this)
                        │
                        ├── register()
                        │     ├── loadManifest()      (cache or build)
                        │     ├── eager providers → new + register()
                        │     └── deferred classes → seed deferred map
                        │
                        └── boot()
                              ├── sort eager by bootPriority()
                              └── foreach → bootProvider()  (once each)

register() vs boot()

Aspect register() boot()
When Immediately on discovery After all providers registered
Purpose Bind services Wire cross-service behavior (events, config, routes)
Safety Should not resolve other services May resolve anything
Ordering Manifest order Sorted by bootPriority()

Rule of thumb: Only bind in register(). Only resolve in boot().

Lifecycle guarantee

  • register() fires exactly once per provider class (tracked in $registered).
  • boot() fires exactly once per provider (tracked in $booted).
  • Both wrapped in try/catch → re-thrown as MachinjiriException (code 30112).

10. Deferred Providers

Deferred providers are not instantiated at boot. Their services are only registered when someone resolves one of them. This is a huge performance win for rarely-used services (notifications, performance profiler, etc.).

Two ways to declare deferral

A) Static — best for manifest caching and opcache preloading

use Mlangeni\Machinjiri\Core\Providers\Contracts\DeferredProviderInterface;

class NotificationServiceProvider extends ServiceProvider implements DeferredProviderInterface
{
    protected bool $defer = true;

    public static function providesStatic(): array
    {
        return [
            NotificationManager::class,
            ChannelManager::class,
            MailChannel::class,
            SmsChannel::class,
            WebhookChannel::class,
            DatabaseChannel::class,
        ];
    }

    public static function when(): array
    {
        return ['notification.manager', 'notification.channel.manager'];
    }
}

B) Instance-based — fallback when static declaration isn't possible

class MyProvider extends ServiceProvider
{
    protected bool $defer = true;

    public function provides(): array { return [MyService::class]; }
    public function when(): array     { return []; }
}

How deferred loading is triggered

Inside Container::make():

if ($this->providerLoader !== null
    && !$this->serviceContainer->bound($abstract)
    && !isset($this->instances[$abstract])
    && !isset($this->aliases[$abstract])
) {
    $this->providerLoader->loadDeferredProvider($abstract);
}

loadDeferredProvider() then:

  1. Looks up $deferred[$service] → provider class.
  2. Instantiates + register() the provider (once).
  3. Calls boot() (once).

The first resolve of a deferred service is slightly slower; subsequent resolves hit normal cached bindings.


11. Boot Priority & Ordering

The default bootPriority() returns 0. Override it for dependencies:

class LoggerServiceProvider extends ServiceProvider
{
    public function bootPriority(): int { return 10; }
}

class MailingServiceProvider extends ServiceProvider
{
    // Needs Logger booted first
    public function bootPriority(): int { return 20; }
}

The loader sorts via uasort with <=> — stable for equal priorities (declaration order preserved).

Deferred providers' boot() runs on first resolve, so their priority matters only relative to other deferred providers triggered in the same cycle.


12. Environment & Configuration Loading

Environment caching

ConfigurationLoader::loadEnv($container);        // parses .env once per base path
ConfigurationLoader::loadEnvArray($container);   // same, but never null
ConfigurationLoader::clearEnvCache();            // for testing / watcher restarts

Cache key = $path ?? Container::$appBasePath ?? '__default__'. Pass an explicit path to test alternate environment files:

$testEnv = ConfigurationLoader::loadEnv(null, '/path/to/.env.testing');

resolveDebugMode() — chicken-and-egg solver

The debug flag is needed by Container::__construct() before the ConfigurationLoader exists. It uses the static loadEnvArray(null, $path) form to parse .env directly:

public static function resolveDebugMode(string $path): bool
{
    $variables = ConfigurationLoader::loadEnvArray(null, $path);
    $debug = $variables['APP_DEBUG'] ?? true;
    return filter_var($debug, FILTER_VALIDATE_BOOLEAN);
}

Config file validation

ConfigurationLoader::validate() requires either a readable app.php or a non-empty .env; same for database.php. This lets you run with config files only, .env only, or both.


Part III — Advanced

13. Container Freezing & Immutability

After boot, call freeze() from your front controller:

$loader->register();
$loader->boot();
$container->freeze();

Any subsequent bind(), singleton(), scoped(), instance(), or alias() throws MachinjiriException (code 30120).

The provider escape hatch

Deferred providers legitimately need to bind at resolve time. assertNotFrozen() walks 6 stack frames and allows bindings originating from any class under Mlangeni\Machinjiri\Core\Providers\…:

if ($this->providerLoader !== null) {
    $current = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 6);
    foreach ($current as $frame) {
        $class = $frame['class'] ?? '';
        if (str_starts_with($class, __NAMESPACE__ . '\\Providers')) {
            return; // allow
        }
    }
}

⚠️ Gotcha: If your deferred provider's register() calls a helper in App\, the stack still shows the provider class in an earlier frame — so it works. But if you wrap the binding call in a closure passed to array_map invoked from outside the provider, the frame may not be visible within 6 levels. Increase the limit or keep binding calls shallow.


14. Scoped Bindings for Long-Running Workers

Under RoadRunner, Swoole, FrankenPHP, or Octane-style runtimes, singleton instances leak between requests. Use scoped():

$this->scoped(RequestContext::class, fn() => new RequestContext());

After each request, Container::forgetScopedInstances() clears them:

public function forgetScopedInstances(): void
{
    foreach (array_keys($this->scopedBindings) as $abstract) {
        unset($this->instances[$abstract]);
    }

    if (method_exists($this->serviceContainer, 'forgetScopedInstances')) {
        $this->serviceContainer->forgetScopedInstances();
    }
}

Worker loop pattern

while ($request = $worker->receive()) {
    try {
        $response = $app->make(Kernel::class)->handle($request);
    } finally {
        $app->forgetScopedInstances();   // ← per-request cleanup
    }
}

Note: The fallback path uses singleton() when ServiceContainer::scoped() is unavailable. Upgrade the underlying container for true scoping semantics.


15. Provider Manifest Caching

ProviderLoader writes a flat PHP array to storage/cache/providers.php:

<?php
// Auto-generated by ProviderLoader. Do not edit.
return array(
  'providers' => array( /* eager classes */ ),
  'deferred'  => array( /* deferred classes */ ),
);
  • Read: is_file() check → require → return.
  • Write: best-effort var_export + file_put_contents(…, LOCK_EX); failures are silently swallowed so a read-only filesystem never breaks the app.
  • Invalidation: delete the file, or call flush() (which also clears the env cache).

The cache is not invalidated automatically when config/services/providers.php changes — CI/deploy scripts should rm storage/cache/providers.php after provider list changes.


16. Error Codes Reference

Container & configuration (100–119)

Code Meaning
100 Container::getInstance() called before construction
101 Invalid application base path
102 App config unreadable and no env vars
103 Database config unreadable and no env vars
104 database.php did not return an array
105 No default DB driver
106 No DB connections defined
107 Default driver not found in connections
108 Default driver config empty
109 routes/web.php missing
110 Unable to resolve service (unbound, non-class)
111 __get on unknown property
112 __call on unknown method
113 Database configuration missing in dbConnect()
114 Target class does not exist
115 Target class not instantiable
116 Unresolvable constructor dependency
117+ reserved

Providers (301xx)

Code Meaning
30104 mergeConfigFrom file not found (when $throw true)
30105 Config file did not return an array
30106 providers.php did not return an array
30107 loadRoutesFrom file missing
30110 Provider class not found
30111 Provider instantiation failed
30112 Provider register()/boot() threw
30113 Provider does not extend ServiceProvider
30120 Binding attempted on frozen container

17. Performance Considerations

Where time goes

Operation Cost Mitigation
.env parsing O(file size) Memoised per base path
Config loading O(configs) Memoised in $configurationCache
Reflection O(constructor params) Unavoidable; use singletons for hot services
Provider boot() O(providers) Defer rarely-used ones
Deferred first-resolve Instantiation + reflection Amortised after first hit

Practical tips

  1. Prefer singleton() for anything with I/O — DB connections, file handles, HTTP clients.
  2. Defer notification/performance providers — already done in getCoreProviders().
  3. Bind closures only when you need runtime config — otherwise let reflection do the work.
  4. Avoid resolving services in tight loops — pull once into a local variable.
  5. Warm the manifest in your build pipeline so production never runs buildManifest().
  6. Freeze the container after boot to catch accidental rebinding early in CI.
  7. Use scoped() for anything holding per-request state under long-running workers.

Lazy resolution pattern

$this->singleton(ExpensiveClient::class, function ($app) {
    return new ExpensiveClient(/* connect lazily inside methods */);
});

18. Testing Strategies

Reset between tests

protected function setUp(): void
{
    parent::setUp();

    $this->app = new Container(__DIR__ . '/../', true);
    $this->app->freeze(); // optional — catch accidental bind()
}

protected function tearDown(): void
{
    $this->app->flush();                    // clears bindings + config + env cache
    Container::forgetInstance();
    ConfigurationLoader::clearEnvCache();
}

Swap in fakes

$app->instance(MailManager::class, new FakeMailManager());
$app->instance('clock', new FrozenClock('2026-01-01'));

Because instance() overwrites any existing binding, this is the cleanest seam for testing.

Test with alternate .env

$vars = ConfigurationLoader::loadEnv(null, __DIR__ . '/fixtures/.env.testing');
$this->assertSame('testing', $vars['APP_ENV']);

The path-keyed cache means your test env never poisons the app's env cache.

Assert deferred loading

$loader = $app->getProviderLoader();
$this->assertArrayHasKey(NotificationManager::class, $loader->getDeferredServices());
$app->make(NotificationManager::class);
$this->assertTrue($loader->isBooted(NotificationServiceProvider::class));

19. Common Pitfalls & Anti-Patterns

❌ Resolving in register()

public function register(): void
{
    // BAD — other providers may not have registered yet
    $logger = $this->app->resolve(Logger::class);
    $this->singleton(MyService::class, fn() => new MyService($logger));
}

Fix: store a closure; resolve lazily.

$this->singleton(MyService::class, fn($app) => new MyService($app->resolve(Logger::class)));

❌ Binding an interface without a concrete

$this->singleton(CacheInterface::class); // 💥 code 115

Fix: $this->singleton(CacheInterface::class, RedisCache::class);

❌ Relying on bind() for shared state

$this->bind(DatabaseConnection::class); // new connection each resolve

Fix: singleton().

❌ Circular dependencies

class A { public function __construct(B $b) {} }
class B { public function __construct(A $a) {} } // 💥 code 116

Fix: break the cycle with setter injection or an interface + factory.

❌ Mutating after freeze

$app->freeze();
$app->singleton(Foo::class); // 💥 code 30120

Fix: bind before freezing, or bind inside a provider's deferred register().

❌ Config lookup with the wrong key case

config('app.App_Name') ≠ config('app.app_name'). Dot-notation is case-sensitive.

❌ Assuming flush() resets the singleton instance

flush() clears bindings and caches but does not reset Container::$instance. Call Container::forgetInstance() explicitly in tests.

❌ Building the manifest on every request in production

Write it once in CI, ship the file. Otherwise buildManifest() runs on the first request after deploy.

❌ Using env() outside the bootstrap

env() reads from ConfigurationLoader; it's fine at boot but slow in hot code paths. Cache the value in a singleton or use config() after merge.


20. Architecture Diagrams

Resolution flow

make(Abstract, params)
        │
        ▼
 ┌──────────────────┐
 │ Alias lookup     │──→ abstract = aliases[abstract]
 └──────────────────┘
        │
        ▼
 ┌──────────────────┐         ┌─────────────────────────┐
 │ Binding exists?  │─ yes ──▶│ shared & cached? → ret  │
 └──────────────────┘         └─────────────────────────┘
        │                              │
        │ no                           ▼
        ▼                     ┌─────────────────┐
 ┌──────────────────┐         │ buildConcrete() │
 │ Deferred match?  │         └─────────────────┘
 └──────────────────┘                  │
        │ yes                          ▼
        ▼                     ┌─────────────────┐
 ┌──────────────────┐         │ closure → call  │
 │ loadDeferred()   │         │ class   → new   │
 └──────────────────┘         └─────────────────┘
        │                             │
        └──────────────┬──────────────┘
                       ▼
              ┌──────────────────┐
              │ class_exists?    │── yes ──▶ buildClass() (auto-wire)
              └──────────────────┘
                       │ no
                       ▼
                 throw 110

Provider state machine

        ┌────────────┐
        │ discovered │
        └──────┬─────┘
               │
      ┌────────┴────────┐
      │                 │
      ▼                 ▼
┌───────────┐    ┌──────────────┐
│  deferred │    │    eager     │
└─────┬─────┘    └──────┬───────┘
      │                 │
      │ first resolve   │ boot()
      ▼                 ▼
┌───────────┐    ┌──────────────┐
│ register()│    │  registered  │
└─────┬─────┘    └──────┬───────┘
      │                 │
      ▼                 ▼
┌───────────┐    ┌──────────────┐
│  boot()   │    │    booted    │◄── once
└─────┬─────┘    └──────────────┘
      │
      ▼
┌───────────┐
│  booted   │
└───────────┘

Path & config ownership

Container
├── $appBasePath : static
├── $bootstrap, $routes, $resources, $database, $storage,
│   $routing, $app, $config, $coreConfig, $unitTesting,
│   $seeders, $factories
├── $paths : array (mirror, updated by setPath)
├── ConfigurationLoader
│   ├── $configDir → config/core/
│   ├── $envCache (static, keyed by base path)
│   └── load() → ['app' => …, 'database' => …]
├── $configurationCache : ?array
├── $configurations : array  ← providers merge into this
├── ProviderLoader
│   ├── $providers, $registered, $booted
│   ├── $deferred : [service => providerClass]
│   └── cacheFile: storage/cache/providers.php
└── ServiceContainer
    ├── $bindings, $aliases, $instances  (bound by reference)
    └── setFacade($container)

Quick Reference Card

// ── Binding ─────────────────────────────────────────────
$app->bind(Abstract::class, Concrete::class);
$app->singleton(Abstract::class, fn($app) => new Concrete(...));
$app->scoped(Abstract::class, Concrete::class);
$app->instance(Abstract::class, $object);
$app->alias(Abstract::class, 'short-name');

// ── Resolution ──────────────────────────────────────────
$app->make(Abstract::class);
$app->make(Abstract::class, ['param1', 'param2']);
$app->resolve(Class::class);       // auto-wire
$app->get('service.id');           // PSR-11

// ── Introspection ───────────────────────────────────────
$app->bound(Abstract::class);
$app->hasInstance(Abstract::class);
$app->getBindings();

// ── Config ──────────────────────────────────────────────
$app->config('app.app_name');
$app->config('app.app_env', 'production');
$app->mergeConfigFrom($path, 'key');
ConfigurationLoader::loadEnv($app);

// ── Providers ───────────────────────────────────────────
$loader = $app->getProviderLoader();
$loader->registerProvider(MyProvider::class);
$loader->getRegisteredProviders();
$loader->isBooted(MyProvider::class);

// ── Lifecycle ───────────────────────────────────────────
$app->freeze();
$app->forgetScopedInstances();
$app->flush();
Container::forgetInstance();
ConfigurationLoader::clearEnvCache();

Further Reading

  • DeferredProviderInterface — static service declaration for cache-friendly deferral.
  • Container::assertNotFrozen() — the provider-only rebinding escape hatch.
  • Container::__get / __set — magic property routing ($app->db, $app->config.foo = …, $app->path.app = …).
  • ServiceProvider::normaliseListeners() — four accepted shapes for listenMany().
  • getCoreProviders() — canonical example of eager vs deferred provider choice.

Clone this wiki locally