diff --git a/README.md b/README.md
index 11db6f1..198ad1c 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f
## Repositories
- [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container)
+- [stellarwp/foundation-container-wordpress](https://github.com/stellarwp/foundation-container-wordpress)
- [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline)
- [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log)
- [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli)
diff --git a/composer.json b/composer.json
index 8a27e39..25cd000 100644
--- a/composer.json
+++ b/composer.json
@@ -13,7 +13,7 @@
"ext-curl": "*",
"ext-exif": "*",
"adbario/php-dot-notation": ">=2.5",
- "lucatume/di52": ">=3.0",
+ "lucatume/di52": ">=4.1",
"monolog/monolog": "^2.11",
"psr/log": ">=1.0",
"stellarwp/container-contract": "^1.1",
@@ -25,6 +25,7 @@
"monorepo-php/monorepo": "^12.7",
"nunomaduro/collision": "^8.9",
"php-mock/php-mock-mockery": "^1.5",
+ "php-stubs/wordpress-stubs": ">=6.0",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^11.5",
@@ -34,6 +35,7 @@
"replace": {
"stellarwp/foundation-cli": "self.version",
"stellarwp/foundation-container": "self.version",
+ "stellarwp/foundation-container-wordpress": "self.version",
"stellarwp/foundation-log": "self.version",
"stellarwp/foundation-pipeline": "self.version",
"stellarwp/foundation-wpcli": "self.version"
@@ -43,6 +45,7 @@
"autoload": {
"psr-4": {
"StellarWP\\Foundation\\Cli\\": "src/Cli/",
+ "StellarWP\\Foundation\\ContainerWordPress\\": "src/ContainerWordPress/",
"StellarWP\\Foundation\\Container\\": "src/Container/",
"StellarWP\\Foundation\\Log\\": "src/Log/",
"StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/",
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 44a650b..a1cb3a6 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -3,7 +3,13 @@ parameters:
paths:
- src
- tests
+ scanFiles:
+ - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php
excludePaths:
analyse:
- src/*/vendor/*
- tests/CodeceptionSupport/*
+ ignoreErrors:
+ -
+ identifier: catch.neverThrown
+ path: tests/*
diff --git a/src/Container/ContainerAdapter.php b/src/Container/ContainerAdapter.php
index 91d728a..96bce13 100644
--- a/src/Container/ContainerAdapter.php
+++ b/src/Container/ContainerAdapter.php
@@ -39,7 +39,7 @@ public function get(string $id): mixed {
/**
* @codeCoverageIgnore
*/
- public function get_container(): DI52Container {
+ public function getContainer(): DI52Container {
return $this->container;
}
@@ -95,6 +95,15 @@ public function instance(mixed $id, array $buildArgs = [], ?array $afterBuildMet
return $this->container->instance($id, $buildArgs, $afterBuildMethods);
}
+ /**
+ * {@inheritDoc}
+ *
+ * @throws ContainerException
+ */
+ public function mergeArrayVar(string $id, mixed $implementation): void {
+ $this->container->mergeArrayVar($id, $implementation);
+ }
+
/**
* @param class-string|string|object $id
*
diff --git a/src/Container/Contracts/Container.php b/src/Container/Contracts/Container.php
index 5502a94..3c1aca2 100644
--- a/src/Container/Contracts/Container.php
+++ b/src/Container/Contracts/Container.php
@@ -37,6 +37,15 @@ public function needs(string $id): Container;
public function give(mixed $implementation): void;
+ /**
+ * Add array values to an existing or future binding without replacing previous values.
+ *
+ * @param class-string|string $id
+ *
+ * @throws \lucatume\DI52\ContainerException
+ */
+ public function mergeArrayVar(string $id, mixed $implementation): void;
+
/**
* Returns a callable object (Closure) that will build an instance of the specified
* class using the specified arguments when called.
diff --git a/src/Container/composer.json b/src/Container/composer.json
index 9e083a6..06734e7 100644
--- a/src/Container/composer.json
+++ b/src/Container/composer.json
@@ -10,7 +10,7 @@
"require": {
"php": ">=8.3",
"adbario/php-dot-notation": ">=2.5",
- "lucatume/di52": ">=3.0",
+ "lucatume/di52": ">=4.1",
"stellarwp/container-contract": "^1.1",
"vlucas/phpdotenv": ">=4.3"
},
diff --git a/src/ContainerWordPress/.gitattributes b/src/ContainerWordPress/.gitattributes
new file mode 100644
index 0000000..e82014a
--- /dev/null
+++ b/src/ContainerWordPress/.gitattributes
@@ -0,0 +1,7 @@
+# Path-based git attributes
+# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html
+
+# Ignore paths when git creates an archive of this package
+.gitattributes export-ignore
+.gitignore export-ignore
+.github export-ignore
diff --git a/src/ContainerWordPress/.github/workflows/close-pull-request.yml b/src/ContainerWordPress/.github/workflows/close-pull-request.yml
new file mode 100644
index 0000000..6bfbabe
--- /dev/null
+++ b/src/ContainerWordPress/.github/workflows/close-pull-request.yml
@@ -0,0 +1,13 @@
+name: Close Pull Request
+
+on:
+ pull_request_target:
+ types: [opened]
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: superbrothers/close-pull-request@v3
+ with:
+ comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.
Thanks!"
diff --git a/src/ContainerWordPress/.gitignore b/src/ContainerWordPress/.gitignore
new file mode 100644
index 0000000..d1502b0
--- /dev/null
+++ b/src/ContainerWordPress/.gitignore
@@ -0,0 +1,2 @@
+vendor/
+composer.lock
diff --git a/src/ContainerWordPress/ContainerAdapter.php b/src/ContainerWordPress/ContainerAdapter.php
new file mode 100644
index 0000000..98c12c8
--- /dev/null
+++ b/src/ContainerWordPress/ContainerAdapter.php
@@ -0,0 +1,258 @@
+ $decorators, ?array $afterBuildMethods = null)
+ * @method void bindDecorators($id, array $decorators, ?array $afterBuildMethods = null)
+ */
+final readonly class ContainerAdapter implements Container
+{
+ private FoundationContainerAdapter $container;
+
+ private string $prefix;
+
+ public function __construct(
+ FoundationContainerAdapter $container,
+ string $prefix = 'nexcess/foundation/container/wp/',
+ ) {
+ $this->container = $container;
+ $this->prefix = $prefix === '' ? '' : rtrim($prefix, '/') . '/';
+ }
+
+ /**
+ * Build the "registered" WordPress action name for a service provider or alias.
+ *
+ * @param string $identifier The service provider class or alias slug.
+ *
+ * @throws InvalidArgumentException When the provided $identifier is empty.
+ *
+ * @return non-empty-string
+ */
+ private function registeredAction(string $identifier): string {
+ if ($identifier === '') {
+ throw new InvalidArgumentException('You need to provide an identifier!');
+ }
+
+ return $this->prefix . $identifier . '/registered';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function register(string $serviceProviderClass, ...$alias): void {
+ $this->registeredAction($serviceProviderClass);
+
+ foreach ($alias as $slug) {
+ $this->registeredAction($slug);
+ }
+
+ $this->container->register($serviceProviderClass, ...$alias);
+
+ /**
+ * Fires after a service provider has been registered in the container.
+ *
+ * The dynamic portion of the hook name, `$serviceProviderClass`, refers to the
+ * fully-qualified class name of the registered service provider.
+ *
+ * @param class-string $serviceProviderClass The registered service provider class.
+ * @param string[] $alias The aliases the provider was registered under.
+ */
+ do_action($this->registeredAction($serviceProviderClass), $serviceProviderClass, $alias);
+
+ foreach ($alias as $slug) {
+ /**
+ * Fires after a service provider has been registered, once per alias.
+ *
+ * The dynamic portion of the hook name, `$slug`, refers to an alias the
+ * service provider was registered under.
+ *
+ * @param class-string $serviceProviderClass The registered service provider class.
+ * @param string[] $alias The aliases the provider was registered under.
+ */
+ do_action($this->registeredAction($slug), $serviceProviderClass, $alias);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function registerAfterAllActions(array $actions, string $serviceProviderClass, ...$alias): void {
+ $pending = array_values(array_filter($actions, static fn (string $action): bool => ! did_action($action)));
+
+ if ($pending === []) {
+ // All the actions have already fired, register the provider immediately.
+ $this->register($serviceProviderClass, ...$alias);
+
+ return;
+ }
+
+ // A single closure is hooked onto every pending action. Whichever action fires
+ // last finds all actions done, registers once, and detaches from the rest.
+ $register_when_ready = function () use ($actions, $pending, $serviceProviderClass, $alias, &$register_when_ready): void {
+ foreach ($actions as $action) {
+ if (! did_action($action)) {
+ return;
+ }
+ }
+
+ // Detach from every pending action so the provider is only registered once.
+ foreach ($pending as $action) {
+ remove_action($action, $register_when_ready);
+ }
+
+ $this->register($serviceProviderClass, ...$alias);
+ };
+
+ foreach ($pending as $action) {
+ add_action($action, $register_when_ready);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function registerOnAction(string $action, string $serviceProviderClass, ...$alias): void {
+ if (did_action($action)) {
+ // If the action has already fired, register the provider immediately.
+ $this->register($serviceProviderClass, ...$alias);
+
+ return;
+ }
+
+ // If the action has not fired yet, register the provider when/if it does.
+ $registration_closure = function () use ($action, $serviceProviderClass, $alias, &$registration_closure) {
+ // Remove the closure from the action to avoid calling it again.
+ remove_action($action, $registration_closure);
+ $this->register($serviceProviderClass, ...$alias);
+ };
+
+ add_action($action, $registration_closure);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function registerOnProvider(
+ string $baseProviderClass,
+ string $dependentProviderClass,
+ ...$alias
+ ): void {
+ $this->registerOnAction($this->registeredAction($baseProviderClass), $dependentProviderClass, ...$alias);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function when(string $class): Container {
+ $this->container->when($class);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function needs(string $id): Container {
+ $this->container->needs($id);
+
+ return $this;
+ }
+
+ /**
+ * @param string[]|null $afterBuildMethods
+ *
+ * @throws \lucatume\DI52\ContainerException
+ */
+ public function bind(string $id, mixed $implementation = null, ?array $afterBuildMethods = null): void {
+ $this->container->bind($id, $implementation, $afterBuildMethods);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function get(string $id): mixed {
+ return $this->container->get($id);
+ }
+
+ /**
+ * @codeCoverageIgnore
+ */
+ public function getContainer(): DI52Container {
+ return $this->container->getContainer();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @codeCoverageIgnore
+ */
+ public function has(string $id): bool {
+ return $this->container->has($id);
+ }
+
+ /**
+ * @param string[]|null $afterBuildMethods
+ *
+ * @throws \lucatume\DI52\ContainerException
+ */
+ public function singleton(string $id, mixed $implementation = null, ?array $afterBuildMethods = null): void {
+ $this->container->singleton($id, $implementation, $afterBuildMethods);
+ }
+
+ public function give(mixed $implementation): void {
+ $this->container->give($implementation);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @throws ContainerException
+ */
+ public function mergeArrayVar(string $id, mixed $implementation): void {
+ $this->container->mergeArrayVar($id, $implementation);
+ }
+
+ /**
+ * @param array $buildArgs
+ * @param string[]|null $afterBuildMethods
+ */
+ public function instance(mixed $id, array $buildArgs = [], ?array $afterBuildMethods = null): Closure {
+ return $this->container->instance($id, $buildArgs, $afterBuildMethods);
+ }
+
+ /**
+ * @param class-string|string|object $id
+ *
+ * @throws ContainerException
+ */
+ public function callback(object|string $id, string $method): callable {
+ return $this->container->callback($id, $method);
+ }
+
+ /**
+ * Defer all other calls to the wrapped Foundation container adapter.
+ *
+ * @param string $name The method name.
+ * @param mixed[] $args Method arguments.
+ */
+ public function __call(string $name, array $args): mixed {
+ return $this->container->{$name}(...$args);
+ }
+}
diff --git a/src/ContainerWordPress/Contracts/Container.php b/src/ContainerWordPress/Contracts/Container.php
new file mode 100644
index 0000000..054ea82
--- /dev/null
+++ b/src/ContainerWordPress/Contracts/Container.php
@@ -0,0 +1,60 @@
+ $serviceProviderClass The Service Provider to register on $action.
+ * @param string $alias Alias(es) for the $serviceProviderClass.
+ *
+ * @throws \lucatume\DI52\ContainerException
+ */
+ public function registerOnAction(string $action, string $serviceProviderClass, ...$alias): void;
+
+ /**
+ * @param class-string|string $baseProviderClass The provider class or id that the $dependentProviderClass
+ * depends on.
+ * @param class-string $dependentProviderClass The Service Provider to register after
+ * $baseProviderClass has been registered.
+ * @param string ...$alias Alias(es) for the $dependentProviderClass.
+ *
+ * @throws \lucatume\DI52\ContainerException
+ */
+ public function registerOnProvider(string $baseProviderClass, string $dependentProviderClass, ...$alias): void;
+
+ /**
+ * @param list $actions A list of actions that all need to be fired for the Provider to be
+ * registered.
+ * @param class-string $serviceProviderClass The Service Provider to register when the last action from $actions
+ * is fired.
+ * @param string ...$alias Alias(es) for the $serviceProviderClass.
+ *
+ * @throws \lucatume\DI52\ContainerException
+ */
+ public function registerAfterAllActions(array $actions, string $serviceProviderClass, ...$alias): void;
+}
diff --git a/src/ContainerWordPress/README.md b/src/ContainerWordPress/README.md
new file mode 100644
index 0000000..3d944d7
--- /dev/null
+++ b/src/ContainerWordPress/README.md
@@ -0,0 +1,143 @@
+# Foundation Container WordPress
+
+> [!WARNING]
+> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation).
+
+A WordPress-focused wrapper around [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container).
+It exposes the full Foundation DI container API and adds WordPress-specific helpers.
+
+## Installation
+
+```shell
+composer require stellarwp/foundation-container-wordpress
+```
+
+## Usage
+
+Create a new `ContainerAdapter`, passing in a Foundation container adapter. It implements the
+[`Contracts/Container.php`](./Contracts/Container.php) interface, which extends the base
+Foundation container contract:
+
+```php
+bind(ContainerContract::class, $container);
+$container->bind(WPContainerContract::class, $container);
+```
+
+Everything the [Foundation Container](https://github.com/stellarwp/foundation-container) can do,
+this wrapper can do too — binding, singletons, service providers, contextual bindings, and so on.
+
+## WordPress helpers
+
+On top of the base container API, this wrapper adds hook-aware service provider
+registration. These methods are declared on
+[`Contracts/Container.php`](./Contracts/Container.php) and implemented in
+[`ContainerAdapter.php`](./ContainerAdapter.php).
+
+All of them accept the same optional trailing `...$alias` arguments as the base
+`register()` method.
+
+## Hook Prefix
+
+The WordPress container adapter will fire registration hooks when a Provider is being registered. By default, it uses
+`'nexcess/foundation/container/wp/'` as the hook prefix, but you can change that by passing a second argument during the
+adapter's initialization. The adapter normalizes non-empty prefixes to include one trailing slash.
+
+```php
+$container = new ContainerAdapter(new FoundationContainerAdapter(new Container()), 'my/hook/prefix/');
+
+add_action(
+ 'my/hook/prefix/' . My_Provider::class . '/registered',
+ function ( string $provider_class, array $aliases ): void {
+ // React to My_Provider having been registered.
+ },
+ 10,
+ 2
+);
+
+// Or by using its alias.
+add_action(
+ 'my/hook/prefix/my-alias/registered',
+ function ( string $provider_class, array $aliases ): void {
+ // React to My_Provider having been registered.
+ },
+ 10,
+ 2
+);
+
+$container->register( My_Provider::class, 'my-alias' );
+
+
+```
+
+### Registration actions
+
+`register()` is overridden so that, once a provider has been registered, it fires
+WordPress actions other code can hook onto:
+
+| Action | Fired |
+|----------------------------------------------------------------------| --- |
+| `nexcess/foundation/container/wp/{$serviceProviderClass}/registered` | Once, for the registered provider class. |
+| `nexcess/foundation/container/wp/{$alias}/registered` | Once per alias the provider was registered under. |
+
+Both actions pass two arguments to listeners: the registered provider class
+(`string`) and the list of aliases (`string[]`).
+
+```php
+add_action(
+ 'nexcess/foundation/container/wp/' . My_Provider::class . '/registered',
+ function ( string $provider_class, array $aliases ): void {
+ // React to My_Provider having been registered.
+ },
+ 10,
+ 2
+);
+
+$container->register( My_Provider::class, 'my-alias' );
+```
+
+### `registerOnAction()`
+
+Register a provider when a WordPress action fires. If the action has already
+fired, the provider is registered immediately; otherwise registration is deferred
+until the action fires and happens only once.
+
+```php
+// Register when `init` fires (or right away if it already has).
+$container->registerOnAction( 'init', My_Provider::class );
+```
+
+### `registerOnProvider()`
+
+Register a provider only after another provider has been registered. It builds on
+the `.../registered` action above, so the dependent provider is wired up as soon
+as the base provider is registered.
+
+```php
+// Register Feature_Provider after Core_Provider has been registered.
+$container->registerOnProvider( Core_Provider::class, Feature_Provider::class );
+```
+
+### `registerAfterAllActions()`
+
+Register a provider only after *every* one of the given actions has fired. If all
+of them have already fired, registration happens immediately; otherwise it waits
+for the last one and then registers exactly once.
+
+```php
+// Register once both `plugins_loaded` and `init` have fired.
+$container->registerAfterAllActions( [ 'plugins_loaded', 'init' ], My_Provider::class );
+```
diff --git a/src/ContainerWordPress/composer.json b/src/ContainerWordPress/composer.json
new file mode 100644
index 0000000..a71c108
--- /dev/null
+++ b/src/ContainerWordPress/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "stellarwp/foundation-container-wordpress",
+ "type": "library",
+ "description": "The Foundation DI container wrapper for WordPress projects.",
+ "license": "GPL-2.0-or-later",
+ "config": {
+ "vendor-dir": "vendor",
+ "preferred-install": "dist"
+ },
+ "require": {
+ "php": ">=8.3",
+ "stellarwp/foundation-container": "^1.2"
+ },
+ "autoload": {
+ "psr-4": {
+ "StellarWP\\Foundation\\ContainerWordPress\\": ""
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.2.x-dev"
+ }
+ }
+}
diff --git a/tests/Support/Fixtures/ContainerWordPress/FirstProvider.php b/tests/Support/Fixtures/ContainerWordPress/FirstProvider.php
new file mode 100644
index 0000000..e405239
--- /dev/null
+++ b/tests/Support/Fixtures/ContainerWordPress/FirstProvider.php
@@ -0,0 +1,20 @@
+assertSame('fallback', $adapter->getVar('missing', 'fallback'));
}
+
+ public function test_it_merges_array_values_across_multiple_calls(): void {
+ $adapter = new ContainerAdapter(new DI52Container());
+
+ $adapter->mergeArrayVar('list', ['a']);
+ $adapter->mergeArrayVar('list', ['b', 'c']);
+
+ $this->assertSame(['a', 'b', 'c'], $adapter->getVar('list'));
+ }
+
+ public function test_it_merges_associative_array_values_without_replacing_previous_ones(): void {
+ $adapter = new ContainerAdapter(new DI52Container());
+
+ $adapter->mergeArrayVar('config', ['x' => 1]);
+ $adapter->mergeArrayVar('config', ['y' => 2]);
+
+ $this->assertSame(['x' => 1, 'y' => 2], $adapter->getVar('config'));
+ }
+
+ public function test_it_throws_when_merging_into_an_already_resolved_singleton(): void {
+ $adapter = new ContainerAdapter(new DI52Container());
+
+ $adapter->singleton('resolved', static fn (): array => ['first']);
+ $adapter->get('resolved');
+
+ $this->expectException(ContainerException::class);
+
+ $adapter->mergeArrayVar('resolved', ['second']);
+ }
}
diff --git a/tests/Unit/ContainerWordPress/ContainerAdapterTest.php b/tests/Unit/ContainerWordPress/ContainerAdapterTest.php
new file mode 100644
index 0000000..dcfdaeb
--- /dev/null
+++ b/tests/Unit/ContainerWordPress/ContainerAdapterTest.php
@@ -0,0 +1,91 @@
+assertInstanceOf(Container::class, $this->make_adapter());
+ }
+
+ public function test_it_binds_and_resolves_through_the_wrapped_container(): void {
+ $adapter = $this->make_adapter();
+ $adapter->bind(ContainerAdapterSample::class, static fn (): ContainerAdapterSample => new ContainerAdapterSample('value'));
+
+ $this->assertSame('value', $adapter->get(ContainerAdapterSample::class)->value);
+ }
+
+ public function test_it_returns_instance_builders_from_the_wrapped_container(): void {
+ $adapter = $this->make_adapter();
+ $factory = $adapter->instance(ContainerAdapterSample::class, ['value']);
+
+ $this->assertInstanceOf(ContainerAdapterSample::class, $factory());
+ $this->assertSame('value', $factory()->value);
+ }
+
+ public function test_it_returns_callbacks_from_the_wrapped_container(): void {
+ $adapter = $this->make_adapter();
+ $callback = $adapter->callback(new ContainerAdapterSample('value'), 'read');
+
+ $this->assertSame('value', $callback());
+ }
+
+ public function test_it_exposes_contextual_bindings_fluently(): void {
+ $adapter = $this->make_adapter();
+
+ $this->assertSame($adapter, $adapter->when(ContainerAdapterSample::class));
+ $this->assertSame($adapter, $adapter->needs('$value'));
+ }
+
+ public function test_it_forwards_unknown_method_calls_to_the_wrapped_container(): void {
+ $this->assertSame('fallback', $this->make_adapter()->getVar('missing', 'fallback'));
+ }
+
+ public function test_it_exposes_the_underlying_di52_container(): void {
+ $di52 = new DI52Container();
+ $adapter = new ContainerAdapter(new FoundationContainerAdapter($di52));
+
+ $this->assertSame($di52, $adapter->getContainer());
+ }
+
+ public function test_it_merges_array_values_through_the_wrapped_container(): void {
+ $adapter = $this->make_adapter();
+
+ $adapter->mergeArrayVar('list', ['a']);
+ $adapter->mergeArrayVar('list', ['b', 'c']);
+
+ $this->assertSame(['a', 'b', 'c'], $adapter->getVar('list'));
+ }
+
+ public function test_it_merges_associative_array_values_without_replacing_previous_ones(): void {
+ $adapter = $this->make_adapter();
+
+ $adapter->mergeArrayVar('config', ['x' => 1]);
+ $adapter->mergeArrayVar('config', ['y' => 2]);
+
+ $this->assertSame(['x' => 1, 'y' => 2], $adapter->getVar('config'));
+ }
+
+ public function test_it_throws_when_merging_into_an_already_resolved_singleton(): void {
+ $adapter = $this->make_adapter();
+
+ $adapter->singleton('resolved', static fn (): array => ['first']);
+ $adapter->get('resolved');
+
+ $this->expectException(ContainerException::class);
+
+ $adapter->mergeArrayVar('resolved', ['second']);
+ }
+}
diff --git a/tests/wpunit/ContainerWordPress/ContainerAdapterTest.php b/tests/wpunit/ContainerWordPress/ContainerAdapterTest.php
new file mode 100644
index 0000000..08c212e
--- /dev/null
+++ b/tests/wpunit/ContainerWordPress/ContainerAdapterTest.php
@@ -0,0 +1,268 @@
+adapter = new ContainerAdapter(new FoundationContainerAdapter(new DI52Container()));
+
+ $this->adapter->bind(FoundationContainer::class, $this->adapter);
+ $this->adapter->bind(WPContainerContract::class, $this->adapter);
+ }
+
+ /**
+ * Build the "registered" action name the adapter fires for a provider or alias.
+ */
+ private function registered_action(string $identifier): string {
+ return 'nexcess/foundation/container/wp/' . $identifier . '/registered';
+ }
+
+ private function adapter_with_prefix(string $prefix): ContainerAdapter {
+ $adapter = new ContainerAdapter(new FoundationContainerAdapter(new DI52Container()), $prefix);
+
+ $adapter->bind(FoundationContainer::class, $adapter);
+ $adapter->bind(WPContainerContract::class, $adapter);
+
+ return $adapter;
+ }
+
+ /**
+ * Count how many times a WordPress action fires while a callback is attached.
+ */
+ private function count_action(string $action): callable {
+ $original = did_action($action);
+
+ return static fn (): int => did_action($action) - $original;
+ }
+
+ public function test_register_fires_a_registered_action_for_the_provider(): void {
+ $fired = $this->count_action($this->registered_action(FirstProvider::class));
+
+ $this->adapter->register(FirstProvider::class);
+
+ $this->assertSame(1, $fired());
+
+ $this->adapter->register(FirstProvider::class);
+
+ $this->assertSame(2, $fired());
+ }
+
+ public function test_register_fires_a_registered_action_for_each_alias(): void {
+ $provider = $this->count_action($this->registered_action(FirstProvider::class));
+ $alpha = $this->count_action($this->registered_action('alpha'));
+ $beta = $this->count_action($this->registered_action('beta'));
+
+ $this->adapter->register(FirstProvider::class, 'alpha', 'beta');
+
+ $this->assertSame(1, $provider());
+ $this->assertSame(1, $alpha());
+ $this->assertSame(1, $beta());
+
+ $this->adapter->register(FirstProvider::class, 'alpha', 'beta');
+
+ $this->assertSame(2, $provider());
+ $this->assertSame(2, $alpha());
+ $this->assertSame(2, $beta());
+ }
+
+ public function test_register_normalizes_custom_prefixes_to_one_trailing_slash(): void {
+ foreach (['custom/foundation/container', 'custom/foundation/container/', 'custom/foundation/container///'] as $prefix) {
+ $adapter = $this->adapter_with_prefix($prefix);
+ $fired = $this->count_action('custom/foundation/container/' . FirstProvider::class . '/registered');
+
+ $adapter->register(FirstProvider::class);
+
+ $this->assertSame(1, $fired());
+ }
+ }
+
+ public function test_register_allows_an_empty_custom_prefix(): void {
+ $adapter = $this->adapter_with_prefix('');
+ $fired = $this->count_action(FirstProvider::class . '/registered');
+
+ $adapter->register(FirstProvider::class);
+
+ $this->assertSame(1, $fired());
+ }
+
+ public function test_register_validates_aliases_before_registering_the_provider(): void {
+ $fired = $this->count_action($this->registered_action(FirstProvider::class));
+
+ try {
+ $this->adapter->register(FirstProvider::class, '');
+
+ $this->fail('Expected an invalid alias to throw before provider registration.');
+ } catch (InvalidArgumentException) {
+ $this->assertSame(0, $fired());
+ }
+ }
+
+ public function test_register_passes_the_provider_class_and_aliases_to_listeners(): void {
+ $received = [];
+
+ add_action(
+ $this->registered_action(FirstProvider::class),
+ static function ($serviceProviderClass = null, $alias = null) use (&$received): void {
+ $received = ['provider' => $serviceProviderClass, 'alias' => $alias];
+ },
+ 10,
+ 2
+ );
+
+ $this->adapter->register(FirstProvider::class, 'alpha', 'beta');
+
+ $this->assertSame(FirstProvider::class, $received['provider']);
+ $this->assertSame(['alpha', 'beta'], $received['alias']);
+ }
+
+ public function test_register_passes_the_provider_class_and_aliases_to_alias_listeners(): void {
+ $received = [];
+
+ add_action(
+ $this->registered_action('alpha'),
+ static function ($serviceProviderClass = null, $alias = null) use (&$received): void {
+ $received = ['provider' => $serviceProviderClass, 'alias' => $alias];
+ },
+ 10,
+ 2
+ );
+
+ $this->adapter->register(FirstProvider::class, 'alpha', 'beta');
+
+ $this->assertSame(FirstProvider::class, $received['provider']);
+ $this->assertSame(['alpha', 'beta'], $received['alias']);
+ }
+
+ public function test_register_on_action_registers_immediately_when_the_action_already_fired(): void {
+ $fired = $this->count_action($this->registered_action(FirstProvider::class));
+
+ do_action('foundation_boot_done');
+
+ $this->assertSame(0, $fired());
+
+ $this->adapter->registerOnAction('foundation_boot_done', FirstProvider::class);
+
+ $this->assertSame(1, $fired());
+ }
+
+ public function test_register_on_action_defers_registration_until_the_action_fires(): void {
+ $fired = $this->count_action($this->registered_action(FirstProvider::class));
+
+ $this->adapter->registerOnAction('foundation_boot_pending', FirstProvider::class);
+ $this->assertSame(0, $fired());
+
+ do_action('foundation_boot_pending');
+ $this->assertSame(1, $fired());
+ }
+
+ public function test_register_on_action_only_registers_once_even_if_the_action_fires_again(): void {
+ $fired = $this->count_action($this->registered_action(FirstProvider::class));
+
+ $this->adapter->registerOnAction('foundation_boot_repeat', FirstProvider::class);
+
+ do_action('foundation_boot_repeat');
+
+ $this->assertSame(1, $fired());
+ do_action('foundation_boot_repeat');
+
+ $this->assertSame(1, $fired());
+ }
+
+ public function test_register_after_provider_registers_the_dependent_once_the_base_registers(): void {
+ $base = $this->count_action($this->registered_action(FirstProvider::class));
+ $dependent = $this->count_action($this->registered_action(SecondProvider::class));
+
+ $this->adapter->registerOnProvider(FirstProvider::class, SecondProvider::class);
+ $this->assertSame(0, $base());
+ $this->assertSame(0, $dependent());
+
+ $this->adapter->register(FirstProvider::class);
+
+ $this->assertSame(1, $base());
+ $this->assertSame(1, $dependent());
+ }
+
+ public function test_register_after_all_actions_registers_immediately_when_all_actions_are_done(): void {
+ $fired = $this->count_action($this->registered_action(FirstProvider::class));
+
+ do_action('foundation_all_first');
+ do_action('foundation_all_second');
+
+ $this->adapter->registerAfterAllActions(
+ ['foundation_all_first', 'foundation_all_second'],
+ FirstProvider::class
+ );
+
+ $this->assertSame(1, $fired());
+
+ do_action('foundation_all_first');
+ do_action('foundation_all_second');
+
+ $this->assertSame(1, $fired());
+ }
+
+ public function test_register_after_all_actions_defers_until_the_pending_action_fires(): void {
+ $fired = $this->count_action($this->registered_action(FirstProvider::class));
+
+ $this->adapter->registerAfterAllActions(['foundation_all_pending'], FirstProvider::class);
+ $this->assertSame(0, $fired());
+
+ do_action('foundation_all_pending');
+ $this->assertSame(1, $fired());
+ }
+
+ public function test_register_after_all_actions_waits_for_every_action_before_registering(): void {
+ $fired = $this->count_action($this->registered_action(FirstProvider::class));
+
+ $this->adapter->registerAfterAllActions(
+ ['foundation_multi_first', 'foundation_multi_second'],
+ FirstProvider::class
+ );
+ $this->assertSame(0, $fired());
+
+ do_action('foundation_multi_first');
+ $this->assertSame(0, $fired());
+
+ do_action('foundation_multi_second');
+ $this->assertSame(1, $fired());
+
+ do_action('foundation_multi_first');
+ $this->assertSame(1, $fired());
+
+ do_action('foundation_multi_second');
+ $this->assertSame(1, $fired());
+ }
+
+ public function test_register_after_all_actions_registers_once_when_actions_fire_repeatedly(): void {
+ $fired = $this->count_action($this->registered_action(FirstProvider::class));
+
+ $this->adapter->registerAfterAllActions(
+ ['foundation_repeat_first', 'foundation_repeat_second'],
+ FirstProvider::class
+ );
+
+ do_action('foundation_repeat_first');
+ do_action('foundation_repeat_second');
+ $this->assertSame(1, $fired());
+
+ // Firing the same actions again must not register the provider a second time.
+ do_action('foundation_repeat_first');
+ do_action('foundation_repeat_second');
+ $this->assertSame(1, $fired());
+ }
+}