Dependency Injection

Melodic's DI container handles object creation and wiring so your classes can declare what they need without knowing how to build it. The container supports auto-wiring, interface binding, singletons, and modular service providers.

The Container

The Melodic\DI\Container class is the central registry for all services in your application. It implements ContainerInterface and provides five core methods:

get(string $id): mixed

Resolve a class or binding from the container. The resolution order is:

  1. Check for a cached instance (previously resolved singletons or manually registered instances)
  2. Check for a registered binding (transient or singleton) and build it
  3. Attempt auto-wiring by reflecting the class constructor
$userService = $container->get(UserService::class);

// Interface resolution works the same way — if a binding exists
$service = $container->get(UserServiceInterface::class);

Throws a RuntimeException if the class cannot be resolved, does not exist, or is not instantiable.

has(string $id): bool

Check whether the container can resolve a given identifier. Returns true if any of the following are true:

  • A binding has been registered for the identifier
  • An instance has been stored for the identifier
  • The identifier is an existing class name (resolvable via auto-wiring)
if ($container->has(UserServiceInterface::class)) {
    $service = $container->get(UserServiceInterface::class);
}

bind(string $abstract, string|callable $concrete): void

Register a transient binding. Every call to get() will create a new instance. The concrete argument can be a class name string or a factory callable.

// Class name string — auto-wired each time
$container->bind(UserServiceInterface::class, UserService::class);

// Factory callable — invoked each time
$container->bind(ReportGenerator::class, function (Container $c) {
    return new ReportGenerator($c->get(DbContextInterface::class));
});

Note: Calling bind() clears any previously cached instance for that key, ensuring the new binding takes effect immediately.

singleton(string $abstract, string|callable $concrete): void

Register a singleton binding. The concrete is resolved once on first access, then the same instance is returned for all subsequent calls. Like bind(), the concrete can be a class name or factory callable.

// Resolved once, cached forever
$container->singleton(DbContextInterface::class, function () {
    $pdo = new PDO('sqlite::memory:');
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    return new DbContext($pdo);
});

Overwriting: Calling singleton() again for the same key overwrites the previous binding. However, if the previous singleton was already resolved and cached, the cached instance remains until the container is rebuilt. Use this behavior intentionally when overriding framework defaults.

instance(string $abstract, object $instance): void

Register a pre-built object directly. No factory or resolution is involved — the exact object you provide is returned by get().

$config = new Configuration();
$config->loadFile('config/config.json');

$container->instance(Configuration::class, $config);

The framework uses instance() internally to register core objects during bootstrap:

// Inside Application::__construct()
$this->container->instance(Configuration::class, $this->configuration);
$this->container->instance(Container::class, $this->container);
$this->container->instance(Router::class, $this->router);
$this->container->instance(Application::class, $this);

Auto-Wiring

When you call get() for a class with no explicit binding, the container uses PHP's Reflection API to automatically resolve constructor dependencies. This is the mechanism that makes most manual registration unnecessary.

How It Works

  1. The container reads the constructor parameters of the requested class using ReflectionClass
  2. For each parameter with a class or interface type hint, it recursively calls get() to resolve that type
  3. If resolution fails for a parameter that has a default value, the default is used instead
  4. Parameters without type hints and without defaults throw a RuntimeException
  5. The resolved dependencies are passed to the constructor and the object is returned
class UserController extends ApiController
{
    public function __construct(
        private readonly UserServiceInterface $userService,
        private readonly ViewEngine $viewEngine,
    ) {}
}

// The container resolves this automatically:
// 1. Looks up UserServiceInterface → finds binding → resolves UserService
// 2. Looks up ViewEngine → finds singleton → returns cached instance
// 3. Calls new UserController($userService, $viewEngine)
$controller = $container->get(UserController::class);

Circular Dependency Detection

The container tracks which classes are currently being resolved. If it encounters the same class again during resolution, it throws a RuntimeException with the full dependency chain:

// This will throw: "Circular dependency detected: A -> B -> A"
class A { public function __construct(B $b) {} }
class B { public function __construct(A $a) {} }

$container->get(A::class); // RuntimeException

When Auto-Wiring Is Not Enough

Auto-wiring cannot resolve:

  • Scalar parameters (string, int, bool) without default values
  • Interfaces or abstract classes without a registered binding
  • Classes that need specific configuration passed to the constructor

For these cases, use explicit bindings with factory callables.

Factory Callables

When a class name string is not enough to construct an object, pass a callable (closure) as the concrete argument. The factory receives the Container instance as its only parameter, giving you access to configuration and other services.

$container->singleton(DbContextInterface::class, function (Container $c) {
    $config = $c->get(Configuration::class);

    $pdo = new PDO(
        $config->get('database.dsn'),
        $config->get('database.username'),
        $config->get('database.password'),
    );
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    return new DbContext($pdo);
});

Factories are ideal for:

  • Objects that need configuration values (database connections, API clients)
  • Complex initialization sequences (creating a PDO, setting attributes, then wrapping it)
  • Conditional logic during construction
  • Pulling multiple dependencies together in a specific way
// Short arrow function syntax works well for simpler factories
$container->singleton(ViewEngine::class, fn(Container $c) =>
    new ViewEngine($c->get(Application::class)->getBasePath() . '/views')
);

Interface Binding

Interface binding is the most important pattern in the container. It decouples your code from concrete implementations, making it testable and swappable.

Basic Interface Binding

When you bind an interface to a concrete class, any constructor that type-hints the interface will receive an instance of the concrete class:

$container->bind(UserServiceInterface::class, UserService::class);

Now anywhere in your application that depends on UserServiceInterface:

class UserController extends ApiController
{
    public function __construct(
        private readonly UserServiceInterface $userService,
    ) {}
    // $userService will be an instance of UserService
}

Interface Binding with Factory

Combine interface binding with a factory when the concrete class needs special construction:

$container->singleton(DbContextInterface::class, fn() => new DbContext($pdo));

Why This Matters

  • Controllers never know the concrete class — they depend on interfaces only
  • Swapping implementations is a single line change at the registration point
  • Testing becomes easy: bind the interface to a mock or stub
  • Framework defaults can be overridden by re-binding the same interface to your own class

Service Providers

A ServiceProvider is a modular unit of registration. Instead of putting all your bindings in a single closure, you group related bindings into a provider class.

The Base Class

Extend Melodic\DI\ServiceProvider and implement the register() method:

<?php

use Melodic\DI\Container;
use Melodic\DI\ServiceProvider;

class DatabaseServiceProvider extends ServiceProvider
{
    public function register(Container $container): void
    {
        $container->singleton(DbContextInterface::class, function (Container $c) {
            $config = $c->get(Configuration::class);
            $pdo = new PDO($config->get('database.dsn'));
            return new DbContext($pdo);
        });
    }

    public function boot(Container $container): void
    {
        // Called after ALL providers have been registered.
        // Use this for logic that depends on other providers' bindings.
    }
}

register() vs boot()

MethodWhen It RunsUse For
register()Immediately when the provider is addedBinding services, singletons, interfaces
boot()After all providers are registered, just before run()Logic that depends on bindings from other providers

Framework Service Providers

The framework ships with several built-in service providers that register core infrastructure services. Register them in your application bootstrap before your own providers:

ProviderNamespaceWhat It Registers
LoggingServiceProvider Melodic\Log Registers LoggerInterface with file-based log output. Reads logging.path and logging.level from config.
EventServiceProvider Melodic\Event Registers the EventDispatcherInterface for decoupled event-driven communication between components.
CacheServiceProvider Melodic\Cache Registers the CacheInterface with file-based caching and TTL-based expiration.
SessionServiceProvider Melodic\Session Registers the SessionManagerInterface for session-based state management.
SecurityServiceProvider Melodic\Security Registers JWT validation, authentication middleware, OAuth/OIDC providers, session management, and the login page renderer.
use Melodic\Log\LoggingServiceProvider;
use Melodic\Event\EventServiceProvider;
use Melodic\Cache\CacheServiceProvider;
use Melodic\Session\SessionServiceProvider;
use Melodic\Security\SecurityServiceProvider;

$app->register(new LoggingServiceProvider());
$app->register(new EventServiceProvider());
$app->register(new CacheServiceProvider());
$app->register(new SessionServiceProvider());
$app->register(new SecurityServiceProvider());

Tip: Register LoggingServiceProvider first so that other providers (like SecurityServiceProvider) can resolve the logger during their own registration.

Real Example: SecurityServiceProvider

The framework includes Melodic\Security\SecurityServiceProvider, which registers all authentication and authorization services:

use Melodic\Security\SecurityServiceProvider;

$app->register(new SecurityServiceProvider());

This single call registers bindings for AuthConfig, SessionManager, AuthProviderRegistry, JwtValidator, AuthLoginRendererInterface, and all authentication middleware. You don't need to wire any of these yourself.

Writing Your Own Provider

<?php

declare(strict_types=1);

namespace App\Providers;

use Melodic\DI\Container;
use Melodic\DI\ServiceProvider;
use App\Services\NotificationService;
use App\Services\NotificationServiceInterface;
use App\Services\EmailSender;
use App\Services\EmailSenderInterface;

class NotificationServiceProvider extends ServiceProvider
{
    public function register(Container $container): void
    {
        $container->singleton(EmailSenderInterface::class, function (Container $c) {
            $config = $c->get(Configuration::class);
            return new EmailSender(
                host: $config->get('mail.host'),
                port: (int) $config->get('mail.port'),
                username: $config->get('mail.username'),
                password: $config->get('mail.password'),
            );
        });

        $container->bind(NotificationServiceInterface::class, NotificationService::class);
    }
}

Registration in Application

There are two ways to register services with the application, and the order matters.

Using Service Providers

$app->register(new SecurityServiceProvider());
$app->register(new NotificationServiceProvider());

Each provider's register() method runs immediately when $app->register() is called.

Using the Services Callback

$app->services(function (Container $container) {
    $container->bind(UserServiceInterface::class, UserService::class);
    $container->singleton(DbContextInterface::class, fn() => new DbContext($pdo));
});

Order of Execution

The intended pattern is: register providers first, then use the services() callback for application-level bindings. Because services() runs after providers, your app-level bindings can override any defaults set by a provider.

// 1. SecurityServiceProvider registers AuthLoginRendererInterface
//    bound to the framework's default AuthLoginRenderer
$app->register(new SecurityServiceProvider());

// 2. App-level binding overrides the framework default
$app->services(function (Container $container) {
    $container->singleton(
        AuthLoginRendererInterface::class,
        ExampleLoginRenderer::class,
    );
});

Tip: This override pattern is how the example app provides a custom login page. The SecurityServiceProvider registers a default AuthLoginRenderer, and then the app's services() callback replaces it with ExampleLoginRenderer.

Complete Examples

Typical Application Bootstrap

This example shows the full registration pattern used in a real Melodic application:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use Melodic\Core\Application;
use Melodic\Data\DbContext;
use Melodic\Data\DbContextInterface;
use Melodic\Security\AuthLoginRendererInterface;
use Melodic\Security\SecurityServiceProvider;
use Melodic\View\ViewEngine;
use App\Security\CustomLoginRenderer;
use App\Services\UserService;
use App\Services\UserServiceInterface;

$app = new Application(dirname(__DIR__));
$app->loadEnvironmentConfig();

// Register framework service providers
$app->register(new SecurityServiceProvider());

// Register application services
$app->services(function (Container $container) use ($app) {

    // Singleton with factory — database connection
    $container->singleton(DbContextInterface::class, function () use ($app) {
        $pdo = new PDO(
            $app->config('database.dsn'),
            $app->config('database.username'),
            $app->config('database.password'),
        );
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        return new DbContext($pdo);
    });

    // Singleton with factory — view engine
    $container->singleton(ViewEngine::class, fn() =>
        new ViewEngine($app->getBasePath() . '/views')
    );

    // Interface binding with auto-wiring — new instance each time
    $container->bind(UserServiceInterface::class, UserService::class);

    // Override a framework default — custom login page
    $container->singleton(
        AuthLoginRendererInterface::class,
        CustomLoginRenderer::class,
    );
});

$app->run();

Resolution Flow Summary

Registration MethodHow It ResolvesInstance Lifetime
bind($abstract, $concrete)Builds via factory or auto-wiringNew instance every call
singleton($abstract, $concrete)Builds once, then returns cachedShared for app lifetime
instance($abstract, $object)Returns the exact objectShared for app lifetime
No registration (auto-wiring)Reflects constructor, resolves typesNew instance every call