Contextual Containers

Canvas includes Contextual Containers, a dependency injection system that lets you use different implementations of the same interface based on context. For example Twig for frontend templates but Blade for emails. You write providers—factory classes that create services—and register them in composer.json. Canvas automatically discovers them and uses contextual resolution to instantiate the right implementation when you request a service.

Core Concepts

Canvas uses two packages for dependency management:

  • Quellabs Discover - Finds provider classes in composer.json and reads their metadata
  • Quellabs Dependency Injection - Creates service instances through providers with autowiring

The key concept is that providers are factories that create services. A service is the actual object you use (like a template engine). A provider knows how to create and configure that service.

At startup the Dependency Injection package:

  1. Instantiates Discover for provider discovery
  2. Discover scans composer.json for provider class names
  3. Discover reads static metadata from each provider class
  4. Discover registers the providers with the dependency injection container

When you request a service, the Dependency Injection package:

  1. Asks each provider "Can you create this service?" via supports()
  2. Selects the first provider that returns true
  3. Autowires the service's constructor dependencies
  4. Calls the provider's createInstance() method
  5. Returns the created service instance

Service Providers

Service providers are classes that implement the ServiceProvider interface:

namespace Quellabs\Contracts\DependencyInjection;

interface ServiceProvider extends ProviderInterface {
    public function supports(string $className, array $metadata): bool;
    public function createInstance(
        string $className,
        array $dependencies,
        array $metadata,
        ?MethodContext $methodContext = null
    ): object;
}

Registration

Register your providers in the "di" family in composer.json. Canvas's dependency injection container automatically scans this family at startup - the name "di" is required and cannot be changed.

{
  "extra": {
    "discover": {
      "di": {
        "providers": [
          "App\\Providers\\TwigProvider",
          "App\\Providers\\BladeProvider"
        ]
      }
    }
  }
}

You can optionally specify configuration files for providers. Discover will automatically load those in.

{
  "extra": {
    "discover": {
      "di": {
        "providers": [
          {
            "class": "App\\Providers\\TwigProvider",
            "config": "config/twig.php"
          }
        ]
      }
    }
  }
}

Configuration files should return an array:

// config/twig.php
return [
    'setting1' => 'xyz'
];

Local Configuration Overrides

For any configuration file, you can create a corresponding .local.php file that Discover will automatically load and merge on top of the base configuration. For example, given config/twig.php, you can create config/twig.local.php:

// config/twig.local.php
return [
    'setting1' => 'abc',       // overrides 'xyz' from twig.php
    'debug'    => true,         // added only in this environment
];

The merge is recursive — only the keys you specify are overridden, the rest are kept from the base file. This is useful for keeping environment-specific settings like credentials, paths, or debug flags out of version control.

Contextual Resolution

Request different implementations of the same interface using for():

// Get different template engines
$twig = $container->for('twig')->get(TemplateEngineInterface::class);
$blade = $container->for('blade')->get(TemplateEngineInterface::class);

// Get different database connections
$primaryDb = $container->for(['driver' => 'mysql', 'role' => 'primary'])
    ->get(DatabaseInterface::class);
$replicaDb = $container->for(['driver' => 'mysql', 'role' => 'replica'])
    ->get(DatabaseInterface::class);

How it works: When you call for('twig'), the container converts the string to ['provider' => 'twig'] and passes it as metadata to each provider's supports() method. Array contexts are passed directly without conversion.

Example: Template Engines

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,
        ?MethodContext $methodContext = null
    ): object {
        return new TwigEngine($this->getConfig());
    }
}

class BladeProvider extends ServiceProvider {
    public function supports(string $className, array $metadata): bool {
        return $className === TemplateEngineInterface::class
            && ($metadata['provider'] ?? 'blade') === 'blade';
    }

    public function createInstance(
        string $className,
        array $dependencies,
        array $metadata,
        ?MethodContext $methodContext = null
    ): 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', ['title' => 'Welcome']);
$blade->render('page.blade.php', ['title' => 'Welcome']);

Default Singleton Behavior

When services are resolved without a custom provider, the default service provider implements singleton behavior - the same instance is returned for repeated requests:

// Services without custom providers use singleton pattern
$logger1 = $container->get(LoggerInterface::class);
$logger2 = $container->get(LoggerInterface::class);
// $logger1 === $logger2 (same instance)

// Custom providers control their own instantiation behavior
$twig1 = $container->for('twig')->get(TemplateEngineInterface::class);
$twig2 = $container->for('twig')->get(TemplateEngineInterface::class);
// May or may not be the same instance, depending on TwigProvider implementation

Key Benefits

  • Interface-based design - Depend on interfaces, not implementations
  • Context-aware resolution - Same interface, different implementations per context
  • Automatic discovery - Providers found via composer.json
  • Automatic wiring - Constructor dependencies autowired
  • Lazy loading - Services created only when requested
  • Default singleton behavior - Same instance reused when using default provider
  • Configuration merging - Defaults + environment config
  • Production caching - Skip discovery in production

The Canvas Way: Write providers that create services. Register them in composer.json. Canvas discovers them automatically and uses them to autowire your application. When you need different implementations of the same interface, use contextual resolution with for().