diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml
index 97589f2..c8de574 100644
--- a/.github/workflows/codecov.yml
+++ b/.github/workflows/codecov.yml
@@ -15,14 +15,13 @@ jobs:
- ubuntu-latest
php:
- - "8.2"
- "8.3"
- "8.4"
- "8.5"
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Install PHP
uses: shivammathur/setup-php@v2
@@ -36,7 +35,7 @@ jobs:
run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV
- name: Cache dependencies installed with composer
- uses: actions/cache@v4
+ uses: actions/cache@v6
with:
path: ${{ env.COMPOSER_CACHE_DIR }}
key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }}
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
index 9976515..9f01888 100644
--- a/.github/workflows/static-analysis.yml
+++ b/.github/workflows/static-analysis.yml
@@ -15,14 +15,13 @@ jobs:
- ubuntu-latest
php:
- - "8.2"
- "8.3"
- "8.4"
- "8.5"
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Install PHP
uses: shivammathur/setup-php@v2
@@ -36,7 +35,7 @@ jobs:
run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV
- name: Cache dependencies installed with composer
- uses: actions/cache@v4
+ uses: actions/cache@v6
with:
path: ${{ env.COMPOSER_CACHE_DIR }}
key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }}
diff --git a/README.md b/README.md
index 2a7cd92..e265d36 100644
--- a/README.md
+++ b/README.md
@@ -2,16 +2,27 @@
`dot-dependency-injection` is Dotkernel's dependency injection service.
-By providing reusable factories for service and repository injection, it reduces code complexity in projects.
+Instead of a hand-written factory class per service, you declare a class's dependencies with the `#[Inject]` attribute on its constructor - or a repository's entity with `#[Entity]` - and register one of the two reusable factories this package ships.
+That removes an entire category of boilerplate files from a project and keeps the dependency list next to the constructor it feeds.
+
+See [Attributes vs. factories](https://docs.dotkernel.org/dot-dependency-injection/v1/attributes-vs-factories/) for the full comparison and the trade-offs.
## Documentation
-Documentation is available at: https://docs.dotkernel.org/dot-dependency-injection/.
+Documentation is available at: .
+
+- [Installation](https://docs.dotkernel.org/dot-dependency-injection/v1/installation/)
+- [Configuration](https://docs.dotkernel.org/dot-dependency-injection/v1/configuration/)
+- [Attributes vs. factories](https://docs.dotkernel.org/dot-dependency-injection/v1/attributes-vs-factories/)
+- [Factories](https://docs.dotkernel.org/dot-dependency-injection/v1/factories/)
+- [Inject class dependencies](https://docs.dotkernel.org/dot-dependency-injection/v1/factories/service/)
+- [Inject entity repositories](https://docs.dotkernel.org/dot-dependency-injection/v1/factories/repository/)
+- [FAQ](https://docs.dotkernel.org/dot-dependency-injection/v1/faq/)
## Badges

-
+
[](https://github.com/dotkernel/dot-dependency-injection/issues)
[](https://github.com/dotkernel/dot-dependency-injection/network)
@@ -23,6 +34,12 @@ Documentation is available at: https://docs.dotkernel.org/dot-dependency-injecti
[](https://github.com/dotkernel/dot-dependency-injection/actions/workflows/docs-build.yml)
[](https://github.com/dotkernel/dot-dependency-injection/actions/workflows/static-analysis.yml)
+## Requirements
+
+- PHP 8.3, 8.4 or 8.5
+- a PSR-11 container, usually `laminas/laminas-servicemanager`
+- `doctrine/orm` ^2.9 || ^3.0, if you use `AttributedRepositoryFactory`
+
## Installation
Install `dot-dependency-injection` by running the following command in your project directory:
@@ -44,6 +61,8 @@ Dot\DependencyInjection\ConfigProvider::class,
You can register services in the service manager using `AttributedServiceFactory` as seen in the below example:
```php
+use Dot\DependencyInjection\Factory\AttributedServiceFactory;
+
return [
'factories' => [
ServiceClass::class => AttributedServiceFactory::class,
@@ -56,39 +75,49 @@ return [
The next step is to add the `#[Inject]` attribute to the service constructor with the service FQCNs to inject:
```php
-#[\Dot\DependencyInjection\Attribute\Inject(
- App\Srevice\Dependency1::class,
- App\Srevice\Dependency2::class,
- "config",
+use App\Service\Dependency1;
+use App\Service\Dependency2;
+use Dot\DependencyInjection\Attribute\Inject;
+
+#[Inject(
+ Dependency1::class,
+ Dependency2::class,
+ 'config',
)]
public function __construct(
- protected App\Srevice\Dependency1 $dep1,
- protected App\Srevice\Dependency2 $dep2,
- protected array $config
+ protected Dependency1 $dep1,
+ protected Dependency2 $dep2,
+ protected array $config,
) {
}
```
-The `#[Inject]` attribute is telling `AttributedServiceFactory` to inject the services specified as parameters.
+The `#[Inject]` attribute is telling `AttributedServiceFactory` to inject the services specified as parameters, in the same order as the constructor parameters.
Valid service names should be provided, as registered in the service manager.
+A name that is not registered in the container, but is an existing class, is instantiated directly.
+
+A class without a constructor does not need the attribute.
To inject an array value from the service manager, you can use dot notation as below
```php
-#[\Dot\DependencyInjection\Attribute\Inject(
- "config.debug",
+#[Inject(
+ 'config.debug',
)]
```
which will inject `$container->get('config')['debug'];`.
> Even if using dot notation, `AttributedServiceFactory` will check first if a service name exists with that name.
+> Only the segment before the first dot is resolved from the container; the rest are array keys, and arrays as well as `ArrayAccess` objects can be traversed.
### Using the AttributedRepositoryFactory
You can register doctrine repositories and inject them using the `AttributedRepositoryFactory` as below:
```php
+use Dot\DependencyInjection\Factory\AttributedRepositoryFactory;
+
return [
'factories' => [
ExampleRepository::class => AttributedRepositoryFactory::class,
@@ -103,7 +132,7 @@ The `name` field has to be the fully qualified class name.
Every repository should extend `Doctrine\ORM\EntityRepository`.
```php
-use Api\App\Entity\Example;
+use App\Entity\Example;
use Doctrine\ORM\EntityRepository;
use Dot\DependencyInjection\Attribute\Entity;
@@ -113,6 +142,24 @@ class ExampleRepository extends EntityRepository
}
```
-> Dependencies injected via the`#[Entity]`/`#[Inject]` attributes are not cached
+Because Doctrine builds the repository from the entity's mapping, the entity must point back to the repository:
+
+```php
+#[ORM\Entity(repositoryClass: ExampleRepository::class)]
+class Example
+{
+}
+```
+
+> Dependencies injected via the `#[Entity]`/`#[Inject]` attributes are not cached.
+> Injecting dependencies into property setters is not supported.
+
+## Quality assurance
+
+Run the full suite - coding standard, tests and static analysis:
+
+```shell
+composer check
+```
-> Injecting dependencies into property setters is not supported
+Individual targets: `composer cs-check`, `composer cs-fix`, `composer test`, `composer static-analysis`.
diff --git a/SECURITY.md b/SECURITY.md
index 15fa2fa..1ecc7e6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,15 +2,14 @@
## Supported Versions
-| Version | Supported | PHP Version |
-|---------|--------------------|--------------------------------------------------------------------------------------------------------------------------|
-| 1.x | :white_check_mark: |  |
+| Version | Supported | PHP Version |
+| --- | --- | --- |
+| 1.x | :white_check_mark: |  |
## Reporting Potential Security Issues
-If you have encountered a potential security vulnerability in this project,
-please report it to us at . We will work with you to
-verify the vulnerability and patch it.
+If you have encountered a potential security vulnerability in this project, please report it to us at .
+We will work with you to verify the vulnerability and patch it.
When reporting issues, please provide the following information:
@@ -18,19 +17,11 @@ When reporting issues, please provide the following information:
- A description indicating how to reproduce the issue
- A summary of the security vulnerability and impact
-We request that you contact us via the email address above and give the
-project contributors a chance to resolve the vulnerability and issue a new
-release prior to any public exposure; this helps protect the project's
-users, and provides them with a chance to upgrade and/or update in order to
-protect their applications.
+We request that you contact us via the email address above and give the project contributors a chance to resolve the vulnerability and issue a new release prior to any public exposure; this helps protect the project's users, and provides them with a chance to upgrade and/or update in order to protect their applications.
## Policy
If we verify a reported security vulnerability, our policy is:
-- We will patch the current release branch, as well as the immediate prior minor
- release branch.
-
-- After patching the release branches, we will immediately issue new security
- fix releases for each patched release branch.
-
+- We will patch the current release branch, as well as the immediate prior minor release branch.
+- After patching the release branches, we will immediately issue new security fix releases for each patched release branch.
diff --git a/composer.json b/composer.json
index 0aa14e9..85c8e44 100644
--- a/composer.json
+++ b/composer.json
@@ -24,7 +24,7 @@
"service"
],
"require": {
- "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+ "php": "~8.3.0 || ~8.4.0 || ~8.5.0",
"doctrine/orm": "^2.9 || ^3.0",
"psr/container": "^1.0 || ^2.0"
},
diff --git a/docs/book/v1/attributes-vs-factories.md b/docs/book/v1/attributes-vs-factories.md
new file mode 100644
index 0000000..1b4a976
--- /dev/null
+++ b/docs/book/v1/attributes-vs-factories.md
@@ -0,0 +1,155 @@
+# Attributes vs. factories
+
+A PSR-11 container needs to know how to build every service.
+The usual answer is a factory class per service, which means that a project ends up carrying one extra file for every class it wires up.
+`dot-dependency-injection` replaces those files with a single attribute on the constructor and two reusable factories.
+
+## The same service, both ways
+
+### With a hand-written factory
+
+Two files, plus the registration.
+
+```php
+get(UserRepository::class),
+ $container->get(UrlHelper::class),
+ $container->get('config')['user'],
+ );
+ }
+}
+```
+
+```php
+'factories' => [
+ UserService::class => UserServiceFactory::class,
+],
+```
+
+### With `#[Inject]`
+
+One file, and the registration.
+
+```php
+ [
+ UserService::class => AttributedServiceFactory::class,
+],
+```
+
+## Why this is an advantage
+
+**Fewer files, less code.**
+A module with 20 services no longer needs 20 factory classes.
+The wiring shrinks from a class per service to four lines inside the class that already exists.
+
+**One source of truth.**
+The dependency list sits directly above the constructor it feeds.
+Adding a constructor parameter and forgetting to update the factory is the single most common wiring bug in a factory-based project, and it cannot happen when both live on the same lines.
+
+**No drift, no duplication.**
+Hand-written factories repeat the same `$container->get(...)` pattern hundreds of times across a project.
+Every repetition is a place where a typo, a stale service name, or a copy-paste mistake can hide.
+
+**Nothing to test.**
+A trivial factory is still code, so it shows up in coverage reports and either gets a test that asserts nothing meaningful or drags coverage down.
+The two factories in this package are tested once, here.
+
+**Configuration without boilerplate.**
+`'config.user'` replaces `$container->get('config')['user']` and fails with a clear, package-level exception naming the full path when that key is missing, instead of an `Undefined array key` notice or a silent `null`.
+
+**Repositories for free.**
+`AttributedRepositoryFactory` removes an entire category of factories: every Doctrine repository in a project is otherwise a near-identical factory calling `$container->get(EntityManagerInterface::class)->getRepository(...)`.
+
+**Consistent failures.**
+All wiring errors surface as `Dot\DependencyInjection\Exception\ExceptionInterface`, with messages that name the class, the attribute and the factory involved, rather than whatever each hand-written factory happened to do.
+
+**Readable classes.**
+Anyone opening the class sees what it needs and where each dependency comes from without opening a second file.
+
+## What you give up
+
+Being explicit about the trade-offs matters more than the line count.
+
+| | Hand-written factory | `#[Inject]` |
+| --- | --- | --- |
+| Wiring is checked by the type system | yes, `new UserService(...)` is analysed | no, names are strings resolved at runtime |
+| Order of arguments verified statically | yes | no, the attribute order must match the constructor |
+| Reflection at build time | none | one `ReflectionClass` per service creation, not cached |
+| Conditional or computed wiring | anything PHP allows | not supported |
+| Works on third-party classes | yes | only on classes you can annotate |
+
+## When to keep writing a factory
+
+`#[Inject]` covers the common case: a constructor that receives services and configuration.
+Write a factory by hand when you need something else.
+
+- the arguments are computed, conditional, or come from something other than the container
+- the service is decorated, or built through a delegator or an abstract factory
+- you are wiring a class you do not own and cannot annotate
+- the class is intentionally constructed with runtime arguments rather than resolved from the container
+- injection has to happen somewhere other than the constructor
+
+Both styles coexist without any friction: the factory is chosen per service in your `ConfigProvider`, so you can use `AttributedServiceFactory` for the bulk of a module and a hand-written factory for the few services that need one.
diff --git a/docs/book/v1/configuration.md b/docs/book/v1/configuration.md
index d4915b1..a9e596b 100644
--- a/docs/book/v1/configuration.md
+++ b/docs/book/v1/configuration.md
@@ -5,3 +5,15 @@ After installation, register `dot-dependency-injection` in your project by addin
```php
Dot\DependencyInjection\ConfigProvider::class,
```
+
+`Dot\DependencyInjection\ConfigProvider` currently returns an empty array: the package exposes factories that you reference from your own modules, so it does not need to register any service of its own.
+Registering it keeps your configuration aggregate consistent with the other Dotkernel packages and makes sure that future configuration shipped by this package is picked up automatically.
+
+There is no package-specific configuration to set.
+Everything else is declared where it belongs:
+
+- the dependencies of a class, through the `#[Inject]` attribute on its constructor
+- the entity of a repository, through the `#[Entity]` attribute on the repository class
+- the mapping between a class and the factory that builds it, under the `dependencies.factories` key of the `ConfigProvider` of your own module
+
+See [Inject class dependencies](factories/service.md) and [Inject entity repositories](factories/repository.md).
diff --git a/docs/book/v1/factories.md b/docs/book/v1/factories.md
index a4bac6f..66cb4b5 100644
--- a/docs/book/v1/factories.md
+++ b/docs/book/v1/factories.md
@@ -1,30 +1,82 @@
# Factories
-`dot-dependency-injection` is based on two reusable factories - `AttributedRepositoryFactory` and `AttributedServiceFactory` - able to inject any dependency into a class.
+`dot-dependency-injection` is based on two reusable factories - `AttributedServiceFactory` and `AttributedRepositoryFactory` - able to inject any dependency into a class.
-## AttributedRepositoryFactory
+Both are plain invokable factories, so they work with any PSR-11 container that passes the requested service name to the factory (for example `laminas/laminas-servicemanager`):
-Injects entity repositories into a class.
+```php
+$factory = new Dot\DependencyInjection\Factory\AttributedServiceFactory();
+$service = $factory($container, YourApp\Service\Example::class);
+```
-### Exceptions thrown
+Both expose the same logic through `createObject(ContainerInterface $container, string $requestedName)`, which `__invoke()` simply delegates to.
+Use `createObject()` when you extend one of the factories and need to call the parent implementation explicitly.
-- `Dot\DependencyInjection\Exception\RuntimeException` if repository does not exist
-- `Dot\DependencyInjection\Exception\RuntimeException` if repository does not extend `Doctrine\ORM\EntityRepository`
-- `Dot\DependencyInjection\Exception\RuntimeException` if repository does not have `#[Entity]` attribute
-- `Psr\Container\NotFoundExceptionInterface` if `Doctrine\ORM\EntityManagerInterface` does not exist in the service container
-- `Psr\Container\ContainerExceptionInterface` if service manager is unable to provide an instance of `Doctrine\ORM\EntityManagerInterface`
+> Because the service name is also the class to instantiate, you can only register these factories under the fully qualified class name of the service.
+> Aliases still work, as long as the alias points to a service registered under its FQCN.
## AttributedServiceFactory
Injects class dependencies into classes.
-If a dependency is specified using the dot notation, `AttributedServiceFactory` will try to load a service having that specific alias.
-If it does not find one, it will try to load the dependency as a config tree, checking each segment if it's available in the service container.
+The factory looks for the `#[Inject]` attribute on the constructor of the requested class and resolves each of its parameters, in the declared order, into a constructor argument.
+
+Resolution of a single dependency happens as follows:
+
+1. if the container has a service registered under the exact name, that service is used - this is checked first, even when the name contains dots
+2. otherwise, if the name contains dots, the part before the first dot is resolved from the container and the remaining parts are read as keys from the returned array (or `ArrayAccess` object)
+3. otherwise, if the name is an existing class, the factory instantiates it directly with `new $name()`
+4. otherwise, a `RuntimeException` is thrown
+
+A class whose constructor has no parameters at all does not need the `#[Inject]` attribute: if the requested class has no constructor, it is instantiated directly.
-### Exceptions thrown
+### Exceptions thrown by AttributedServiceFactory
-- `Dot\DependencyInjection\Exception\RuntimeException` if service does not exist
-- `Dot\DependencyInjection\Exception\RuntimeException` if service does not have `#[Inject]` attribute on it's constructor
-- `Dot\DependencyInjection\Exception\RuntimeException` if service tries to inject itself recursively
+- `Dot\DependencyInjection\Exception\RuntimeException` if the requested class does not exist
+- `Dot\DependencyInjection\Exception\RuntimeException` if the requested class has a constructor without the `#[Inject]` attribute
+- `Dot\DependencyInjection\Exception\RuntimeException` if the class tries to inject itself
+- `Dot\DependencyInjection\Exception\RuntimeException` if a dependency is neither a registered service nor an existing class
+- `Dot\DependencyInjection\Exception\InvalidArgumentException` if a key of a dot-separated dependency cannot be found in the array service
- `Psr\Container\NotFoundExceptionInterface` if a dependency does not exist in the service container
-- `Psr\Container\ContainerExceptionInterface` if service manager is unable to provide an instance of a dependency
+- `Psr\Container\ContainerExceptionInterface` if the service manager is unable to provide an instance of a dependency
+
+## AttributedRepositoryFactory
+
+Injects entity repositories into a class.
+
+The factory looks for the `#[Entity]` attribute on the requested repository class and returns `$container->get(EntityManagerInterface::class)->getRepository($entityName)`.
+
+Since Doctrine decides which repository class to build from the mapping of the entity, the entity referenced by the `#[Entity]` attribute must declare the repository back:
+
+```php
+#[ORM\Entity(repositoryClass: ExampleRepository::class)]
+```
+
+If it does not, Doctrine returns its default repository and the factory throws a `RuntimeException` instead of handing back an object of an unexpected type.
+
+### Exceptions thrown by AttributedRepositoryFactory
+
+- `Dot\DependencyInjection\Exception\RuntimeException` if the repository class does not exist
+- `Dot\DependencyInjection\Exception\RuntimeException` if the repository class does not extend `Doctrine\ORM\EntityRepository`
+- `Dot\DependencyInjection\Exception\RuntimeException` if the repository class does not have the `#[Entity]` attribute
+- `Dot\DependencyInjection\Exception\RuntimeException` if the class referenced by the `#[Entity]` attribute does not exist
+- `Dot\DependencyInjection\Exception\RuntimeException` if Doctrine returns a repository that is not an instance of the requested class
+- `Psr\Container\NotFoundExceptionInterface` if `Doctrine\ORM\EntityManagerInterface` does not exist in the service container
+- `Psr\Container\ContainerExceptionInterface` if the service manager is unable to provide an instance of `Doctrine\ORM\EntityManagerInterface`
+
+## Exception hierarchy
+
+Both exceptions implement `Dot\DependencyInjection\Exception\ExceptionInterface`, so you can catch anything thrown by this package with a single `catch` block:
+
+```php
+try {
+ $service = $container->get(YourApp\Service\Example::class);
+} catch (Dot\DependencyInjection\Exception\ExceptionInterface $exception) {
+ // ...
+}
+```
+
+| Exception | Extends | Meaning |
+| --- | --- | --- |
+| `RuntimeException` | `\RuntimeException` | the class or service graph cannot be built as declared |
+| `InvalidArgumentException` | `\InvalidArgumentException` | a dot-separated dependency points to a missing array key |
diff --git a/docs/book/v1/factories/repository.md b/docs/book/v1/factories/repository.md
index e8687a1..52370d5 100644
--- a/docs/book/v1/factories/repository.md
+++ b/docs/book/v1/factories/repository.md
@@ -1,5 +1,25 @@
# Inject entity repositories
+## Prepare entity
+
+Doctrine decides which repository class to instantiate based on the mapping of the entity, so the entity must point to its repository:
+
+```php
+
+ */
+#[Entity(name: Example::class)]
+class ExampleRepository extends EntityRepository
{
}
```
-Each entity repository must extend `Doctrine\ORM\EntityRepository`.
+- the `name` field has to be the fully qualified class name of the entity
+- each entity repository must extend `Doctrine\ORM\EntityRepository`
+- `#[Entity]` targets classes only
+
+The factory returns `$container->get(EntityManagerInterface::class)->getRepository(Example::class)`, which means the repository is built and managed by Doctrine, with the entity manager already injected.
+Do not declare a constructor with a different signature on your repository.
+
+> If the entity does not declare `repositoryClass`, Doctrine returns its default `EntityRepository` and the factory throws a `Dot\DependencyInjection\Exception\RuntimeException` naming both classes, instead of returning an object of an unexpected type under your repository's service name.
## Register repository
@@ -34,6 +68,9 @@ declare(strict_types=1);
namespace YourApp;
+use Dot\DependencyInjection\Factory\AttributedRepositoryFactory;
+use YourApp\Repository\ExampleRepository;
+
class ConfigProvider
{
public function __invoke(): array
@@ -47,9 +84,30 @@ class ConfigProvider
{
return [
'factories' => [
- YourApp\Repository\ExampleRepository::class => Dot\DependencyInjection\Factory\AttributedRepositoryFactory::class,
+ ExampleRepository::class => AttributedRepositoryFactory::class,
],
];
}
}
```
+
+`Doctrine\ORM\EntityManagerInterface` must be registered in your container.
+In Dotkernel projects this is provided by `doctrine/doctrine-orm-module` / `dot-cache` configuration and is already in place.
+
+## Inject the repository into a service
+
+Once registered, the repository is an ordinary service, so it can be injected with `#[Inject]`:
+
+```php
+#[Inject(
+ ExampleRepository::class,
+)]
+public function __construct(
+ protected ExampleRepository $exampleRepository,
+) {
+}
+```
+
+## Caching
+
+The `#[Entity]` attribute is read with reflection on every repository creation and is not cached by this package.
diff --git a/docs/book/v1/factories/service.md b/docs/book/v1/factories/service.md
index dc16e0d..e08d9ee 100644
--- a/docs/book/v1/factories/service.md
+++ b/docs/book/v1/factories/service.md
@@ -3,7 +3,7 @@
## Prepare class
`dot-dependency-injection` determines the dependencies by looking at the `#[Inject]` attribute, added to the constructor of a class.
-Dependencies are specified as separate parameters of the `#[Inject]` attribute.
+Dependencies are specified as separate parameters of the `#[Inject]` attribute, in the same order as the constructor parameters.
```php
`#[Inject]` targets methods only, and only the constructor is inspected.
+> Injecting dependencies into property setters is not supported.
+
+## Inject a configuration value
+
If your class needs the value of a specific configuration key, you can specify the path using dot notation:
```php
- #[Dot\DependencyInjection\Attribute\Inject(
- YourApp\Repository\Dependency1::class,
- YourApp\Helper\Dependency2::class,
- "config.example",
+ #[Inject(
+ Dependency1::class,
+ 'config.example',
)]
public function __construct(
- protected YourApp\Repository\Dependency1 $dependency1,
- protected YourApp\Helper\Dependency2 $dependency2,
+ protected Dependency1 $dependency1,
protected array $exampleConfig,
) {
}
```
+`'config.example'` injects `$container->get('config')['example']`, and paths of any depth work: `'config.example.nested.value'`.
+
+Things worth knowing about dot notation:
+
+- the full name is always looked up in the container first, so a service literally registered as `config.example` takes precedence over the `example` key of the `config` service
+- only the part before the first dot is resolved from the container; every following segment is an array key
+- both arrays and objects implementing `ArrayAccess` can be traversed
+- a key holding `null` is injected as `null`, it is not treated as missing
+- if a segment does not exist, or the path continues past a scalar value, `Dot\DependencyInjection\Exception\InvalidArgumentException` is thrown, naming the full path you declared
+
## Register class
Open the ConfigProvider of the module where your class resides.
@@ -59,6 +85,9 @@ declare(strict_types=1);
namespace YourApp;
+use Dot\DependencyInjection\Factory\AttributedServiceFactory;
+use YourApp\Service\Example;
+
class ConfigProvider
{
public function __invoke(): array
@@ -67,14 +96,38 @@ class ConfigProvider
'dependencies' => $this->getDependencies(),
];
}
-
+
public function getDependencies(): array
{
return [
'factories' => [
- YourApp\Service\Example::class => Dot\DependencyInjection\Factory\AttributedServiceFactory::class,
+ Example::class => AttributedServiceFactory::class,
],
];
}
}
```
+
+> The service key must be the fully qualified class name of the class to build, because the factory instantiates the requested service name.
+> Register an alias if you need a shorter name.
+
+## Recursion
+
+A class cannot inject itself:
+
+```php
+#[Inject(self::class)]
+public function __construct(protected ?Example $example = null)
+{
+}
+```
+
+This throws a `Dot\DependencyInjection\Exception\RuntimeException`.
+
+> Only direct self-injection is detected.
+> A cycle spanning several classes (`A` needs `B`, `B` needs `A`) is not detected by this package and will end in infinite recursion, exactly as it would with hand-written factories.
+
+## Caching
+
+Dependencies declared through `#[Inject]` are resolved with reflection on every service creation, and the result is not cached by this package.
+The container's own instance cache still applies: a shared service is built once per request, no matter how many classes inject it.
diff --git a/docs/book/v1/faq.md b/docs/book/v1/faq.md
new file mode 100644
index 0000000..80b2e02
--- /dev/null
+++ b/docs/book/v1/faq.md
@@ -0,0 +1,155 @@
+# FAQ
+
+## Setup and registration
+
+### Do I still have to register every service in a ConfigProvider?
+
+Yes.
+This package replaces the factory class you would write for a service, not the `dependencies.factories` entry that tells the container which factory to use.
+Each service still needs one line mapping its FQCN to `AttributedServiceFactory` or `AttributedRepositoryFactory`.
+
+### Why does it not read the dependencies from my constructor type hints?
+
+Container identifiers are not types.
+The same type can be registered several times under different names, plenty of services are registered under strings such as `config`, and a parameter typed against an interface gives no indication of which implementation you want.
+`#[Inject]` asks you for the identifier because that is the only thing the container can actually resolve.
+
+### Can I register a service under a name other than its fully qualified class name?
+
+Not directly, because the factory instantiates the requested service name.
+Register the service under its FQCN and add an alias pointing to it if you need a shorter name.
+
+### Can I keep some hand-written factories?
+
+Yes, the factory is chosen per service, so both styles can live side by side in the same module.
+See [Attributes vs. factories](attributes-vs-factories.md) for the cases where a hand-written factory is still the better choice.
+
+## The `#[Inject]` attribute
+
+### My constructor takes no arguments, do I need the attribute?
+
+It depends on whether the constructor exists at all.
+A class with no constructor is instantiated directly and needs no attribute.
+A class that explicitly declares `public function __construct()` does need the attribute, otherwise you get `You need to use the "...\Inject" attribute on the "..." class`.
+Use `#[Inject()]` with no parameters, or delete the empty constructor.
+
+### What happens if the order in the attribute does not match the constructor?
+
+Nothing checks it, and the arguments are passed positionally in the order you listed them.
+If the types differ you get a `TypeError` when the service is created; if they happen to be compatible, the wrong values are injected silently.
+Keep the attribute parameters in the same order as the constructor parameters.
+
+### Can I leave out some constructor parameters?
+
+Only trailing ones that have a default value.
+Arguments are passed positionally, so you cannot skip a parameter in the middle of the list and you cannot inject by parameter name.
+
+### Can I inject a plain value, such as a string or a number?
+
+No.
+Every parameter of the attribute is resolved as a container identifier, a class name, or a dot-separated path into an array service.
+Put the literal in your configuration and inject it with dot notation instead.
+
+### Is `#[Inject]` inherited by subclasses?
+
+Yes.
+If a subclass does not declare its own constructor, the factory reads the attribute from the inherited constructor and builds the subclass with the dependencies the parent declared.
+Declare a constructor with its own `#[Inject]` in the subclass to change them.
+
+### Can I put `#[Inject]` on a property or a setter?
+
+No.
+The attribute targets methods, and only the constructor is inspected.
+
+### Can I inject the same service twice?
+
+Yes, list it as many times as your constructor needs it.
+A shared service is resolved from the container each time, so both parameters receive the same instance.
+
+## Configuration values
+
+### How do I inject a single configuration key?
+
+Use dot notation: `#[Inject('config.example')]` injects `$container->get('config')['example']`.
+Paths of any depth work, and both arrays and `ArrayAccess` objects can be traversed.
+
+### What if a service is registered under a name that contains dots?
+
+The full name is always looked up in the container first, so a service literally registered as `config.example` wins over the `example` key of the `config` service.
+
+### Can I address a configuration key that itself contains a dot?
+
+No.
+Only the segment before the first dot is treated as a service identifier; every following dot starts a new array key, so a key named `my.key` cannot be reached.
+Nest the value one level deeper, or expose it as its own service.
+
+### What if the configuration value is `null`?
+
+It is injected as `null`.
+A key that exists but holds `null` is not treated as missing.
+
+### What if the key does not exist?
+
+`Dot\DependencyInjection\Exception\InvalidArgumentException` is thrown, naming the full path you declared.
+You get the same exception when the path continues past a scalar value, for example `config.debug.verbose` where `config.debug` is a boolean.
+
+## Doctrine repositories
+
+### Why do I get a repository of the wrong class, or an "unexpected repository" error?
+
+Doctrine, not this package, decides which class to instantiate, and it reads that from the mapping of the entity.
+Add `#[ORM\Entity(repositoryClass: ExampleRepository::class)]` to the entity, so that it points back to the repository that declares it through `#[Entity]`.
+
+### Do I still need `#[Entity]` if the entity already declares `repositoryClass`?
+
+Yes.
+The two point at each other: `repositoryClass` tells Doctrine which class to build, and `#[Entity]` tells the factory which entity to ask for.
+
+### Can I inject extra dependencies into a repository?
+
+No.
+The repository is constructed by Doctrine with the entity manager and the class metadata, so its constructor signature is not yours to change.
+Put the extra dependencies in a service that receives the repository through `#[Inject]`.
+
+### How do I inject a repository into a service?
+
+Register the repository with `AttributedRepositoryFactory`, then list it like any other service: `#[Inject(ExampleRepository::class)]`.
+
+### Do I need `doctrine/orm` if I only use `AttributedServiceFactory`?
+
+It is a hard requirement of the package, so it will be installed, but nothing loads it unless you use `AttributedRepositoryFactory`.
+
+## Performance
+
+### Is the attribute lookup cached?
+
+No.
+One `ReflectionClass` is created per service creation and the attribute is read every time.
+The container's own instance cache still applies, so a shared service is built once per request no matter how many classes inject it.
+
+### Does that slow down my application?
+
+The reflection happens once per service that a request actually builds, and it is a lookup on an already-loaded class rather than file parsing.
+If a profile ever shows it mattering for a specific hot service, write a factory by hand for that one service.
+
+## Testing and debugging
+
+### How do I unit test a class that uses `#[Inject]`?
+
+Exactly as you would test any other class: instantiate it with `new` and pass test doubles.
+The attribute is inert outside the factory, so it has no effect on your tests.
+
+### I get "You need to use the ... attribute on the ... class", what now?
+
+The requested class has a constructor without the `#[Inject]` attribute, or the attribute sits somewhere other than the constructor.
+Check that the attribute is imported from `Dot\DependencyInjection\Attribute\Inject`, because an attribute of another `Inject` class is ignored.
+
+### Are circular dependencies detected?
+
+Only direct self-injection, which throws a `RuntimeException`.
+A cycle spanning several classes, where `A` needs `B` and `B` needs `A`, is not detected and ends in infinite recursion, exactly as it would with hand-written factories.
+
+### Which exception should I catch?
+
+Both exceptions of this package implement `Dot\DependencyInjection\Exception\ExceptionInterface`, so a single `catch` covers every wiring error.
+Note that your container usually wraps them, for example in a `Laminas\ServiceManager\Exception\ServiceNotCreatedException`, so inspect the previous exception.
diff --git a/docs/book/v1/installation.md b/docs/book/v1/installation.md
index dacf977..ad3402d 100644
--- a/docs/book/v1/installation.md
+++ b/docs/book/v1/installation.md
@@ -1,5 +1,13 @@
# Installation
+## Requirements
+
+- PHP 8.3, 8.4 or 8.5
+- A PSR-11 container (`psr/container` ^1.0 || ^2.0), usually `laminas/laminas-servicemanager`
+- `doctrine/orm` ^2.9 || ^3.0 - required only if you use `AttributedRepositoryFactory`
+
+## Install
+
Install `dotkernel/dot-dependency-injection` by executing the following Composer command:
```shell
diff --git a/docs/book/v1/overview.md b/docs/book/v1/overview.md
index 87f9685..b70544f 100644
--- a/docs/book/v1/overview.md
+++ b/docs/book/v1/overview.md
@@ -2,12 +2,22 @@
`dot-dependency-injection` is Dotkernel's dependency injection service.
-By providing reusable factories for service and repository injection, it reduces code complexity in projects.
+Instead of a handwritten factory class per service, you declare a class's dependencies with the `#[Inject]` attribute on its constructor - or a repository's entity with `#[Entity]` - and register one of the two reusable factories this package ships.
+That removes an entire category of boilerplate files from a project and keeps the dependency list next to the constructor it feeds.
+
+It provides:
+
+- `Dot\DependencyInjection\Attribute\Inject` - declares the dependencies of a constructor
+- `Dot\DependencyInjection\Attribute\Entity` - declares the entity of a Doctrine repository
+- `Dot\DependencyInjection\Factory\AttributedServiceFactory` - builds any class from its `#[Inject]` attribute
+- `Dot\DependencyInjection\Factory\AttributedRepositoryFactory` - builds any Doctrine repository from its `#[Entity]` attribute
+
+Continue with [Attributes vs. factories](attributes-vs-factories.md) for the comparison and the trade-offs, or with [Installation](installation.md).
## Badges

-
+
[](https://github.com/dotkernel/dot-dependency-injection/issues)
[](https://github.com/dotkernel/dot-dependency-injection/network)
diff --git a/docs/book/v1/usage.md b/docs/book/v1/usage.md
index 21ae6d0..bb49199 100644
--- a/docs/book/v1/usage.md
+++ b/docs/book/v1/usage.md
@@ -1,6 +1,12 @@
# Usage
+Start with [Attributes vs. factories](attributes-vs-factories.md) for what this package replaces and why.
+
You can use it to:
- [Inject class dependencies](factories/service.md)
- [Inject entity repositories](factories/repository.md)
+
+Both are described in detail in [Factories](factories.md).
+
+For the questions that come up most often, see the [FAQ](faq.md).
diff --git a/mkdocs.yml b/mkdocs.yml
index 197b451..5380864 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -12,10 +12,12 @@ nav:
- Installation: v1/installation.md
- Configuration: v1/configuration.md
- Usage: v1/usage.md
+ - "Attributes vs. factories": v1/attributes-vs-factories.md
- Factories: v1/factories.md
- Reference:
- "Inject class dependencies": v1/factories/service.md
- "Inject entity repositories": v1/factories/repository.md
+ - FAQ: v1/faq.md
site_name: dot-dependency-injection
site_description: "Dotkernel's dependency injection service"
repo_url: "https://github.com/dotkernel/dot-dependency-injection"
diff --git a/phpstan.neon b/phpstan.neon
index 45a12e4..349be25 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -6,8 +6,3 @@ parameters:
- src
- test
treatPhpDocTypesAsCertain: false
- ignoreErrors:
- - message: '#Constructor of an anonymous class has an unused parameter \$config.#'
- path: test/Factory/AttributedServiceFactoryTest.php
- - message: '#Constructor of an anonymous class has an unused parameter \$test.#'
- path: test/Factory/AttributedServiceFactoryTest.php
diff --git a/src/Attribute/Entity.php b/src/Attribute/Entity.php
index 78acdec..ff0a6b8 100644
--- a/src/Attribute/Entity.php
+++ b/src/Attribute/Entity.php
@@ -6,14 +6,20 @@
use Attribute;
-#[Attribute]
+/**
+ * Marks a Doctrine entity repository as belonging to the given entity.
+ *
+ * Read by {@see \Dot\DependencyInjection\Factory\AttributedRepositoryFactory}.
+ */
+#[Attribute(Attribute::TARGET_CLASS)]
class Entity
{
- private string $name;
-
- public function __construct(string $name)
+ /**
+ * @param string $name FQCN of the entity the annotated repository manages.
+ * Validated at runtime by AttributedRepositoryFactory.
+ */
+ public function __construct(private readonly string $name)
{
- $this->name = $name;
}
public function getName(): string
diff --git a/src/Attribute/Inject.php b/src/Attribute/Inject.php
index 3a43b0e..bec9cfa 100644
--- a/src/Attribute/Inject.php
+++ b/src/Attribute/Inject.php
@@ -6,16 +6,31 @@
use Attribute;
-#[Attribute]
+use function array_values;
+
+/**
+ * Declares, in order, the dependencies to pass to the annotated constructor.
+ *
+ * Read by {@see \Dot\DependencyInjection\Factory\AttributedServiceFactory}.
+ */
+#[Attribute(Attribute::TARGET_METHOD)]
class Inject
{
+ /** @var list */
protected array $services = [];
+ /**
+ * @param string ...$services Service container identifiers, class names,
+ * or dot-separated paths into an array service.
+ */
public function __construct(string ...$services)
{
- $this->services = $services;
+ $this->services = array_values($services);
}
+ /**
+ * @return list
+ */
public function getServices(): array
{
return $this->services;
diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php
index e5e8f32..026ee4b 100644
--- a/src/Exception/InvalidArgumentException.php
+++ b/src/Exception/InvalidArgumentException.php
@@ -4,8 +4,15 @@
namespace Dot\DependencyInjection\Exception;
+use function sprintf;
+
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
{
public const MESSAGE_MISSING_KEY =
'The key "%s" provided in the dotted notation could not be found in the array service.';
+
+ public static function missingKey(string $serviceKey): self
+ {
+ return new self(sprintf(self::MESSAGE_MISSING_KEY, $serviceKey));
+ }
}
diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php
index 99e5bb6..14c73b9 100644
--- a/src/Exception/RuntimeException.php
+++ b/src/Exception/RuntimeException.php
@@ -8,14 +8,17 @@
class RuntimeException extends \RuntimeException implements ExceptionInterface
{
- public const MESSAGE_ATTRIBUTE_NOT_FOUND =
+ public const MESSAGE_ATTRIBUTE_NOT_FOUND =
'You need to use the "%s" attribute on the "%s" class so that "%s" can create it.';
- public const MESSAGE_CLASS_NOT_FOUND =
+ public const MESSAGE_CLASS_NOT_FOUND =
'Defined injectable "%s" could not be found in container or as a class.';
- public const MESSAGE_DOES_NOT_EXTEND =
+ public const MESSAGE_DOES_NOT_EXTEND =
'Class "%s" must extend class "%s".';
- public const MESSAGE_RECURSIVE_INJECT =
+ public const MESSAGE_RECURSIVE_INJECT =
'Class "%s" can not be injected into itself.';
+ public const MESSAGE_UNEXPECTED_REPOSITORY =
+ 'Doctrine returned an instance of "%s" instead of "%s". '
+ . 'Make sure that entity "%s" declares #[ORM\Entity(repositoryClass: ...)] pointing to it.';
public static function classNotFound(string $requestedName): self
{
@@ -36,4 +39,9 @@ public static function recursiveInject(string $requestedName): self
{
return new self(sprintf(self::MESSAGE_RECURSIVE_INJECT, $requestedName));
}
+
+ public static function unexpectedRepository(string $actual, string $requestedName, string $entity): self
+ {
+ return new self(sprintf(self::MESSAGE_UNEXPECTED_REPOSITORY, $actual, $requestedName, $entity));
+ }
}
diff --git a/src/Factory/AttributedRepositoryFactory.php b/src/Factory/AttributedRepositoryFactory.php
index d5c64ca..dc38488 100644
--- a/src/Factory/AttributedRepositoryFactory.php
+++ b/src/Factory/AttributedRepositoryFactory.php
@@ -14,12 +14,17 @@
use ReflectionClass;
use function class_exists;
+use function is_a;
+/**
+ * Creates a Doctrine entity repository based on the #[Entity] attribute of the requested class.
+ */
class AttributedRepositoryFactory
{
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
+ * @throws RuntimeException
*/
public function __invoke(ContainerInterface $container, string $requestedName): EntityRepository
{
@@ -29,6 +34,7 @@ public function __invoke(ContainerInterface $container, string $requestedName):
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
+ * @throws RuntimeException
*/
public function createObject(ContainerInterface $container, string $requestedName): EntityRepository
{
@@ -46,18 +52,28 @@ public function createObject(ContainerInterface $container, string $requestedNam
throw RuntimeException::attributeNotFound(Entity::class, $requestedName, static::class);
}
- return $container->get(EntityManagerInterface::class)->getRepository($entityAttribute->getName());
+ $entityName = $entityAttribute->getName();
+ if (! class_exists($entityName)) {
+ throw RuntimeException::classNotFound($entityName);
+ }
+
+ $repository = $container->get(EntityManagerInterface::class)->getRepository($entityName);
+ if (! is_a($repository, $requestedName)) {
+ throw RuntimeException::unexpectedRepository($repository::class, $requestedName, $entityName);
+ }
+
+ return $repository;
}
+ /**
+ * @template T of object
+ * @param ReflectionClass $reflectionClass
+ */
protected function findEntityAttribute(ReflectionClass $reflectionClass): ?Entity
{
- $attributes = $reflectionClass->getAttributes();
- foreach ($attributes as $attribute) {
- if ($attribute->getName() === Entity::class) {
- return $attribute->newInstance();
- }
- }
+ $attribute = $reflectionClass->getAttributes(Entity::class)[0] ?? null;
+ $instance = $attribute?->newInstance();
- return null;
+ return $instance instanceof Entity ? $instance : null;
}
}
diff --git a/src/Factory/AttributedServiceFactory.php b/src/Factory/AttributedServiceFactory.php
index e0ce6ac..4bea971 100644
--- a/src/Factory/AttributedServiceFactory.php
+++ b/src/Factory/AttributedServiceFactory.php
@@ -14,21 +14,30 @@
use ReflectionClass;
use ReflectionMethod;
+use function array_key_exists;
use function array_shift;
use function class_exists;
use function count;
use function explode;
use function in_array;
use function is_array;
-use function sprintf;
+/**
+ * Creates any class based on the #[Inject] attribute of its constructor.
+ */
class AttributedServiceFactory
{
- protected string $originalKey;
+ /**
+ * Retained for backwards compatibility with subclasses overriding readKeysFromArray().
+ * Assigned immediately before the call, so a nested service creation cannot overwrite it.
+ */
+ protected string $originalKey = '';
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
+ * @throws RuntimeException
+ * @throws InvalidArgumentException
*/
public function __invoke(ContainerInterface $container, string $requestedName): mixed
{
@@ -38,6 +47,8 @@ public function __invoke(ContainerInterface $container, string $requestedName):
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
+ * @throws RuntimeException
+ * @throws InvalidArgumentException
*/
public function createObject(ContainerInterface $container, string $requestedName): mixed
{
@@ -66,19 +77,19 @@ public function createObject(ContainerInterface $container, string $requestedNam
protected function findInjectAttribute(ReflectionMethod $constructor): ?Inject
{
- $attributes = $constructor->getAttributes();
- foreach ($attributes as $attribute) {
- if ($attribute->getName() === Inject::class) {
- return $attribute->newInstance();
- }
- }
+ $attribute = $constructor->getAttributes(Inject::class)[0] ?? null;
+ $instance = $attribute?->newInstance();
- return null;
+ return $instance instanceof Inject ? $instance : null;
}
/**
+ * @param list $parameters
+ * @return list
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
+ * @throws RuntimeException
+ * @throws InvalidArgumentException
*/
protected function getServicesToInject(ContainerInterface $container, array $parameters): array
{
@@ -94,47 +105,78 @@ protected function getServicesToInject(ContainerInterface $container, array $par
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
+ * @throws RuntimeException
+ * @throws InvalidArgumentException
*/
protected function getServiceToInject(ContainerInterface $container, string $serviceKey): mixed
{
- $this->originalKey = $serviceKey;
-
/**
* Even when dots are found, try to find a service with the full name.
* If it is not found, then assume dots are used to get part of an array service
*/
- $parts = explode('.', $serviceKey);
+ $parts = explode('.', $serviceKey);
+ $identifier = $serviceKey;
if (count($parts) > 1 && ! $container->has($serviceKey)) {
- $serviceKey = array_shift($parts);
+ $identifier = array_shift($parts);
} else {
$parts = [];
}
- if ($container->has($serviceKey)) {
- $service = $container->get($serviceKey);
- } elseif (class_exists($serviceKey)) {
- $service = new $serviceKey();
+ if ($container->has($identifier)) {
+ $service = $container->get($identifier);
+ } elseif (class_exists($identifier)) {
+ $service = new $identifier();
} else {
- throw RuntimeException::classNotFound($serviceKey);
+ throw RuntimeException::classNotFound($identifier);
}
- return empty($parts) ? $service : $this->readKeysFromArray($parts, $service);
+ if ($parts === []) {
+ return $service;
+ }
+
+ $this->originalKey = $serviceKey;
+
+ return $this->readKeysFromArray($parts, $service);
}
+ /**
+ * @param non-empty-list $keys
+ * @throws InvalidArgumentException
+ */
protected function readKeysFromArray(array $keys, mixed $array): mixed
{
$key = array_shift($keys);
- if (! isset($array[$key])) {
- throw new InvalidArgumentException(
- sprintf(InvalidArgumentException::MESSAGE_MISSING_KEY, $this->originalKey)
- );
+ if (! $this->hasKey($array, $key)) {
+ throw InvalidArgumentException::missingKey($this->originalKey);
}
$value = $array[$key];
- if (! empty($keys) && (is_array($value) || $value instanceof ArrayAccess)) {
- $value = $this->readKeysFromArray($keys, $value);
+ if ($keys === []) {
+ return $value;
+ }
+
+ if (! is_array($value) && ! $value instanceof ArrayAccess) {
+ throw InvalidArgumentException::missingKey($this->originalKey);
+ }
+
+ return $this->readKeysFromArray($keys, $value);
+ }
+
+ /**
+ * Unlike isset(), this does not treat a null value as a missing key.
+ *
+ * Private so that it cannot collide with a method of the same name in a subclass.
+ */
+ private function hasKey(mixed $array, string $key): bool
+ {
+ if (is_array($array)) {
+ return array_key_exists($key, $array);
+ }
+
+ if ($array instanceof ArrayAccess) {
+ return $array->offsetExists($key);
}
- return $value;
+ return false;
}
}
diff --git a/test/Factory/AttributedRepositoryFactoryTest.php b/test/Factory/AttributedRepositoryFactoryTest.php
index ab241de..9937d7e 100644
--- a/test/Factory/AttributedRepositoryFactoryTest.php
+++ b/test/Factory/AttributedRepositoryFactoryTest.php
@@ -11,6 +11,7 @@
use Dot\DependencyInjection\Exception\RuntimeException;
use Dot\DependencyInjection\Factory\AttributedRepositoryFactory;
use DotTest\DependencyInjection\TestData\Entity as TestEntity;
+use DotTest\DependencyInjection\TestData\InvalidEntityRepository;
use DotTest\DependencyInjection\TestData\Repository as TestRepository;
use PHPUnit\Framework\MockObject\Exception;
use PHPUnit\Framework\TestCase;
@@ -116,4 +117,62 @@ public function testWillCreateRepository(): void
$repository = (new AttributedRepositoryFactory())($container, TestRepository::class);
$this->assertInstanceOf(TestRepository::class, $repository);
}
+
+ /**
+ * @throws Exception
+ * @throws ContainerExceptionInterface
+ * @throws NotFoundExceptionInterface
+ */
+ public function testWillThrowExceptionIfEntityClassNotFound(): void
+ {
+ $container = $this->createMock(ContainerInterface::class);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage(
+ sprintf(
+ RuntimeException::MESSAGE_CLASS_NOT_FOUND,
+ 'DotTest\\DependencyInjection\\TestData\\NotAnEntity'
+ )
+ );
+
+ (new AttributedRepositoryFactory())($container, InvalidEntityRepository::class);
+ }
+
+ /**
+ * @throws Exception
+ * @throws ContainerExceptionInterface
+ * @throws NotFoundExceptionInterface
+ */
+ public function testWillThrowExceptionIfDoctrineReturnsAnotherRepository(): void
+ {
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $container = $this->createMock(ContainerInterface::class);
+
+ $metadata = new ClassMetadata(TestEntity::class);
+ $repository = new class ($entityManager, $metadata) extends EntityRepository {
+ };
+
+ $container
+ ->expects($this->once())
+ ->method('get')
+ ->with(EntityManagerInterface::class)
+ ->willReturn($entityManager);
+ $entityManager
+ ->expects($this->once())
+ ->method('getRepository')
+ ->with(TestEntity::class)
+ ->willReturn($repository);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage(
+ sprintf(
+ RuntimeException::MESSAGE_UNEXPECTED_REPOSITORY,
+ $repository::class,
+ TestRepository::class,
+ TestEntity::class
+ )
+ );
+
+ (new AttributedRepositoryFactory())($container, TestRepository::class);
+ }
}
diff --git a/test/Factory/AttributedServiceFactoryTest.php b/test/Factory/AttributedServiceFactoryTest.php
index c5686de..3bfe3e0 100644
--- a/test/Factory/AttributedServiceFactoryTest.php
+++ b/test/Factory/AttributedServiceFactoryTest.php
@@ -4,6 +4,7 @@
namespace DotTest\DependencyInjection\Factory;
+use ArrayObject;
use Dot\DependencyInjection\Attribute\Inject;
use Dot\DependencyInjection\Exception\InvalidArgumentException;
use Dot\DependencyInjection\Exception\RuntimeException;
@@ -124,7 +125,7 @@ function (string $key) use ($mapping): array {
$subject = new class
{
#[Inject('config.uration.key')]
- public function __construct(array $config = [])
+ public function __construct(public array $config = [])
{
}
};
@@ -149,7 +150,7 @@ public function testWillThrowExceptionIfDependencyNotFound(): void
$subject = new class
{
#[Inject('test')]
- public function __construct(mixed $test = null)
+ public function __construct(public mixed $test = null)
{
}
};
@@ -209,4 +210,160 @@ function (string $key) use ($mapping): array {
$service = (new AttributedServiceFactory())($container, $subject::class);
$this->assertInstanceOf(ValidService::class, $service);
}
+
+ /**
+ * @throws ContainerExceptionInterface
+ * @throws Exception
+ * @throws NotFoundExceptionInterface
+ */
+ public function testWillInjectNullValueFromDottedNotation(): void
+ {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->expects($this->any())->method('has')->willReturnCallback(
+ fn (string $key): bool => $key === 'config',
+ );
+ $container->expects($this->any())->method('get')->willReturn(['debug' => null]);
+
+ $subject = new class {
+ #[Inject('config.debug')]
+ public function __construct(public mixed $debug = 'not injected')
+ {
+ }
+ };
+
+ $service = (new AttributedServiceFactory())($container, $subject::class);
+ $this->assertNull($service->debug);
+ }
+
+ /**
+ * @throws ContainerExceptionInterface
+ * @throws Exception
+ * @throws NotFoundExceptionInterface
+ */
+ public function testWillThrowExceptionIfDottedNotationOvershootsAScalar(): void
+ {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->expects($this->any())->method('has')->willReturnCallback(
+ fn (string $key): bool => $key === 'config',
+ );
+ $container->expects($this->any())->method('get')->willReturn(['debug' => true]);
+
+ $subject = new class {
+ #[Inject('config.debug.verbose')]
+ public function __construct(public mixed $verbose = null)
+ {
+ }
+ };
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ sprintf(InvalidArgumentException::MESSAGE_MISSING_KEY, 'config.debug.verbose')
+ );
+
+ (new AttributedServiceFactory())($container, $subject::class);
+ }
+
+ /**
+ * @throws ContainerExceptionInterface
+ * @throws Exception
+ * @throws NotFoundExceptionInterface
+ */
+ public function testWillReadDottedNotationFromArrayAccessService(): void
+ {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->expects($this->any())->method('has')->willReturnCallback(
+ fn (string $key): bool => $key === 'config',
+ );
+ $container->expects($this->any())->method('get')->willReturn(
+ new ArrayObject(['nested' => new ArrayObject(['value' => 'injected'])])
+ );
+
+ $subject = new class {
+ #[Inject('config.nested.value')]
+ public function __construct(public ?string $value = null)
+ {
+ }
+ };
+
+ $service = (new AttributedServiceFactory())($container, $subject::class);
+ $this->assertSame('injected', $service->value);
+ }
+
+ /**
+ * @throws ContainerExceptionInterface
+ * @throws Exception
+ * @throws NotFoundExceptionInterface
+ */
+ public function testWillPreferAServiceNamedLikeTheDottedKey(): void
+ {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->expects($this->any())->method('has')->willReturnCallback(
+ fn (string $key): bool => $key === 'config.debug',
+ );
+ $container
+ ->expects($this->once())
+ ->method('get')
+ ->with('config.debug')
+ ->willReturn(['full-service']);
+
+ $subject = new class {
+ #[Inject('config.debug')]
+ public function __construct(public array $debug = [])
+ {
+ }
+ };
+
+ $service = (new AttributedServiceFactory())($container, $subject::class);
+ $this->assertSame(['full-service'], $service->debug);
+ }
+
+ /**
+ * @throws ContainerExceptionInterface
+ * @throws Exception
+ * @throws NotFoundExceptionInterface
+ */
+ public function testWillInstantiateAnUnregisteredClassDependency(): void
+ {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->expects($this->any())->method('has')->willReturn(false);
+ $container->expects($this->never())->method('get');
+
+ $subject = new class {
+ #[Inject(ValidService::class)]
+ public function __construct(public ?ValidService $service = null)
+ {
+ }
+ };
+
+ $service = (new AttributedServiceFactory())($container, $subject::class);
+ $this->assertInstanceOf(ValidService::class, $service->service);
+ }
+
+ /**
+ * @throws ContainerExceptionInterface
+ * @throws Exception
+ * @throws NotFoundExceptionInterface
+ */
+ public function testWillThrowExceptionIfDottedNotationIsUsedOnANonArrayService(): void
+ {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->expects($this->any())->method('has')->willReturnCallback(
+ fn (string $key): bool => $key === 'version',
+ );
+ $container->expects($this->any())->method('get')->willReturn('1.0.0');
+
+ $subject = new class {
+ #[Inject('version.major')]
+ public function __construct(public mixed $major = null)
+ {
+ }
+ };
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ sprintf(InvalidArgumentException::MESSAGE_MISSING_KEY, 'version.major')
+ );
+
+ (new AttributedServiceFactory())($container, $subject::class);
+ }
}
diff --git a/test/TestData/InvalidEntityRepository.php b/test/TestData/InvalidEntityRepository.php
new file mode 100644
index 0000000..bb1827a
--- /dev/null
+++ b/test/TestData/InvalidEntityRepository.php
@@ -0,0 +1,18 @@
+
+ */
+#[Entity(name: 'DotTest\DependencyInjection\TestData\NotAnEntity')]
+class InvalidEntityRepository extends EntityRepository
+{
+}