Contextual Containers (di)
Canvas ships with a fully autowired dependency injection container. Services are
resolved automatically from type hints — no manual binding required for concrete
classes. For interfaces and contextual resolution, service providers and the
@WithContext annotation give you precise control.
Constructor Injection
Any class resolved through the container has its constructor dependencies autowired automatically. Type-hint a concrete class and the container instantiates it, resolving its own dependencies recursively.
class OrderController extends BaseController {
public function __construct(
private OrderRepository $orders,
private PaymentService $payments,
private LoggerInterface $logger,
) {}
}
OrderRepository and PaymentService are instantiated and injected
without any registration. LoggerInterface requires a service provider since
it is an interface — see Service Providers below.
Method Injection
Controller action methods are also autowired. Route parameters are matched by name; typed dependencies are resolved from the container.
class UserController extends BaseController {
/**
* @Route("/users/{id}")
*/
public function show(
int $id, // matched from route variables
UserRepository $repository, // resolved from container
CacheInterface $cache, // resolved via service provider
): Response {
return $cache->remember("user.{$id}", 3600, fn() =>
$repository->find($id)
);
}
}
Contextual Injection via @WithContext
Declare which container context to use for a specific parameter directly in the
method docblock using the @WithContext annotation. This lets you inject
different implementations of the same interface into the same method. Context
resolution is handled automatically — see Contextual Resolution with for()
below for details on how it works under the hood.
/**
* @WithContext(parameter="cache", context="redis")
* @WithContext(parameter="fileCache", context="file")
*/
public function process(
CacheInterface $cache, // resolved via $container->for('redis')
CacheInterface $fileCache, // resolved via $container->for('file')
LoggerInterface $logger, // resolved normally, no context
): Response {
// ...
}
The @WithContext annotation supports the following parameters:
| Parameter | Type | Description |
|---|---|---|
parameter |
string | The exact name of the method parameter to apply context to |
context |
string | The context string passed to $container->for() during resolution |
Context is scoped strictly to the annotated parameter — it never bleeds into
adjacent parameters. Parameters without a @WithContext annotation always use
the default container. @WithContext has no effect unless a service provider
is registered whose supports() returns true for the given class and
context combination.
Contextual Resolution with for()
The container supports contextual resolution via for(). A cloned container
scoped to the given context is returned, and service providers can inspect that
context in supports() to return a different implementation.
Pass a string for simple context, or an array for multidimensional context.
A string is automatically converted to ['provider' => $context] internally;
arrays are passed through as-is.
// String context — converted to ['provider' => 'redis'] internally
$cache = $container->for('redis')->get(CacheInterface::class);
// Array context — passed directly to supports() as $metadata
$primaryDb = $container->for(['driver' => 'mysql', 'role' => 'primary'])
->get(DatabaseInterface::class);
$replicaDb = $container->for(['driver' => 'mysql', 'role' => 'replica'])
->get(DatabaseInterface::class);
The provider receives the context as the $metadata argument in supports():
public function supports(string $className, array $metadata = []): bool {
return $className === CacheInterface::class
&& ($metadata['provider'] ?? null) === 'redis';
}
Default Singleton Behaviour
When a service is resolved without a matching custom provider, the default provider applies singleton behaviour — the same instance is returned on repeated requests. Custom providers control their own instantiation strategy.
// Default provider — same instance every time
$logger1 = $container->get(LoggerInterface::class);
$logger2 = $container->get(LoggerInterface::class);
// $logger1 === $logger2
// Custom provider — instantiation strategy is up to the provider
$twig1 = $container->for('twig')->get(TemplateEngineInterface::class);
$twig2 = $container->for('twig')->get(TemplateEngineInterface::class);
// May or may not be the same instance
Example: Multiple Implementations of the Same Interface
A common use case is switching template engines per context. Both providers support the same interface; the context string selects which one is used:
class TwigProvider extends ServiceProvider {
public function supports(string $className, array $metadata = []): bool {
return $className === TemplateEngineInterface::class
&& ($metadata['provider'] ?? 'twig') === 'twig';
}
public function createInstance(string $className, array $dependencies, array $metadata): object {
return new TwigEngine($this->getConfig());
}
}
class BladeProvider extends ServiceProvider {
public function supports(string $className, array $metadata = []): bool {
return $className === TemplateEngineInterface::class
&& ($metadata['provider'] ?? null) === 'blade';
}
public function createInstance(string $className, array $dependencies, array $metadata): object {
return new BladeEngine($this->getConfig());
}
}
// Usage
$twig = $container->for('twig')->get(TemplateEngineInterface::class);
$blade = $container->for('blade')->get(TemplateEngineInterface::class);
$twig->render('page.html.twig', ['title' => 'Welcome']);
$blade->render('page.blade.php', ['title' => 'Welcome']);
Resolving from the Container Directly
When you need to resolve a service imperatively, inject the container itself
and call get():
public function __construct(private ContainerInterface $container) {}
public function handle(): void {
// Resolve with default container
$logger = $this->container->get(LoggerInterface::class);
// Resolve with context
$cache = $this->container->for('redis')->get(CacheInterface::class);
// Instantiate without service providers (concrete classes only)
$mailer = $this->container->make(SmtpMailer::class);
}
| Method | Description |
|---|---|
get($class) |
Resolve a class or interface, running through service providers |
make($class) |
Instantiate a concrete class directly, bypassing service providers |
has($class) |
Check whether the container can resolve a given class or interface |
for($context) |
Return a cloned container scoped to the given context string |
invoke($instance, $method) |
Call a method on an existing instance with autowired arguments |
register($provider) |
Register a service provider at runtime |
unregister($provider) |
Remove a previously registered service provider |
Service Providers
Service providers bind interfaces to implementations and handle any initialization logic the container cannot infer automatically. Providers are discovered via Composer metadata — no manual registration in the kernel.
class LoggerProvider extends ServiceProvider {
public function supports(string $className, array $metadata = []): bool {
return $className === LoggerInterface::class;
}
public function createInstance(
string $className,
array $dependencies,
array $metadata,
): object {
return new FileLogger('/var/log/canvas.log');
}
}
Register providers in composer.json under the di family. Canvas scans
this at boot — the name di is required and cannot be changed.
{
"extra": {
"discover": {
"di": {
"providers": [
"App\\Providers\\TwigProvider",
"App\\Providers\\BladeProvider"
]
}
}
}
}
Providers can optionally declare a configuration file. Discover loads it
automatically and makes it available via $this->getConfig() inside the provider:
{
"extra": {
"discover": {
"di": {
"providers": [
{
"class": "App\\Providers\\TwigProvider",
"config": "config/twig.php"
}
]
}
}
}
}
Configuration files return a plain array:
// config/twig.php
return [
'cache_path' => '/tmp/twig',
'debug' => false,
];
For any configuration file you can create a corresponding .local.php file
that Discover loads and merges on top of the base configuration. Only the keys
you specify are overridden — the rest are kept from the base file. This is
useful for keeping environment-specific settings out of version control:
// config/twig.local.php
return [
'debug' => true, // overrides false from twig.php
];
Circular Dependency Detection
The container tracks the resolution stack and throws a descriptive
RuntimeException when a circular dependency is detected, showing the full
chain:
RuntimeException: Circular dependency detected: ServiceA -> ServiceB -> ServiceA