Skip to content

Container

Foundation Container provides a shared container contract and service provider base class. Use it to describe how application services are constructed while keeping dependency resolution and the underlying container implementation out of the services themselves.

Install the split package in applications that define their own container or service providers:

composer require stellarwp/foundation-container

Other Foundation packages install Container automatically when they depend on it. Composer does not require a second explicit installation in that case.

Create one container in the application composition root and register providers in dependency order. These guides establish that structure:

Let the container autowire concrete classes

Section titled “Let the container autowire concrete classes”

The container can construct an unbound concrete class when its constructor dependencies are also concrete classes:

final readonly class Catalog_Synchronizer {

	public function __construct(
		private Product_Repository $products,
		private Remote_Catalog $catalog
	) {
	}
}

Resolve the application entrypoint where it is needed:

$synchronizer = $container->get( Catalog_Synchronizer::class );

Prefer constructor injection throughout application code. Calling get() inside a service hides its dependencies and turns the container into a service locator.

In src/Catalog/Catalog_Provider.php, bind an interface when the container cannot infer which implementation the application wants. Use bind() for a new instance on each resolution and singleton() when every resolution should return the same instance:

<?php declare(strict_types=1);

namespace Plugin\Catalog;

use StellarWP\Foundation\Container\Contracts\Provider;

/**
 * Selects the catalog implementation used by the application.
 */
final class Catalog_Provider extends Provider {

	public function register(): void {
		$this->register_catalog();
	}

	private function register_catalog(): void {
		$this->container->singleton(
			Catalog::class,
			Remote_Catalog::class
		);
	}
}

Bindings are lazy. Registering Remote_Catalog does not construct it; the container builds it when another service first requests Catalog.

Every provider receives the shared container and read-only configuration snapshot. Use $this->config when a feature needs configuration; providers that do not need it can simply ignore it. This keeps one provider shape throughout the application instead of requiring developers to choose a base class.

Foundation providers register eagerly. Keep expensive services lazy by binding them in register() and letting the container construct them on first use; do not add provider-level deferred or boot phases.

In the same src/Catalog/Catalog_Provider.php, use a contextual binding when one class needs a scalar or a feature-specific implementation. Target scalar constructor arguments by their $name. Import Foundation’s Resolver as C when a factory callback must resolve another service:

<?php declare(strict_types=1);

namespace Plugin\Catalog;

use StellarWP\Foundation\Container\Contracts\Provider;
use StellarWP\Foundation\Container\Contracts\Resolver as C;

/**
 * Configures the remote catalog used by the application.
 */
final class Catalog_Provider extends Provider {

	public function register(): void {
		$this->register_catalog();
	}

	private function register_catalog(): void {
		$this->container->when( Remote_Catalog::class )
			->needs( '$endpoint' )
			->give( (string) $this->config->get( 'catalog.endpoint' ) );

		$this->container->singleton( Remote_Catalog::class );
		$this->container->singleton(
			Catalog::class,
			static fn ( C $c ): Remote_Catalog => $c->get( Remote_Catalog::class )
		);
	}
}

The callback aliases Catalog to the configured Remote_Catalog singleton. This preserves the contextual bindings registered for the concrete class and ensures both identifiers resolve the same object.

Factory callbacks receive Foundation’s Resolver contract. Application providers should not type-hint DI52 directly; keeping the callback behind the Foundation contract allows the underlying container integration to change without requiring edits throughout application providers.

Use a factory callback only when the value must be computed or fetched from the container. Let the container construct the complete service whenever it can.

In src/Report/Report_Provider.php, use mergeArrayVar() when independent providers contribute to one ordered collection. The provider that owns the collection registers its default and supplies it to the consuming class:

<?php declare(strict_types=1);

namespace Plugin\Report;

use StellarWP\Foundation\Container\Contracts\Provider;
use StellarWP\Foundation\Container\Contracts\Resolver as C;

/**
 * Collects report exporters contributed by application features.
 */
final class Report_Provider extends Provider {

	public const string EXPORTERS = 'your-plugin.report.exporters';

	public function register(): void {
		$this->register_exporter_collection();
	}

	private function register_exporter_collection(): void {
		$this->container->mergeArrayVar( self::EXPORTERS, [] );

		$this->container->when( Exporter_Collection::class )
			->needs( '$exporters' )
			->give( static fn ( C $c ): array => $c->get( self::EXPORTERS ) );
	}
}

Other feature providers append their implementations without replacing earlier contributions. For example, src/Report/Csv/Csv_Provider.php can contribute the CSV implementation:

<?php declare(strict_types=1);

namespace Plugin\Report\Csv;

use StellarWP\Foundation\Container\Contracts\Provider;
use StellarWP\Foundation\Container\Contracts\Resolver as C;
use Plugin\Report\Report_Provider;

/**
 * Contributes CSV output to the application's report exporters.
 */
final class Csv_Provider extends Provider {

	public function register(): void {
		$this->register_csv_exporter();
	}

	private function register_csv_exporter(): void {
		$this->container->mergeArrayVar(
			Report_Provider::EXPORTERS,
			static fn ( C $c ): array => [
				$c->get( Csv_Exporter::class ),
			]
		);
	}
}

In src/Catalog/Catalog_Provider.php, use callback() to let WordPress resolve a service only when its hook runs:

<?php declare(strict_types=1);

namespace Plugin\Catalog;

use StellarWP\Foundation\Container\Contracts\Provider;

/**
 * Connects catalog synchronization to WordPress.
 */
final class Catalog_Provider extends Provider {

	public function register(): void {
		$this->register_catalog_sync();
	}

	private function register_catalog_sync(): void {
		$this->container->singleton( Catalog_Synchronizer::class );

		add_action(
			'your_plugin/sync_catalog',
			$this->container->callback( Catalog_Synchronizer::class, 'synchronize' )
		);
	}
}

This avoids constructing the synchronizer during every request merely to register its callback.

Catch StellarWP\Foundation\Container\Exceptions\NotFoundException when a requested identifier may be absent. Failures raised by the container while registering or resolving services use StellarWP\Foundation\Container\Exceptions\ContainerException. Both implement the corresponding PSR container exception interfaces, and the original application failure remains available through getPrevious() when the underlying container wrapped one. Exceptions thrown by a provider’s own register() method remain that provider’s exception and propagate unchanged.

In src/Catalog/Catalog_Provider.php, use a decorator chain when cross-cutting behavior should wrap a service without changing its implementation. List the outermost decorator first and the base implementation last:

<?php declare(strict_types=1);

namespace Plugin\Catalog;

use StellarWP\Foundation\Container\Contracts\Provider;

/**
 * Wraps the remote catalog with caching and logging behavior.
 */
final class Catalog_Provider extends Provider {

	public function register(): void {
		$this->register_catalog();
	}

	private function register_catalog(): void {
		$this->container->singletonDecorators(
			Catalog::class,
			[
				Logging_Catalog::class,
				Caching_Catalog::class,
				Remote_Catalog::class,
			]
		);
	}
}

Resolving Catalog returns one Logging_Catalog that wraps Caching_Catalog, which wraps Remote_Catalog. Use bindDecorators() instead when the application needs a new chain on every resolution.

Replace an implementation in a focused test

Section titled “Replace an implementation in a focused test”

Bind a test double to the same contract before resolving the class under test:

$catalog = new Fake_Catalog();

$this->container->bind( Catalog::class, $catalog );

$synchronizer = $this->container->get( Catalog_Synchronizer::class );
$synchronizer->synchronize();

$this->assertTrue( $catalog->was_synchronized() );

Test application services through their public behavior. Reserve container integration tests for provider graphs where the binding itself is the behavior under test.

Most existing providers need little or no structural change. A provider that already extends Provider, declares no constructor, and registers its feature in register() keeps the same shape.

First, update the application composition root to create the shared container with ContainerFactory. This replaces direct ContainerAdapter, DI52, and Adbar\Dot setup:

Then update each application provider:

  1. Keep extending StellarWP\Foundation\Container\Contracts\Provider and implement register().
  2. Remove custom provider constructors. Read configuration through $this->config and let the base provider receive the container.
  3. Type factory callbacks against StellarWP\Foundation\Container\Contracts\Resolver, conventionally imported as C, instead of DI52’s container.

After those changes, src/Catalog/Catalog_Provider.php can look like this:

<?php declare(strict_types=1);

namespace Plugin\Catalog;

use StellarWP\Foundation\Container\Contracts\Provider;
use StellarWP\Foundation\Container\Contracts\Resolver as C;

/**
 * Configures the application's catalog services.
 */
final class Catalog_Provider extends Provider {

	public function register(): void {
		$this->register_catalog();
	}

	private function register_catalog(): void {
		$this->container->when( Remote_Catalog::class )
			->needs( '$endpoint' )
			->give( (string) $this->config->get( 'catalog.endpoint' ) );

		$this->container->singleton( Remote_Catalog::class );
		$this->container->singleton(
			Catalog::class,
			static fn ( C $c ): Remote_Catalog => $c->get( Remote_Catalog::class )
		);
	}
}

If the application used lower-level container APIs, make these additional replacements:

  • Replace direct implementations of the removed Providable interface with classes that extend Provider.
  • Move provider boot(), provides(), and deferred-provider behavior into register() or an application-owned lifecycle.
  • Replace ContainerAdapter::getContainer() and forwarded DI52 methods with operations declared by Foundation’s Container and Resolver contracts.
  • Catch Foundation’s ContainerException or NotFoundException instead of DI52 exceptions.