The Service Container
Rhapsody's Rhapsody\Core\Container is the framework's dependency injection (DI) container.
That's a fancy way of saying: it's the thing that builds your objects for you and hands them their
dependencies, so you don't have to wire everything up by hand.
You tell the container two things: what you want (an interface, a class name, or a plain
string alias like 'config') and how to build it (a concrete class name, or a
function that returns the object). Then, whenever you need an object, you ask the container for it. If that
object needs other objects to be constructed, the container figures those out too — recursively — by
inspecting the class's constructor with PHP's Reflection API. In practice, you rarely have to write
new Something(...) yourself.
What It Does
- Binds names to things. An interface or alias can be mapped to a concrete class or to a factory function (a closure).
- Auto-wires constructor dependencies. If a class's constructor asks for
LoggerInterfaceandUserRepository, the container will resolve both and pass them in — recursively, all the way down. - Detects circular dependencies. If
AneedsBandBneedsA, the container stops immediately and tells you, instead of looping forever. - Supports singletons.
singleton()bindings are built once and then reused on every later request. - Traces every resolution. Class name, how long it took, who asked for it — all logged for debugging.
Binding Dependencies
bind() — a fresh instance every time
bind() tells the container: "When someone asks for this abstract name, build it like this."
A new instance is created on every resolve() call. Use this for
things that shouldn't be shared, like a per-request Request object.
// Bind a class to a factory function
$container->bind(Request::class, fn() => new Request());
// Bind an interface to a concrete implementation
$container->bind(LoggerInterface::class, FileLogger::class);
// Bind a string alias to a factory function
$container->bind('db', function () {
return new PDO('mysql:host=localhost;dbname=test', 'user', 'pwd');
});
singleton() — build once, reuse forever
singleton() works exactly like bind(), except the first
resolve() call builds the instance and caches it. Every call after that returns
the same object instead of running the factory again.
Use this for anything expensive or stateful that should be shared for the life of the container — a database connection, the Twig environment, an event dispatcher.
$container->singleton(EntityManager::class, function ($container) {
return new EntityManager($connection, $config);
});
instance() — register an object you already built
If you've already constructed the object yourself and just want the container to return that exact instance,
use instance():
$logger = new Logger($config);
$container->instance(LoggerInterface::class, $logger);
// Every resolve(LoggerInterface::class) call now returns this exact object
Resolving Dependencies
resolve() (also available as get() — they're identical) builds and returns an
instance of the given abstract:
$logger = $container->resolve(Logger::class);
$logger = $container->get(Logger::class); // same thing
If you want to check whether something has been explicitly bound before trying to resolve it, use
has():
if ($container->has('db')) {
$connection = $container->get('db');
}
Auto-Wiring
If a class was never explicitly bound, resolve() falls back to reflection: it
looks at the class's constructor and, for each parameter, tries to resolve that parameter's type
automatically.
The rules are simple:
- Type-hinted with a class or interface → resolved recursively.
- Has a default value (like
$debug = false) → that default is used. - Anything else → an exception is thrown, telling you exactly which parameter couldn't be resolved and where.
class UserService
{
public function __construct(
private LoggerInterface $logger,
private UserRepository $repository
) {}
}
// LoggerInterface and UserRepository are both resolved automatically.
// LoggerInterface needs a binding (see above); UserRepository doesn't,
// as long as its own constructor has no unresolvable dependencies.
$userService = $container->resolve(UserService::class);
bind(LoggerInterface::class, FileLogger::class) is what makes the example above work.
Singleton Lifecycle
Two methods exist for managing already-resolved singletons without triggering a new resolution:
resolved(string $abstract): bool— returnstrueif this singleton has already been built and cached on this container.forgetSingleton(string $abstract): void— drops the cached instance, so the nextresolve()call rebuilds it from scratch.
These matter most under a persistent-worker runtime (see RoadRunner Persistent Workers),
where the container is built once and reused for the life of the worker. forgetSingleton() lets
you recover a singleton that entered an unusable state mid-request — for example, an
EntityManager that Doctrine has permanently closed after an unrecoverable ORM exception —
without restarting the whole worker.
Circular Dependency Detection
If class A depends on B, and B depends back on A,
resolving either one throws immediately rather than recursing forever:
// Throws: Circular dependency detected: App\A → App\B → App\A
$container->resolve(App\A::class);
Resolution Tracing
Every resolution — successful or not — is logged with timing and caller information. This is useful for hunting down slow or unexpected instantiations:
$trace = Container::getTrace();
foreach ($trace as $entry) {
echo "Resolved {$entry['class']} in {$entry['duration']}ms (called by {$entry['called_by']})\n";
}
Container::resetTrace(); // clear the log
Container::setProxyMode(true); // flag subsequent entries as proxy resolutions
The Global Accessor
For the vast majority of classes, the container should be threaded in through the constructor — that's what auto-wiring is for, and it's what keeps a class's real dependencies visible and easy to test.
But some infrastructure-level classes are routinely instantiated directly with new by
application code (never resolved through the container itself) and only occasionally need to reach a bound
service. The Pagination class rendering a custom Twig template
is a good example. Forcing every caller of new Pagination(...) to also pass in a
Twig\Environment just for that one optional feature would be worse than the alternative: a
global accessor.
// End of bootstrap.php, after every bind()/singleton() call has run:
Container::setInstance($container);
// Anywhere else in the framework:
$twig = Container::getInstance()->get(Twig\Environment::class);
// Check without triggering an exception if nothing has been set yet:
if (Container::hasInstance()) { ... }
// Test isolation — clear the registered instance between tests:
Container::forgetInstance();
setInstance() at the end of
bootstrap.php, after every bind() and singleton() call has run.
Registering too early is the same footgun that previously caused the custom 404 page to render without Twig
bound — a bare, unconfigured container captured before its bindings existed.
Pros and Cons
setInstance() only ever needs to run
once at boot.
Container::setInstance() with a suitable double before exercising
the class, rather than simply passing one in. Reserve getInstance() for classes the
container doesn't itself resolve — anything the container builds for you (controllers, services
bound via bind()/singleton()) should keep using constructor injection.
Limitations
bind()always creates a new instance. If you want something shared, usesingleton().- No contextual binding. You can't say "give this class implementation X of the interface, but give that class implementation Y." One binding per abstract, period.
- The resolution trace is static and shared.
getTrace()returns the same log across every container instance in the process. - No implicit interface autowiring. An interface must be explicitly
bind()'d to a concrete class before it can be resolved.