Repository navigation
Services and DI
A comprehensive guide to the service container, provider lifecycle, and dependency injection architecture of the Machinjiri Core framework.
Part I — Beginner's Guide
- What Is Dependency Injection?
- The Three Pillars
- Your First Service Provider
- Resolving Services
- 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
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(...).
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.
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 theContainerinstance as its first argument ($app), exactly like Laravel.
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');Given make(Foo::class) where Foo has constructor __construct(Bar $bar, string $name = 'x'):
- Check alias table for
Foo→ redirect if found. - Check bindings for
Foo:- If binding is
sharedand an instance is cached → return it. - Else build the concrete (closure or class).
- If binding is
- 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
nullif nullable → else throwBindingResolutionException(code 116).
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'); // defaultConfiguration 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().
| 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).
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,
]);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 paramFor each constructor parameter resolveParameter() tries, in order:
-
Type-hinted class (
ReflectionNamedTypeand not built-in) →$this->resolve($type->getName()) -
Default value →
$parameter->getDefaultValue() -
Nullable →
null - 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.
// This will fail — interfaces can't be instantiated
$container->make(CacheInterface::class);
// Do this instead
$container->singleton(CacheInterface::class, RedisCache::class);Aliases are a flat map alias => abstract. Resolution happens first, before bindings are consulted:
$this->alias(CacheManager::class, 'cache');
$this->make('cache'); // → resolves CacheManagerAliases 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 lookupsUse them for:
- Short service names (
'cache','logger','db') - Interface-to-implementation names
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)
| 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 inboot().
-
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).
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.).
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 []; }
}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:
- Looks up
$deferred[$service]→ provider class. - Instantiates +
register()the provider (once). - Calls
boot()(once).
The first resolve of a deferred service is slightly slower; subsequent resolves hit normal cached bindings.
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.
ConfigurationLoader::loadEnv($container); // parses .env once per base path
ConfigurationLoader::loadEnvArray($container); // same, but never null
ConfigurationLoader::clearEnvCache(); // for testing / watcher restartsCache key = $path ?? Container::$appBasePath ?? '__default__'. Pass an explicit path to test alternate environment files:
$testEnv = ConfigurationLoader::loadEnv(null, '/path/to/.env.testing');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);
}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.
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).
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'sregister()calls a helper inApp\, 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 toarray_mapinvoked from outside the provider, the frame may not be visible within 6 levels. Increase the limit or keep binding calls shallow.
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();
}
}while ($request = $worker->receive()) {
try {
$response = $app->make(Kernel::class)->handle($request);
} finally {
$app->forgetScopedInstances(); // ← per-request cleanup
}
}Note: The fallback path uses
singleton()whenServiceContainer::scoped()is unavailable. Upgrade the underlying container for true scoping semantics.
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.
| 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 |
| 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 |
| 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 |
-
Prefer
singleton()for anything with I/O — DB connections, file handles, HTTP clients. -
Defer notification/performance providers — already done in
getCoreProviders(). - Bind closures only when you need runtime config — otherwise let reflection do the work.
- Avoid resolving services in tight loops — pull once into a local variable.
-
Warm the manifest in your build pipeline so production never runs
buildManifest(). - Freeze the container after boot to catch accidental rebinding early in CI.
-
Use
scoped()for anything holding per-request state under long-running workers.
$this->singleton(ExpensiveClient::class, function ($app) {
return new ExpensiveClient(/* connect lazily inside methods */);
});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();
}$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.
$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.
$loader = $app->getProviderLoader();
$this->assertArrayHasKey(NotificationManager::class, $loader->getDeferredServices());
$app->make(NotificationManager::class);
$this->assertTrue($loader->isBooted(NotificationServiceProvider::class));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)));$this->singleton(CacheInterface::class); // 💥 code 115Fix: $this->singleton(CacheInterface::class, RedisCache::class);
$this->bind(DatabaseConnection::class); // new connection each resolveFix: singleton().
class A { public function __construct(B $b) {} }
class B { public function __construct(A $a) {} } // 💥 code 116Fix: break the cycle with setter injection or an interface + factory.
$app->freeze();
$app->singleton(Foo::class); // 💥 code 30120Fix: bind before freezing, or bind inside a provider's deferred register().
config('app.App_Name') ≠ config('app.app_name'). Dot-notation is case-sensitive.
flush() clears bindings and caches but does not reset Container::$instance. Call Container::forgetInstance() explicitly in tests.
Write it once in CI, ship the file. Otherwise buildManifest() runs on the first request after deploy.
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.
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
┌────────────┐
│ discovered │
└──────┬─────┘
│
┌────────┴────────┐
│ │
▼ ▼
┌───────────┐ ┌──────────────┐
│ deferred │ │ eager │
└─────┬─────┘ └──────┬───────┘
│ │
│ first resolve │ boot()
▼ ▼
┌───────────┐ ┌──────────────┐
│ register()│ │ registered │
└─────┬─────┘ └──────┬───────┘
│ │
▼ ▼
┌───────────┐ ┌──────────────┐
│ boot() │ │ booted │◄── once
└─────┬─────┘ └──────────────┘
│
▼
┌───────────┐
│ booted │
└───────────┘
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)
// ── 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();-
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 forlistenMany(). -
getCoreProviders()— canonical example of eager vs deferred provider choice.
Machinjiri Docs
- Home
- Learn
- Build
-
Tutorials
- Routing
- Controllers and Middleware
- Views and Templates
- Database ORM
- Eloquent Database ORM
- Errors and Exceptions
- Forms and Validation
- Http
- Events and Logging
- Services and DI
- Security
- Authentication
- Caching
- File System
- Date and Time
- Debugging
- Queue and Jobs
- Task Scheduling
- Transport (Mail, SMS)
- LDAP
- Notifications
- Webhooks
- UUID Generations
- Terminal
- Helper Function
- Reference