diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..142b914
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,67 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ tests:
+ name: PHP ${{ matrix.php }} - ${{ matrix.dependencies }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.1', '8.2', '8.3', '8.4']
+ dependencies: ['lowest', 'highest']
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mbstring
+ coverage: none
+ tools: composer:v2
+
+ - name: Validate composer.json
+ run: composer validate --strict
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+ with:
+ dependency-versions: ${{ matrix.dependencies }}
+
+ - name: Run PHPUnit
+ run: vendor/bin/phpunit
+
+ static-analysis:
+ name: Static analysis & code style
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.1'
+ coverage: none
+ tools: composer:v2
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+
+ - name: Run PHPStan
+ run: vendor/bin/phpstan analyse --no-progress
+
+ - name: Check code style
+ run: vendor/bin/php-cs-fixer fix --dry-run --diff
diff --git a/.gitignore b/.gitignore
index 0abe7ad..23e410c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,8 @@
/.vscode/
/.vs/
/vendor/
-/composer.lock
\ No newline at end of file
+/composer.lock
+/.phpunit.cache/
+/.phpunit.result.cache
+/.php-cs-fixer.cache
+/.phpstan.cache/
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..0d9e235
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,19 @@
+in([__DIR__ . '/src', __DIR__ . '/tests']);
+
+return (new PhpCsFixer\Config())
+ ->setRiskyAllowed(true)
+ ->setRules([
+ '@PSR12' => true,
+ 'declare_strict_types' => true,
+ 'array_syntax' => ['syntax' => 'short'],
+ 'no_unused_imports' => true,
+ 'ordered_imports' => ['sort_algorithm' => 'alpha', 'imports_order' => ['class', 'function', 'const']],
+ 'single_quote' => true,
+ 'trailing_comma_in_multiline' => true,
+ ])
+ ->setFinder($finder);
diff --git a/README.md b/README.md
index cef0cce..b11a612 100644
--- a/README.md
+++ b/README.md
@@ -1,74 +1,178 @@
-# InitPHP Dependencies Container
+# InitPHP Container
-Simple Dependencies Container following PSR-11 standards.
+Minimal [PSR-11](https://www.php-fig.org/psr/psr-11/) dependency injection container with reflection-based autowiring.
-_Note :_ This is a pre-release version of the library currently available. Report potential bugs and feature requests to the issue section of this repo.
+[](https://packagist.org/packages/initphp/container)
+[](https://packagist.org/packages/initphp/container)
+[](https://packagist.org/packages/initphp/container)
+[](https://packagist.org/packages/initphp/container)
+
+The container resolves entries lazily. You can register values, factories and class bindings explicitly, or let the container autowire a class straight from its name by reading its constructor signature through reflection. Every resolved entry is cached, so the container behaves as a shared (singleton) registry.
## Requirements
-- PHP 7.4 or higher
-- [PSR-11 Container Interface Package](https://packagist.org/packages/psr/container) 2.0.2
+- PHP 8.1 or higher
+- [`psr/container`](https://packagist.org/packages/psr/container) `^2.0`
## Installation
-```
-composer require initphp/container:dev-main
+```bash
+composer require initphp/container
```
-## Usage
-
-Check the `Example` directory for an example usage.
+## Quick Start
```php
-require_once "vendor/autoload.php";
+require_once 'vendor/autoload.php';
+
use InitPHP\Container\Container;
-class UserModel
+class Mailer
{
- private string $name;
+}
- public function set(string $name)
- {
- $this->name = $name;
- }
-
- public function get()
+class UserService
+{
+ public function __construct(public Mailer $mailer)
{
- return $this->name ?? null;
}
}
-class User
+$container = new Container();
+
+// No registration needed: the container reads UserService's constructor,
+// builds the Mailer dependency automatically and injects it.
+$service = $container->get(UserService::class);
+
+var_dump($service instanceof UserService); // bool(true)
+var_dump($service->mailer instanceof Mailer); // bool(true)
+```
+
+## Usage
+
+### Autowiring
+
+When you call `get()` with an existing class name, the container instantiates it and recursively resolves every class-typed constructor argument:
+
+```php
+$service = $container->get(UserService::class);
+```
+
+The resolved instance is cached. Asking for the same identifier again returns the exact same object:
+
+```php
+$container->get(Mailer::class) === $container->get(Mailer::class); // true
+```
+
+### Storing values
+
+`set()` accepts any value. Scalars, arrays and objects are returned as they were stored:
+
+```php
+$container->set('app.name', 'InitPHP');
+$container->set('config', ['debug' => true]);
+$container->set('logger', new FileLogger('/var/log/app.log'));
+
+$container->get('app.name'); // 'InitPHP'
+$container->get('config'); // ['debug' => true]
+$container->get('logger'); // the same FileLogger instance
+```
+
+### Factories (closures)
+
+Register a `Closure` to build an entry lazily. The closure receives the container and runs only once, the first time the entry is requested:
+
+```php
+use Psr\Container\ContainerInterface;
+
+$container->set('pdo', function (ContainerInterface $c) {
+ return new PDO('sqlite::memory:');
+});
+
+$pdo = $container->get('pdo'); // closure runs here, result is cached
+```
+
+### Binding interfaces to implementations
+
+Bind an interface (or any identifier) to a concrete class name so that both direct lookups and autowired dependencies resolve to the implementation:
+
+```php
+interface LoggerInterface
{
- private $model;
+}
- public function __construct(UserModel $model)
- {
- $this->model = $model;
- }
+class FileLogger implements LoggerInterface
+{
+}
- public function getModel()
+class Report
+{
+ public function __construct(public LoggerInterface $logger)
{
- return $this->model;
}
}
-$container = new Container();
-$user = $container->get(\Example\User::class);
-$model = $user->getModel();
-$model->set('Muhammet');
-echo $user->getModel()->get();
+$container->set(LoggerInterface::class, FileLogger::class);
+
+$container->get(LoggerInterface::class); // FileLogger instance
+$container->get(Report::class)->logger; // the same FileLogger instance
```
-## Contributing
+### Checking for an entry
+
+`has()` returns `true` when `get()` would not throw a `NotFoundException` — that is, the identifier is a registered entry or an existing class name:
+
+```php
+$container->has('app.name'); // true after set()
+$container->has(UserService::class); // true (autowirable class)
+$container->has('missing'); // false
+```
+
+## How resolution works
+
+`get($id)` resolves in this order:
+
+1. Return the cached instance if `$id` was resolved before.
+2. If `$id` was registered with `set()`, build it (invoke the closure, instantiate the class name, or return the stored value) and cache it.
+3. If `$id` is an existing class name, autowire it and cache it.
+4. Otherwise throw `NotFoundException`.
+
+Constructor parameters are resolved as follows: a class-typed parameter is fetched from the container; otherwise its default value is used; otherwise `null` is supplied for nullable parameters. If none of these apply, resolution fails.
+
+## Exceptions
+
+All exceptions live in `InitPHP\Container\Exception` and implement the relevant PSR-11 interface.
+
+| Exception | Implements | Thrown when |
+| --- | --- | --- |
+| `NotFoundException` | `Psr\Container\NotFoundExceptionInterface` | The identifier is neither registered nor an existing class. |
+| `DependencyIsNotInstantiableException` | `Psr\Container\ContainerExceptionInterface` | The target is an interface, abstract class, or has a non-public constructor. |
+| `DependencyHasNoDefaultValueException` | `Psr\Container\ContainerExceptionInterface` | A constructor parameter cannot be autowired and has no default or nullable fallback. |
+| `CircularDependencyException` | `Psr\Container\ContainerExceptionInterface` | A class depends on itself directly or through a cycle. |
-> All contributions to this project will be published under the MIT License. By submitting a pull request or filing a bug, issue, or feature request, you are agreeing to comply with this waiver of copyright interest.
+`ContainerException` is the base class for `NotFoundException` and the three resolution exceptions, so you can catch every container error with a single `catch (ContainerException $e)`.
+
+## Documentation
+
+In-depth guides with runnable examples live in the [`docs/`](./docs) directory:
+
+- [Getting Started](./docs/getting-started.md)
+- [Autowiring](./docs/autowiring.md)
+- [Binding & Factories](./docs/binding-and-factories.md)
+- [Exceptions & Error Handling](./docs/exceptions.md)
+- [Limitations](./docs/limitations.md)
+
+## Testing
+
+```bash
+composer test # PHPUnit
+composer stan # PHPStan (max level)
+composer cs-check # PHP-CS-Fixer (dry run)
+```
+
+## Contributing
-1. Fork it ( https://github.com/initphp/container/fork )
-2. Create your feature branch (git checkout -b my-new-feature)
-3. Commit your changes (git commit -am "Add some feature")
-4. Push to the branch (git push origin my-new-feature)
-5. Create a new Pull Request
+Contributions are welcome. Please read the org-wide [CONTRIBUTING guide](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md) before opening a pull request.
## Credits
@@ -76,4 +180,4 @@ echo $user->getModel()->get();
## License
-Copyright © 2022 [MIT License](./LICENSE)
+Released under the [MIT License](./LICENSE).
diff --git a/composer.json b/composer.json
index 794fca5..04cd9eb 100644
--- a/composer.json
+++ b/composer.json
@@ -1,13 +1,16 @@
{
"name": "initphp/container",
- "description": "Simple Dependencies Container following PSR-11 standards",
+ "description": "Minimal PSR-11 dependency injection container with reflection-based autowiring.",
"type": "library",
+ "keywords": [
+ "container",
+ "dependency-injection",
+ "di",
+ "psr-11",
+ "autowiring",
+ "ioc"
+ ],
"license": "MIT",
- "autoload": {
- "psr-4": {
- "InitPHP\\Container\\": "src/"
- }
- },
"authors": [
{
"name": "Muhammet ŞAFAK",
@@ -16,9 +19,40 @@
"homepage": "https://www.muhammetsafak.com.tr"
}
],
- "minimum-stability": "stable",
"require": {
- "php": ">=7.4",
- "psr/container": "2.0.2"
+ "php": "^8.1",
+ "psr/container": "^2.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5",
+ "phpstan/phpstan": "^2.0",
+ "friendsofphp/php-cs-fixer": "^3.64"
+ },
+ "provide": {
+ "psr/container-implementation": "2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "InitPHP\\Container\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "InitPHP\\Container\\Tests\\": "tests/"
+ },
+ "files": [
+ "tests/Fixtures/fixtures.php"
+ ]
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true,
+ "config": {
+ "sort-packages": true
+ },
+ "scripts": {
+ "test": "phpunit",
+ "stan": "phpstan analyse",
+ "cs-check": "php-cs-fixer fix --dry-run --diff",
+ "cs-fix": "php-cs-fixer fix"
}
}
diff --git a/docs/autowiring.md b/docs/autowiring.md
new file mode 100644
index 0000000..cc2c053
--- /dev/null
+++ b/docs/autowiring.md
@@ -0,0 +1,147 @@
+# Autowiring
+
+Autowiring is the container's ability to build a class without an explicit registration by inspecting its constructor and resolving each dependency for you.
+
+## Resolving a class by name
+
+Pass any existing class name to `get()`:
+
+```php
+use InitPHP\Container\Container;
+
+class Engine
+{
+}
+
+class Car
+{
+ public function __construct(public Engine $engine)
+ {
+ }
+}
+
+$container = new Container();
+$car = $container->get(Car::class);
+
+$car instanceof Car; // true
+$car->engine instanceof Engine; // true
+```
+
+The container reads `Car::__construct()`, sees it needs an `Engine`, resolves `Engine` (which has no dependencies of its own) and injects it.
+
+## Recursive resolution
+
+Dependencies are resolved recursively to any depth:
+
+```php
+class Db
+{
+}
+
+class Repository
+{
+ public function __construct(public Db $db)
+ {
+ }
+}
+
+class Controller
+{
+ public function __construct(public Repository $repository)
+ {
+ }
+}
+
+$controller = $container->get(Controller::class);
+$controller->repository->db instanceof Db; // true
+```
+
+Because every entry is cached, a dependency shared by several classes is the same instance everywhere:
+
+```php
+$repository = $container->get(Repository::class);
+$controller = $container->get(Controller::class);
+
+$controller->repository === $repository; // true
+```
+
+## How each constructor parameter is resolved
+
+For every constructor parameter, the container applies these rules in order:
+
+1. **Class-typed parameter** (a single, non-builtin type): the container resolves it through `get()`.
+2. **Default value available**: the parameter's default value is used.
+3. **Nullable parameter**: `null` is injected.
+4. **None of the above**: a `DependencyHasNoDefaultValueException` is thrown.
+
+### Examples
+
+```php
+class Service
+{
+ public function __construct(
+ public Engine $engine, // rule 1: autowired
+ public string $name = 'svc', // rule 2: uses 'svc'
+ public ?Engine $spare = null, // rule 1 if resolvable, else rule 3
+ ) {
+ }
+}
+
+$service = $container->get(Service::class);
+$service->engine instanceof Engine; // true
+$service->name; // 'svc'
+$service->spare instanceof Engine; // true (Engine is resolvable)
+```
+
+A scalar parameter without a default cannot be guessed and fails:
+
+```php
+class NeedsString
+{
+ public function __construct(public string $value)
+ {
+ }
+}
+
+$container->get(NeedsString::class); // throws DependencyHasNoDefaultValueException
+```
+
+Register the value or provide a factory instead — see [Binding & Factories](./binding-and-factories.md).
+
+## Union and intersection types
+
+A union or intersection typed parameter is not autowired (the container cannot decide which type to build). It falls back to the default value or `null`:
+
+```php
+class WithUnion
+{
+ public function __construct(public int|string $value = 1)
+ {
+ }
+}
+
+$container->get(WithUnion::class)->value; // 1
+```
+
+If such a parameter has no default and is not nullable, a `DependencyHasNoDefaultValueException` is thrown.
+
+## Classes without a constructor
+
+A class with no constructor is simply instantiated:
+
+```php
+class Plain
+{
+}
+
+$container->get(Plain::class) instanceof Plain; // true
+```
+
+## What cannot be autowired
+
+- Interfaces — not instantiable, and `class_exists()` does not report them, so the container treats an unbound interface as unknown. Requesting one directly throws `NotFoundException`; requiring one as an unbound, non-nullable dependency throws `DependencyHasNoDefaultValueException`. Bind it to a concrete class first (see [Binding & Factories](./binding-and-factories.md)).
+- Abstract classes — `class_exists()` reports them, so the container tries to build one and fails with `DependencyIsNotInstantiableException`, both when requested directly and as a dependency.
+- Classes with a non-public constructor — they throw `DependencyIsNotInstantiableException`.
+- Circular dependencies — they throw `CircularDependencyException`.
+
+See [Exceptions & Error Handling](./exceptions.md) for details.
diff --git a/docs/binding-and-factories.md b/docs/binding-and-factories.md
new file mode 100644
index 0000000..c40871b
--- /dev/null
+++ b/docs/binding-and-factories.md
@@ -0,0 +1,122 @@
+# Binding & Factories
+
+Autowiring covers concrete classes, but real applications also need to register values, bind interfaces to implementations and build entries that need configuration. That is what `set()` is for.
+
+```php
+public function set(string $id, mixed $concrete = null): void;
+```
+
+The container interprets the `$concrete` definition lazily when the entry is first requested:
+
+| `$concrete` is… | Behaviour on `get()` |
+| --- | --- |
+| `null` (omitted) | The identifier is used as the definition. |
+| a `Closure` | The closure is invoked with the container; its return value is cached. |
+| an existing class name (string) | The class is autowired. |
+| any other value (object, scalar, array, non-class string) | The value is returned as is. |
+
+## Storing values
+
+```php
+use InitPHP\Container\Container;
+
+$container = new Container();
+
+$container->set('app.name', 'InitPHP');
+$container->set('app.debug', true);
+$container->set('app.paths', ['cache' => '/tmp/cache']);
+
+$container->get('app.name'); // 'InitPHP'
+$container->get('app.debug'); // true
+$container->get('app.paths'); // ['cache' => '/tmp/cache']
+```
+
+Objects are stored and returned unchanged:
+
+```php
+$logger = new FileLogger('/var/log/app.log');
+$container->set('logger', $logger);
+
+$container->get('logger') === $logger; // true
+```
+
+## Binding a class name
+
+Register a class so it is built on first use. Passing only the identifier uses it as its own definition:
+
+```php
+$container->set(App\Service::class);
+$container->get(App\Service::class); // autowired App\Service instance
+```
+
+You rarely need to do this for plain classes — autowiring already handles them. It matters when you want to alias one identifier to another class.
+
+## Binding interfaces to implementations
+
+Type-hinting an interface is the idiomatic way to depend on abstractions. Bind the interface to a concrete class so both direct lookups and autowired dependencies resolve to it:
+
+```php
+interface CacheInterface
+{
+}
+
+class RedisCache implements CacheInterface
+{
+}
+
+class PageRenderer
+{
+ public function __construct(public CacheInterface $cache)
+ {
+ }
+}
+
+$container->set(CacheInterface::class, RedisCache::class);
+
+$container->get(CacheInterface::class); // RedisCache instance
+$container->get(PageRenderer::class)->cache; // the same RedisCache instance
+```
+
+Without the binding, `get(PageRenderer::class)` would fail because the container cannot build an interface on its own.
+
+## Factories (closures)
+
+When an entry needs constructor arguments the container cannot guess — a DSN, an API key, a file path — register a closure. It receives the container and runs lazily, only on the first `get()`:
+
+```php
+use Psr\Container\ContainerInterface;
+
+$container->set('pdo', function (ContainerInterface $c) {
+ return new PDO('mysql:host=localhost;dbname=app', 'user', 'secret');
+});
+
+$pdo = $container->get('pdo'); // closure runs here
+$container->get('pdo') === $pdo; // true — the result is cached
+```
+
+The container is passed in so a factory can pull other entries:
+
+```php
+$container->set('config', ['dsn' => 'sqlite::memory:']);
+
+$container->set('pdo', function (ContainerInterface $c) {
+ $config = $c->get('config');
+ return new PDO($config['dsn']);
+});
+```
+
+### Factories run once
+
+The closure's return value is cached just like any other entry. If you need it to run on every call, that is outside this container's model — build the object directly where you need it.
+
+## Re-registering an entry
+
+Calling `set()` again replaces the definition and discards the cached instance, so the next `get()` rebuilds it:
+
+```php
+$container->set('mode', 'production');
+$container->get('mode'); // 'production'
+
+$container->set('mode', 'testing');
+$container->get('mode'); // 'testing'
+```
diff --git a/docs/exceptions.md b/docs/exceptions.md
new file mode 100644
index 0000000..4993f35
--- /dev/null
+++ b/docs/exceptions.md
@@ -0,0 +1,144 @@
+# Exceptions & Error Handling
+
+All container exceptions live in the `InitPHP\Container\Exception` namespace and implement a PSR-11 interface, so you can also catch them by the standard `Psr\Container\ContainerExceptionInterface` / `NotFoundExceptionInterface` contracts.
+
+## Hierarchy
+
+```
+Psr\Container\ContainerExceptionInterface
+└── InitPHP\Container\Exception\ContainerException
+ ├── NotFoundException (also implements NotFoundExceptionInterface)
+ ├── DependencyIsNotInstantiableException
+ ├── DependencyHasNoDefaultValueException
+ └── CircularDependencyException
+```
+
+`ContainerException` is the common base, so a single catch handles every container error:
+
+```php
+use InitPHP\Container\Exception\ContainerException;
+
+try {
+ $service = $container->get(App\Service::class);
+} catch (ContainerException $e) {
+ // any container failure
+}
+```
+
+To distinguish a missing entry specifically, catch `NotFoundException` (or the PSR interface) first.
+
+## `NotFoundException`
+
+Thrown by `get()` when the identifier is neither a registered entry nor an existing class name. Implements `Psr\Container\NotFoundExceptionInterface`.
+
+```php
+use InitPHP\Container\Exception\NotFoundException;
+
+try {
+ $container->get('does-not-exist');
+} catch (NotFoundException $e) {
+ echo $e->getMessage(); // No entry was found for identifier "does-not-exist".
+}
+```
+
+This is also thrown when you request an unbound interface directly, because `class_exists()` does not report interfaces:
+
+```php
+interface PaymentGatewayInterface
+{
+}
+
+$container->get(PaymentGatewayInterface::class); // NotFoundException
+```
+
+## `DependencyIsNotInstantiableException`
+
+Thrown when the container reaches a class it cannot instantiate: an abstract class or a class with a non-public constructor.
+
+```php
+use InitPHP\Container\Exception\DependencyIsNotInstantiableException;
+
+abstract class AbstractRepository
+{
+}
+
+try {
+ $container->get(AbstractRepository::class);
+} catch (DependencyIsNotInstantiableException $e) {
+ echo $e->getMessage(); // Class "AbstractRepository" is not instantiable.
+}
+```
+
+## `DependencyHasNoDefaultValueException`
+
+Thrown when a constructor parameter cannot be autowired and has neither a default value nor a nullable type to fall back on. Typical cases: a scalar without a default, or an unbound interface dependency.
+
+```php
+use InitPHP\Container\Exception\DependencyHasNoDefaultValueException;
+
+class Connection
+{
+ public function __construct(public string $dsn)
+ {
+ }
+}
+
+try {
+ $container->get(Connection::class);
+} catch (DependencyHasNoDefaultValueException $e) {
+ echo $e->getMessage(); // Unable to resolve the value of parameter "$dsn".
+}
+```
+
+The fix is to give the container what it needs — register the value, provide a default, or bind the interface:
+
+```php
+$container->set('connection', fn () => new Connection('sqlite::memory:'));
+```
+
+## `CircularDependencyException`
+
+Thrown when a class depends on itself, directly or through a chain, which would otherwise cause unbounded recursion.
+
+```php
+use InitPHP\Container\Exception\CircularDependencyException;
+
+class A
+{
+ public function __construct(public B $b)
+ {
+ }
+}
+
+class B
+{
+ public function __construct(public A $a)
+ {
+ }
+}
+
+try {
+ $container->get(A::class);
+} catch (CircularDependencyException $e) {
+ echo $e->getMessage(); // Circular dependency detected while resolving "...".
+}
+```
+
+Break the cycle by introducing an interface and a factory, or by injecting one of the classes lazily after construction.
+
+## Catching by PSR interface
+
+Because the exceptions implement the PSR-11 interfaces, code that depends only on `psr/container` can catch them without referencing InitPHP types:
+
+```php
+use Psr\Container\ContainerExceptionInterface;
+use Psr\Container\NotFoundExceptionInterface;
+
+try {
+ $container->get($id);
+} catch (NotFoundExceptionInterface $e) {
+ // unknown identifier
+} catch (ContainerExceptionInterface $e) {
+ // any other resolution error
+}
+```
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..f82632f
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,98 @@
+# Getting Started
+
+InitPHP Container is a small [PSR-11](https://www.php-fig.org/psr/psr-11/) container. It stores entries you register and can also build classes for you automatically through reflection. This guide covers installation and the two methods that make up the public API: `get()` and `has()`, plus the `set()` registration method.
+
+## Installation
+
+```bash
+composer require initphp/container
+```
+
+Requires PHP 8.1+ and `psr/container ^2.0` (pulled in automatically).
+
+## Creating a container
+
+```php
+require_once 'vendor/autoload.php';
+
+use InitPHP\Container\Container;
+
+$container = new Container();
+```
+
+The container takes no constructor arguments and holds no global state. Create as many as you need.
+
+## The public API
+
+The container implements `Psr\Container\ContainerInterface`, which defines two methods:
+
+```php
+public function get(string $id): mixed;
+public function has(string $id): bool;
+```
+
+On top of those, the container adds one registration method:
+
+```php
+public function set(string $id, mixed $concrete = null): void;
+```
+
+### `set()` — register an entry
+
+```php
+// Store a value.
+$container->set('app.name', 'InitPHP');
+
+// Store an object.
+$container->set('clock', new DateTimeImmutable());
+
+// Register a class to be built on demand.
+$container->set(App\Service::class);
+
+// Bind an identifier to a class name.
+$container->set(App\LoggerInterface::class, App\FileLogger::class);
+
+// Register a lazy factory.
+$container->set('pdo', fn () => new PDO('sqlite::memory:'));
+```
+
+When the second argument is omitted, the identifier itself becomes the definition. Calling `set()` again with the same identifier replaces the previous definition and clears any cached instance.
+
+### `get()` — retrieve an entry
+
+```php
+$name = $container->get('app.name'); // 'InitPHP'
+$service = $container->get(App\Service::class);
+```
+
+If the identifier was never registered but is an existing class name, the container autowires it. If it is neither, a `NotFoundException` is thrown. See [Autowiring](./autowiring.md).
+
+### `has()` — check for an entry
+
+```php
+$container->has('app.name'); // true once registered
+$container->has(App\Service::class); // true: it is an autowirable class
+$container->has('not-registered'); // false
+```
+
+`has()` returns `true` whenever `get()` would not throw a `NotFoundException`.
+
+## Entries are cached
+
+Every entry the container resolves is cached and reused:
+
+```php
+$a = $container->get(App\Service::class);
+$b = $container->get(App\Service::class);
+
+$a === $b; // true
+```
+
+This makes the container behave as a registry of shared instances. If you need a fresh object every time, build it yourself inside a closure and create the instance there explicitly, or instantiate the class directly without the container.
+
+## Next steps
+
+- [Autowiring](./autowiring.md) — how constructor dependencies are resolved.
+- [Binding & Factories](./binding-and-factories.md) — interfaces, closures and values.
+- [Exceptions & Error Handling](./exceptions.md) — what can go wrong and why.
+- [Limitations](./limitations.md) — what this container intentionally does not do.
diff --git a/docs/limitations.md b/docs/limitations.md
new file mode 100644
index 0000000..64f688b
--- /dev/null
+++ b/docs/limitations.md
@@ -0,0 +1,63 @@
+# Limitations
+
+This container is deliberately minimal. Knowing what it does *not* do helps you decide when it is the right tool and when you should reach for a fuller-featured container.
+
+## Shared instances only
+
+Every resolved entry is cached and reused. There is no "transient" or "prototype" scope that returns a fresh object on each call.
+
+```php
+$container->get(Service::class) === $container->get(Service::class); // always true
+```
+
+If you need a new instance every time, build it yourself — either `new Service(...)` directly, or call a factory you keep outside the container.
+
+## No contextual or tagged bindings
+
+You cannot say "inject implementation X here but implementation Y there", and there is no concept of tagging multiple services and retrieving them as a group. A given identifier resolves to exactly one definition.
+
+## Autowiring resolves single class types only
+
+Constructor autowiring works for parameters typed with a single class or interface name. It does **not** pick a type out of a union or intersection:
+
+```php
+public function __construct(Cache|Store $backend) { /* not autowired */ }
+```
+
+Such a parameter falls back to its default value or `null`; otherwise resolution fails. Provide a factory for these cases.
+
+## Scalar dependencies are not guessed
+
+The container never invents scalar values. A constructor that needs a `string`, `int`, etc. without a default must be satisfied through a factory or by storing the value:
+
+```php
+public function __construct(string $apiKey) { /* needs a factory */ }
+```
+
+## No attribute-based configuration
+
+Resolution is driven entirely by constructor type hints and the definitions you register. There is no support for PHP attributes, annotations, or configuration files to influence how a class is built.
+
+## No method or property injection
+
+Only constructor injection is performed. Setter methods and properties are never populated by the container.
+
+## `has()` and autowiring
+
+`has($id)` returns `true` for any existing class name, because such a class is potentially autowirable. It does **not** prove that resolution will succeed — building the class may still fail (for example a constructor needs a scalar). `has()` only guarantees that `get()` will not throw a `NotFoundException`.
+
+```php
+class NeedsApiKey
+{
+ public function __construct(string $apiKey)
+ {
+ }
+}
+
+$container->has(NeedsApiKey::class); // true
+$container->get(NeedsApiKey::class); // throws DependencyHasNoDefaultValueException
+```
+
+## When to use a larger container
+
+If you need scopes, tagging, contextual bindings, lazy proxies, compiled containers, or attribute configuration, consider a full-featured container such as PHP-DI or the Symfony DependencyInjection component. InitPHP Container aims to stay small and predictable for projects that only need PSR-11 lookups and straightforward autowiring.
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..b74b10f
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,5 @@
+parameters:
+ level: max
+ paths:
+ - src
+ treatPhpDocTypesAsCertain: false
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..d107c6b
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/src/Container.php b/src/Container.php
index 9b38c03..da1d837 100644
--- a/src/Container.php
+++ b/src/Container.php
@@ -1,4 +1,5 @@
* @copyright Copyright © 2022 InitPHP Container
- * @license http://initphp.github.io/license.txt MIT
- * @version 0.4
- * @link https://www.muhammetsafak.com.tr
+ * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT
*/
declare(strict_types=1);
namespace InitPHP\Container;
-use \InitPHP\Container\Exception\{DependencyHasNoDefaultValueException, DependencyIsNotInstantiable};
-use \Psr\Container\ContainerInterface;
+use Closure;
+use InitPHP\Container\Exception\CircularDependencyException;
+use InitPHP\Container\Exception\DependencyHasNoDefaultValueException;
+use InitPHP\Container\Exception\DependencyIsNotInstantiableException;
+use InitPHP\Container\Exception\NotFoundException;
+use Psr\Container\ContainerExceptionInterface;
+use Psr\Container\ContainerInterface;
+use ReflectionClass;
+use ReflectionNamedType;
+use ReflectionParameter;
+
+use function array_key_exists;
+use function class_exists;
+use function is_string;
+/**
+ * A minimal PSR-11 container that resolves entries on demand.
+ *
+ * Entries may be registered explicitly with {@see Container::set()} or, when an
+ * identifier maps to an existing class name, resolved automatically through
+ * constructor autowiring. Every entry is built lazily on first access and the
+ * resulting value is cached, so repeated calls to {@see Container::get()} with
+ * the same identifier return the very same instance.
+ */
class Container implements ContainerInterface
{
+ /**
+ * Registered definitions keyed by identifier.
+ *
+ * A definition is whatever was passed to {@see Container::set()}: a class
+ * name, a {@see Closure} factory, an already built object or any scalar.
+ *
+ * @var array
+ */
+ protected array $definitions = [];
- protected array $instance = [];
-
- public function set(string $id, $concrete = null)
- {
- if($concrete === null){
- $concrete = $id;
- }
+ /**
+ * Fully resolved entries keyed by identifier, used as a build cache.
+ *
+ * @var array
+ */
+ protected array $resolved = [];
- if (is_string($concrete) && class_exists($concrete)) {
- $concrete = $this->resolve($concrete);
- }
+ /**
+ * Identifiers currently being resolved, used to detect circular graphs.
+ *
+ * @var array
+ */
+ private array $building = [];
- return $this->instance[$id] = $concrete;
+ /**
+ * Registers an entry on the container.
+ *
+ * The entry is stored as a definition and resolved lazily the first time it
+ * is requested. When `$concrete` is `null` the identifier itself is used as
+ * the definition, which is the common case for autowiring a class by its
+ * own name. Registering an identifier again replaces any previously cached
+ * instance.
+ *
+ * @param string $id Identifier of the entry.
+ * @param mixed $concrete A class name, a Closure factory invoked with the
+ * container, an object, or any value to store as is.
+ * Defaults to `$id` when omitted.
+ * @return void
+ */
+ public function set(string $id, mixed $concrete = null): void
+ {
+ $this->definitions[$id] = $concrete ?? $id;
+ unset($this->resolved[$id]);
}
/**
* @inheritDoc
+ *
+ * @return mixed
+ * @throws NotFoundException When no entry or class matches `$id`.
+ * @throws DependencyIsNotInstantiableException
+ * @throws DependencyHasNoDefaultValueException
+ * @throws CircularDependencyException
*/
- public function get(string $id)
+ public function get(string $id): mixed
{
- if(!$this->has($id)){
- $this->set($id);
+ if (array_key_exists($id, $this->resolved)) {
+ return $this->resolved[$id];
+ }
+
+ if (array_key_exists($id, $this->definitions)) {
+ return $this->resolved[$id] = $this->build($this->definitions[$id]);
}
- return $this->instance[$id];
+ if (class_exists($id)) {
+ return $this->resolved[$id] = $this->resolve($id);
+ }
+
+ throw new NotFoundException('No entry was found for identifier "' . $id . '".');
}
/**
* @inheritDoc
+ *
+ * Returns `true` when {@see Container::get()} would not throw a
+ * {@see NotFoundException} for the given identifier, i.e. the identifier is
+ * a registered entry or an existing, loadable class name. A `true` result
+ * does not guarantee that resolution succeeds; building the entry may still
+ * fail with another container exception.
*/
public function has(string $id): bool
{
- return isset($this->instance[$id]);
+ return array_key_exists($id, $this->resolved)
+ || array_key_exists($id, $this->definitions)
+ || class_exists($id);
+ }
+
+ /**
+ * Turns a registered definition into a concrete value.
+ *
+ * @param mixed $definition
+ * @return mixed
+ * @throws DependencyIsNotInstantiableException
+ * @throws DependencyHasNoDefaultValueException
+ * @throws CircularDependencyException
+ * @throws NotFoundException
+ */
+ private function build(mixed $definition): mixed
+ {
+ if ($definition instanceof Closure) {
+ return $definition($this);
+ }
+
+ if (is_string($definition) && class_exists($definition)) {
+ return $this->resolve($definition);
+ }
+
+ return $definition;
}
/**
- * @param $concrete
- * @return object|null
+ * Instantiates a class, recursively autowiring its constructor arguments.
+ *
+ * @param class-string $concrete
+ * @return object
+ * @throws DependencyIsNotInstantiableException
* @throws DependencyHasNoDefaultValueException
- * @throws DependencyIsNotInstantiable
- * @throws \ReflectionException
+ * @throws CircularDependencyException
+ * @throws NotFoundException
*/
- private function resolve($concrete): ?object
+ private function resolve(string $concrete): object
{
- $reflection = new \ReflectionClass($concrete);
- if(!$reflection->isInstantiable()){
- throw new DependencyIsNotInstantiable('Class "' . $concrete . '" is not instantiable.');
+ if (isset($this->building[$concrete])) {
+ throw new CircularDependencyException(
+ 'Circular dependency detected while resolving "' . $concrete . '".'
+ );
+ }
+
+ $reflection = new ReflectionClass($concrete);
+ if (!$reflection->isInstantiable()) {
+ throw new DependencyIsNotInstantiableException('Class "' . $concrete . '" is not instantiable.');
}
- if(($constructor = $reflection->getConstructor()) === null){
+ $constructor = $reflection->getConstructor();
+ if ($constructor === null) {
return $reflection->newInstance();
}
- $arguments = $constructor->getParameters();
- $dependencies = $this->getDependencies($arguments, $reflection);
+ $this->building[$concrete] = true;
+ try {
+ $dependencies = $this->getDependencies($constructor->getParameters());
+ } finally {
+ unset($this->building[$concrete]);
+ }
+
return $reflection->newInstanceArgs($dependencies);
}
/**
- * @param \ReflectionParameter[] $arguments
- * @param \ReflectionClass $reflection
- * @return array
+ * Resolves the argument list for a constructor.
+ *
+ * Each parameter is resolved in the following order: a class-typed
+ * parameter is autowired from the container; otherwise a default value is
+ * used when available; otherwise `null` is supplied for nullable
+ * parameters. When none of these apply the parameter cannot be resolved.
+ *
+ * @param ReflectionParameter[] $parameters
+ * @return list
* @throws DependencyHasNoDefaultValueException
+ * @throws DependencyIsNotInstantiableException
+ * @throws CircularDependencyException
+ * @throws NotFoundException
*/
- private function getDependencies(array $arguments, \ReflectionClass $reflection): array
+ private function getDependencies(array $parameters): array
{
$dependencies = [];
- foreach ($arguments as $argument) {
- if($argument->isDefaultValueAvailable()){
- $dependencies[] = $argument->getDefaultValue();
- continue;
- }
- if($argument->hasType()){
- if (($type = $argument->getType()) !== null) {
- if(!$type->isBuiltin()){
- $dependencies[] = $this->get($type->getName());
- continue;
- }
- }
- }
- if($argument->allowsNull()){
- $dependencies[] = null;
- continue;
- }
- throw new DependencyHasNoDefaultValueException('Sorry cannot resolve class dependency ' . $argument->name);
+ foreach ($parameters as $parameter) {
+ $dependencies[] = $this->resolveParameter($parameter);
}
+
return $dependencies;
}
+ /**
+ * Resolves a single constructor parameter to a concrete value.
+ *
+ * A class-typed parameter the container knows about is autowired. If that
+ * build fails but the parameter is optional (it has a default value or is
+ * nullable), the optional fallback is used instead of propagating the
+ * error; otherwise the failure is re-thrown.
+ *
+ * @param ReflectionParameter $parameter
+ * @return mixed
+ * @throws DependencyHasNoDefaultValueException
+ * @throws DependencyIsNotInstantiableException
+ * @throws CircularDependencyException
+ * @throws NotFoundException
+ */
+ private function resolveParameter(ReflectionParameter $parameter): mixed
+ {
+ $type = $parameter->getType();
+
+ if ($type instanceof ReflectionNamedType && !$type->isBuiltin() && $this->has($type->getName())) {
+ try {
+ return $this->get($type->getName());
+ } catch (ContainerExceptionInterface $e) {
+ if (!$parameter->isDefaultValueAvailable() && !$parameter->allowsNull()) {
+ throw $e;
+ }
+ }
+ }
+
+ if ($parameter->isDefaultValueAvailable()) {
+ return $parameter->getDefaultValue();
+ }
+
+ if ($parameter->allowsNull()) {
+ return null;
+ }
+
+ throw new DependencyHasNoDefaultValueException(
+ 'Unable to resolve the value of parameter "$' . $parameter->getName() . '".'
+ );
+ }
}
diff --git a/src/Exception/CircularDependencyException.php b/src/Exception/CircularDependencyException.php
new file mode 100644
index 0000000..32a8076
--- /dev/null
+++ b/src/Exception/CircularDependencyException.php
@@ -0,0 +1,23 @@
+
+ * @copyright Copyright © 2022 InitPHP Container
+ * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT
+ */
+
+declare(strict_types=1);
+
+namespace InitPHP\Container\Exception;
+
+/**
+ * Thrown when a class depends on itself either directly or through a chain of
+ * other classes, which would otherwise cause unbounded recursion.
+ */
+class CircularDependencyException extends ContainerException
+{
+}
diff --git a/src/Exception/ContainerException.php b/src/Exception/ContainerException.php
new file mode 100644
index 0000000..6c70390
--- /dev/null
+++ b/src/Exception/ContainerException.php
@@ -0,0 +1,26 @@
+
+ * @copyright Copyright © 2022 InitPHP Container
+ * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT
+ */
+
+declare(strict_types=1);
+
+namespace InitPHP\Container\Exception;
+
+use Exception;
+use Psr\Container\ContainerExceptionInterface;
+
+/**
+ * Base exception for every error raised while the container is building or
+ * retrieving an entry. Specific failure modes extend this class.
+ */
+class ContainerException extends Exception implements ContainerExceptionInterface
+{
+}
diff --git a/src/Exception/DependencyHasNoDefaultValueException.php b/src/Exception/DependencyHasNoDefaultValueException.php
index 4f9f3bb..8fc14c7 100644
--- a/src/Exception/DependencyHasNoDefaultValueException.php
+++ b/src/Exception/DependencyHasNoDefaultValueException.php
@@ -1,4 +1,5 @@
* @copyright Copyright © 2022 InitPHP Container
- * @license http://initphp.github.io/license.txt MIT
- * @version pre-0.1
- * @link https://www.muhammetsafak.com.tr
+ * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT
*/
declare(strict_types=1);
namespace InitPHP\Container\Exception;
-use \Psr\Container\NotFoundExceptionInterface;
-
-class DependencyHasNoDefaultValueException extends \Exception implements NotFoundExceptionInterface
+/**
+ * Thrown when a constructor parameter cannot be autowired and has neither a
+ * default value nor a nullable type to fall back on.
+ */
+class DependencyHasNoDefaultValueException extends ContainerException
{
}
diff --git a/src/Exception/DependencyIsNotInstantiable.php b/src/Exception/DependencyIsNotInstantiable.php
deleted file mode 100644
index 944bd26..0000000
--- a/src/Exception/DependencyIsNotInstantiable.php
+++ /dev/null
@@ -1,22 +0,0 @@
-
- * @copyright Copyright © 2022 InitPHP Container
- * @license http://initphp.github.io/license.txt MIT
- * @version pre-0.1
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-
-namespace InitPHP\Container\Exception;
-
-use \Psr\Container\NotFoundExceptionInterface;
-
-class DependencyIsNotInstantiable extends \Exception implements NotFoundExceptionInterface
-{
-}
diff --git a/src/Exception/DependencyIsNotInstantiableException.php b/src/Exception/DependencyIsNotInstantiableException.php
new file mode 100644
index 0000000..e8f7a29
--- /dev/null
+++ b/src/Exception/DependencyIsNotInstantiableException.php
@@ -0,0 +1,24 @@
+
+ * @copyright Copyright © 2022 InitPHP Container
+ * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT
+ */
+
+declare(strict_types=1);
+
+namespace InitPHP\Container\Exception;
+
+/**
+ * Thrown when the container is asked to build a class that cannot be
+ * instantiated, such as an interface, an abstract class or a class with a
+ * non-public constructor.
+ */
+class DependencyIsNotInstantiableException extends ContainerException
+{
+}
diff --git a/src/Exception/NotFoundException.php b/src/Exception/NotFoundException.php
new file mode 100644
index 0000000..0029f01
--- /dev/null
+++ b/src/Exception/NotFoundException.php
@@ -0,0 +1,25 @@
+
+ * @copyright Copyright © 2022 InitPHP Container
+ * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT
+ */
+
+declare(strict_types=1);
+
+namespace InitPHP\Container\Exception;
+
+use Psr\Container\NotFoundExceptionInterface;
+
+/**
+ * Thrown by {@see \InitPHP\Container\Container::get()} when the requested
+ * identifier is neither a registered entry nor an autowirable class.
+ */
+class NotFoundException extends ContainerException implements NotFoundExceptionInterface
+{
+}
diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php
new file mode 100644
index 0000000..fa1af1b
--- /dev/null
+++ b/tests/ContainerTest.php
@@ -0,0 +1,277 @@
+
+ * @copyright Copyright © 2022 InitPHP Container
+ * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT
+ */
+
+declare(strict_types=1);
+
+namespace InitPHP\Container\Tests;
+
+use InitPHP\Container\Container;
+use InitPHP\Container\Exception\CircularDependencyException;
+use InitPHP\Container\Exception\ContainerException;
+use InitPHP\Container\Exception\DependencyHasNoDefaultValueException;
+use InitPHP\Container\Exception\DependencyIsNotInstantiableException;
+use InitPHP\Container\Exception\NotFoundException;
+use InitPHP\Container\Tests\Fixtures\AbstractClass;
+use InitPHP\Container\Tests\Fixtures\CircularA;
+use InitPHP\Container\Tests\Fixtures\NeedsInterface;
+use InitPHP\Container\Tests\Fixtures\NeedsNoConstructor;
+use InitPHP\Container\Tests\Fixtures\NoConstructor;
+use InitPHP\Container\Tests\Fixtures\NullableBuiltinWithoutDefault;
+use InitPHP\Container\Tests\Fixtures\NullableDependency;
+use InitPHP\Container\Tests\Fixtures\OptionalInterface;
+use InitPHP\Container\Tests\Fixtures\OptionalUnbuildableDependency;
+use InitPHP\Container\Tests\Fixtures\PrivateConstructor;
+use InitPHP\Container\Tests\Fixtures\RequiredUnbuildableDependency;
+use InitPHP\Container\Tests\Fixtures\ScalarWithDefault;
+use InitPHP\Container\Tests\Fixtures\ScalarWithoutDefault;
+use InitPHP\Container\Tests\Fixtures\SelfReferencing;
+use InitPHP\Container\Tests\Fixtures\ServiceImplementation;
+use InitPHP\Container\Tests\Fixtures\ServiceInterface;
+use InitPHP\Container\Tests\Fixtures\UnboundInterface;
+use InitPHP\Container\Tests\Fixtures\UnionTypeWithDefault;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use stdClass;
+
+final class ContainerTest extends TestCase
+{
+ private Container $container;
+
+ protected function setUp(): void
+ {
+ $this->container = new Container();
+ }
+
+ public function testImplementsPsrContainerInterface(): void
+ {
+ $this->assertInstanceOf(ContainerInterface::class, $this->container);
+ }
+
+ public function testHasReturnsFalseForUnknownNonClassIdentifier(): void
+ {
+ $this->assertFalse($this->container->has('unknown-service'));
+ }
+
+ public function testHasReturnsTrueForExistingClassName(): void
+ {
+ $this->assertTrue($this->container->has(NoConstructor::class));
+ }
+
+ public function testHasReturnsTrueAfterSet(): void
+ {
+ $this->container->set('config', ['debug' => true]);
+ $this->assertTrue($this->container->has('config'));
+ }
+
+ public function testGetThrowsNotFoundForUnknownNonClassIdentifier(): void
+ {
+ $this->expectException(NotFoundException::class);
+ $this->container->get('unknown-service');
+ }
+
+ public function testGetStoredScalarValue(): void
+ {
+ $this->container->set('answer', 42);
+ $this->assertSame(42, $this->container->get('answer'));
+ }
+
+ public function testGetStoredArrayValue(): void
+ {
+ $config = ['debug' => true];
+ $this->container->set('config', $config);
+ $this->assertSame($config, $this->container->get('config'));
+ }
+
+ public function testGetStoredNonClassStringIsReturnedVerbatim(): void
+ {
+ $this->container->set('greeting', 'hello');
+ $this->assertSame('hello', $this->container->get('greeting'));
+ }
+
+ public function testSetWithExistingObjectReturnsSameInstance(): void
+ {
+ $object = new stdClass();
+ $this->container->set('obj', $object);
+ $this->assertSame($object, $this->container->get('obj'));
+ }
+
+ public function testAutowireClassWithoutRegistration(): void
+ {
+ $instance = $this->container->get(NoConstructor::class);
+ $this->assertInstanceOf(NoConstructor::class, $instance);
+ }
+
+ public function testResolvedEntriesAreCachedAsSingletons(): void
+ {
+ $first = $this->container->get(NoConstructor::class);
+ $second = $this->container->get(NoConstructor::class);
+ $this->assertSame($first, $second);
+ }
+
+ public function testAutowireResolvesClassDependency(): void
+ {
+ $instance = $this->container->get(NeedsNoConstructor::class);
+ $this->assertInstanceOf(NeedsNoConstructor::class, $instance);
+ $this->assertInstanceOf(NoConstructor::class, $instance->dependency);
+ }
+
+ public function testNestedDependencyIsTheSharedInstance(): void
+ {
+ $dependency = $this->container->get(NoConstructor::class);
+ $consumer = $this->container->get(NeedsNoConstructor::class);
+ $this->assertSame($dependency, $consumer->dependency);
+ }
+
+ public function testScalarParameterUsesDefaultValue(): void
+ {
+ $instance = $this->container->get(ScalarWithDefault::class);
+ $this->assertSame('default', $instance->name);
+ }
+
+ public function testScalarParameterWithoutDefaultThrows(): void
+ {
+ $this->expectException(DependencyHasNoDefaultValueException::class);
+ $this->container->get(ScalarWithoutDefault::class);
+ }
+
+ public function testNullableDependencyResolvesToInstanceWhenAvailable(): void
+ {
+ $instance = $this->container->get(NullableDependency::class);
+ $this->assertInstanceOf(NoConstructor::class, $instance->dependency);
+ }
+
+ public function testNullableBuiltinWithoutDefaultResolvesToNull(): void
+ {
+ $instance = $this->container->get(NullableBuiltinWithoutDefault::class);
+ $this->assertNull($instance->value);
+ }
+
+ public function testUnionTypedParameterUsesDefaultValue(): void
+ {
+ $instance = $this->container->get(UnionTypeWithDefault::class);
+ $this->assertSame(1, $instance->value);
+ }
+
+ public function testBoundInterfaceIsResolvedForDependency(): void
+ {
+ $this->container->set(ServiceInterface::class, ServiceImplementation::class);
+ $instance = $this->container->get(NeedsInterface::class);
+ $this->assertInstanceOf(ServiceImplementation::class, $instance->service);
+ }
+
+ public function testGetBoundInterfaceReturnsImplementation(): void
+ {
+ $this->container->set(ServiceInterface::class, ServiceImplementation::class);
+ $this->assertInstanceOf(ServiceImplementation::class, $this->container->get(ServiceInterface::class));
+ }
+
+ public function testUnboundInterfaceDependencyWithNullDefaultResolvesToNull(): void
+ {
+ $instance = $this->container->get(OptionalInterface::class);
+ $this->assertNull($instance->service);
+ }
+
+ public function testUnboundInterfaceDependencyWithoutFallbackThrows(): void
+ {
+ // An interface dependency with neither a binding, a default value nor a
+ // nullable type cannot be resolved; the failure points at the parameter.
+ $this->expectException(DependencyHasNoDefaultValueException::class);
+ $this->container->get(NeedsInterface::class);
+ }
+
+ public function testGetUnboundInterfaceThrowsNotFound(): void
+ {
+ $this->expectException(NotFoundException::class);
+ $this->container->get(UnboundInterface::class);
+ }
+
+ public function testAbstractClassIsNotInstantiable(): void
+ {
+ $this->expectException(DependencyIsNotInstantiableException::class);
+ $this->container->get(AbstractClass::class);
+ }
+
+ public function testPrivateConstructorIsNotInstantiable(): void
+ {
+ $this->expectException(DependencyIsNotInstantiableException::class);
+ $this->container->get(PrivateConstructor::class);
+ }
+
+ public function testCircularDependencyIsDetected(): void
+ {
+ $this->expectException(CircularDependencyException::class);
+ $this->container->get(CircularA::class);
+ }
+
+ public function testSelfReferencingDependencyIsDetected(): void
+ {
+ $this->expectException(CircularDependencyException::class);
+ $this->container->get(SelfReferencing::class);
+ }
+
+ public function testOptionalDependencyFallsBackToNullWhenClassCannotBeBuilt(): void
+ {
+ // ScalarWithoutDefault cannot be autowired (needs a string), but the
+ // parameter is nullable with a default, so the container falls back.
+ $instance = $this->container->get(OptionalUnbuildableDependency::class);
+ $this->assertNull($instance->dependency);
+ }
+
+ public function testRequiredDependencyRethrowsWhenClassCannotBeBuilt(): void
+ {
+ $this->expectException(DependencyHasNoDefaultValueException::class);
+ $this->container->get(RequiredUnbuildableDependency::class);
+ }
+
+ public function testClosureFactoryIsInvokedLazily(): void
+ {
+ $calls = 0;
+ $this->container->set('service', function () use (&$calls) {
+ $calls++;
+ return new stdClass();
+ });
+
+ $this->assertSame(0, $calls, 'Factory must not run before the entry is requested.');
+ $this->container->get('service');
+ $this->container->get('service');
+ $this->assertSame(1, $calls, 'Factory result must be cached after the first call.');
+ }
+
+ public function testClosureFactoryReceivesContainer(): void
+ {
+ $this->container->set('inner', new stdClass());
+ $this->container->set('outer', fn (ContainerInterface $c) => $c->get('inner'));
+
+ $this->assertSame($this->container->get('inner'), $this->container->get('outer'));
+ }
+
+ public function testSetWithoutConcreteUsesIdentifierAsDefinition(): void
+ {
+ $this->container->set(NoConstructor::class);
+ $this->assertInstanceOf(NoConstructor::class, $this->container->get(NoConstructor::class));
+ }
+
+ public function testReSettingReplacesCachedInstance(): void
+ {
+ $this->container->set('value', 'first');
+ $this->assertSame('first', $this->container->get('value'));
+
+ $this->container->set('value', 'second');
+ $this->assertSame('second', $this->container->get('value'));
+ }
+
+ public function testNotFoundExceptionIsAContainerException(): void
+ {
+ $this->expectException(ContainerException::class);
+ $this->container->get('unknown-service');
+ }
+}
diff --git a/tests/Fixtures/fixtures.php b/tests/Fixtures/fixtures.php
new file mode 100644
index 0000000..ec53169
--- /dev/null
+++ b/tests/Fixtures/fixtures.php
@@ -0,0 +1,153 @@
+
+ * @copyright Copyright © 2022 InitPHP Container
+ * @license https://github.com/InitPHP/Container/blob/main/LICENSE MIT
+ */
+
+declare(strict_types=1);
+
+namespace InitPHP\Container\Tests\Fixtures;
+
+/** A plain class with no constructor. */
+class NoConstructor
+{
+}
+
+/** A class whose only constructor argument is another resolvable class. */
+class NeedsNoConstructor
+{
+ public function __construct(public NoConstructor $dependency)
+ {
+ }
+}
+
+/** A class with a scalar constructor argument that has a default value. */
+class ScalarWithDefault
+{
+ public function __construct(public string $name = 'default')
+ {
+ }
+}
+
+/** A class with a scalar constructor argument that has no default value. */
+class ScalarWithoutDefault
+{
+ public function __construct(public string $name)
+ {
+ }
+}
+
+/** A class with a nullable, untyped-default constructor argument. */
+class NullableDependency
+{
+ public function __construct(public ?NoConstructor $dependency)
+ {
+ }
+}
+
+/** A class with a nullable, builtin-typed argument that has no default. */
+class NullableBuiltinWithoutDefault
+{
+ public function __construct(public ?int $value)
+ {
+ }
+}
+
+/** An interface that is never bound to a concrete implementation. */
+interface UnboundInterface
+{
+}
+
+/** A concrete implementation of {@see ServiceInterface}. */
+interface ServiceInterface
+{
+}
+
+class ServiceImplementation implements ServiceInterface
+{
+}
+
+/** A class that type-hints an interface argument. */
+class NeedsInterface
+{
+ public function __construct(public ServiceInterface $service)
+ {
+ }
+}
+
+/** A class that type-hints an interface argument with a null default. */
+class OptionalInterface
+{
+ public function __construct(public ?ServiceInterface $service = null)
+ {
+ }
+}
+
+/** An abstract class, which cannot be instantiated. */
+abstract class AbstractClass
+{
+}
+
+/** A class with a private constructor, which cannot be instantiated. */
+class PrivateConstructor
+{
+ private function __construct()
+ {
+ }
+}
+
+/** A class with a PHP 8 union-typed constructor argument and a default. */
+class UnionTypeWithDefault
+{
+ public function __construct(public int|string $value = 1)
+ {
+ }
+}
+
+/** Optional dependency on a class that cannot itself be built. */
+class OptionalUnbuildableDependency
+{
+ public function __construct(public ?ScalarWithoutDefault $dependency = null)
+ {
+ }
+}
+
+/** Required dependency on a class that cannot itself be built. */
+class RequiredUnbuildableDependency
+{
+ public function __construct(public ScalarWithoutDefault $dependency)
+ {
+ }
+}
+
+/** Two classes that depend on each other to form a cycle. */
+class CircularA
+{
+ public function __construct(public CircularB $b)
+ {
+ }
+}
+
+class CircularB
+{
+ public function __construct(public CircularA $a)
+ {
+ }
+}
+
+/** A class that depends on itself directly. */
+class SelfReferencing
+{
+ public function __construct(public SelfReferencing $self)
+ {
+ }
+}