diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1fcdc..aefd94b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,18 @@ All notable changes to the AlfacodeTeam PhpServicePlatform (Sentinel) kernel are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.17.1] - 2026-09-25 + +### Fixed +- **An optional constructor dependency now receives its default when nothing + binds it.** A parameter written `?SomeContract $x = null` threw + `EntryNotFoundException` from `ModuleContainer` whenever the module providing + `SomeContract` was not in the request's dependency graph. bind-it falls back + to a parameter's default only on its own `BindingResolutionException`, and + `ModuleContainer` signals "unbound" with `EntryNotFoundException`, so the + fallback never fired. `ModuleContainer::resolveClass()` now applies the same + fallback to its own exception. A parameter with no default still fails + loudly, unchanged. ## [1.17.0] - 2026-09-17 diff --git a/src/Kernel/Container/ModuleContainer.php b/src/Kernel/Container/ModuleContainer.php index 63260e5..20f028d 100644 --- a/src/Kernel/Container/ModuleContainer.php +++ b/src/Kernel/Container/ModuleContainer.php @@ -161,6 +161,34 @@ public function makeInScope(string $abstract, string $scope): mixed } } + /** + * Honour a constructor parameter's DEFAULT when its type is unbound here. + * + * bind-it falls back to `$parameter->getDefaultValue()` only when resolution + * throws its own BindingResolutionException. This container signals + * "nothing bound" with EntryNotFoundException instead, which bind-it does + * not catch — so `?SomeContract $x = null`, the idiomatic way to declare an + * optional collaborator, threw rather than receiving null whenever the + * module providing the contract was not in this request's graph. + * + * A parameter with no default still throws, unchanged: a required + * dependency that is missing must fail loudly. No `with` stack to unwind — + * the EntryNotFoundException is raised in make() before bind-it pushes one. + */ + protected function resolveClass(\ReflectionParameter $parameter): mixed + { + try { + return parent::resolveClass($parameter); + /** @phpstan-ignore-next-line thrown by this container's make(), which bind-it calls polymorphically and does not declare */ + } catch (EntryNotFoundException $e) { + if ($parameter->isDefaultValueAvailable() && !$parameter->isVariadic()) { + return $parameter->getDefaultValue(); + } + + throw $e; + } + } + public function has(string $id): bool { $resolved = $this->getAlias($id); diff --git a/tests/Unit/Kernel/Container/ModuleContainerOptionalDependencyTest.php b/tests/Unit/Kernel/Container/ModuleContainerOptionalDependencyTest.php new file mode 100644 index 0000000..a5fae0e --- /dev/null +++ b/tests/Unit/Kernel/Container/ModuleContainerOptionalDependencyTest.php @@ -0,0 +1,64 @@ +make(TakesAnOptionalCollaborator::class); + + self::assertNull($made->collaborator); + } + + public function test_a_bound_optional_dependency_is_still_injected(): void + { + $container = new ModuleContainer(new CoreContainer()); + $container->bind(OptionalCollaborator::class, static fn (): OptionalCollaborator => new class implements OptionalCollaborator {}); + + self::assertInstanceOf(OptionalCollaborator::class, $container->make(TakesAnOptionalCollaborator::class)->collaborator); + } + + public function test_a_required_dependency_still_fails_loudly(): void + { + $this->expectException(EntryNotFoundException::class); + + (new ModuleContainer(new CoreContainer()))->make(NeedsACollaborator::class); + } +}