|
| 1 | +--- |
| 2 | +title: "Attributes vs. factories" |
| 3 | +description: "Dotkernel's dot-dependency-injection package lets you wire a service with an #[Inject] attribute or a hand-written factory. This article compares both approaches with real dotkernel/api code and explains when each one fits." |
| 4 | +author: "Florin Bidirean" |
| 5 | +date_published: "2026-10-01" |
| 6 | +canonical_url: "https://www.dotkernel.com/best-practice/attributes-vs-factories/" |
| 7 | +category: "Best Practice" |
| 8 | +language: "en" |
| 9 | +--- |
| 10 | + |
| 11 | +# Attributes vs. factories |
| 12 | + |
| 13 | +## TL;DR |
| 14 | + |
| 15 | +Attributes remove one factory file per service and keep dependencies next to the constructor they feed, while hand-written factories keep static type checking and full control over construction. Use #[Inject] for plain constructor wiring, keep factories for computed, conditional, decorated or third-party classes, and mix both freely in the same PSR-11 application. |
| 16 | + |
| 17 | +## Introduction |
| 18 | + |
| 19 | +Dependency injection (DI) is simple to define: a class receives the objects it needs instead of building them itself. |
| 20 | +As Fabien Potencier puts it in [What is Dependency Injection?](https://fabien.potencier.org/what-is-dependency-injection.html), components are given their dependencies through their constructors, methods or fields. |
| 21 | +The payoff is flexibility, because you can swap an implementation without touching the class that uses it, testability, because you can pass a mock, and separation of concerns, because a class does its own job and leaves object creation to someone else. |
| 22 | +For required dependencies, the constructor is the natural place to receive them. |
| 23 | + |
| 24 | +Someone still has to build those objects, and in a [PSR-11](https://www.php-fig.org/psr/psr-11/) application that someone is the container. |
| 25 | +PSR-11 defines only two methods on `ContainerInterface`: `get()` and `has()`. |
| 26 | +It also gives a clear warning: "Users SHOULD NOT pass a container into an object so that the object can retrieve its own dependencies." |
| 27 | +That is the service locator anti-pattern, and it is why the container belongs in a factory, not inside your service. |
| 28 | + |
| 29 | +The classic answer is one factory class per service. |
| 30 | +It works, but every new constructor argument means editing two files, and the factory can quietly drift out of sync with the class it builds. |
| 31 | +Dotkernel's [dot-dependency-injection](https://github.com/dotkernel/dot-dependency-injection) package offers a second answer: declare the dependencies with an `#[Inject]` attribute on the constructor and register one shared factory, `AttributedServiceFactory`, for every service. |
| 32 | +We introduced the package in [Dependency Injection made easy in Laminas/Mezzio applications](https://www.dotkernel.com/dotkernel/dependency-injection-made-easy-in-laminas-mezzio-applications/), and the [package documentation](https://docs.dotkernel.org/dot-dependency-injection/v1/attributes-vs-factories/) covers the trade-offs. |
| 33 | + |
| 34 | +This article puts the two side by side and helps you choose. |
| 35 | +The real-world code comes from the [Dotkernel API](https://github.com/dotkernel/api) repository. |
| 36 | + |
| 37 | +## Comparison table |
| 38 | + |
| 39 | +| Aspect | Hand-written factory | `#[Inject]` attribute | |
| 40 | +| --- | --- | --- | |
| 41 | +| Files per service | 2: the class and its factory | 1: the class | |
| 42 | +| Type checking | Statically verified | None, dependencies are resolved from strings at runtime | |
| 43 | +| Argument order | Written by you in the `new` call | Must match the constructor manually | |
| 44 | +| Reflection | None when the service is built | One `ReflectionClass` per service creation | |
| 45 | +| Conditional wiring | Fully supported | Not supported | |
| 46 | +| Third-party classes | Works on any class | Only classes you can annotate | |
| 47 | +| Config access | `$container->get('config')['user']` | `'config.user'` | |
| 48 | +| Error handling | Whatever you write | Uniform `Dot\DependencyInjection\Exception\ExceptionInterface` | |
| 49 | +| Doctrine repositories | A factory for each repository | `AttributedRepositoryFactory` | |
| 50 | + |
| 51 | +Rule of thumb: if the constructor only needs things the container already holds, use the attribute. If construction needs logic, use a factory. |
| 52 | + |
| 53 | +## Code examples from dotkernel/api |
| 54 | + |
| 55 | +### A service wired with an attribute |
| 56 | + |
| 57 | +`UserAvatarService` needs a Doctrine repository and the application config. |
| 58 | +In dotkernel/api, the class declares both on the constructor: |
| 59 | + |
| 60 | +```php |
| 61 | +use Core\User\Repository\UserAvatarRepository; |
| 62 | +use Dot\DependencyInjection\Attribute\Inject; |
| 63 | + |
| 64 | +class UserAvatarService implements UserAvatarServiceInterface |
| 65 | +{ |
| 66 | + /** |
| 67 | + * @param array<non-empty-string, mixed> $config |
| 68 | + */ |
| 69 | + #[Inject( |
| 70 | + UserAvatarRepository::class, |
| 71 | + 'config', |
| 72 | + )] |
| 73 | + public function __construct( |
| 74 | + protected UserAvatarRepository $userAvatarRepository, |
| 75 | + protected array $config, |
| 76 | + ) { |
| 77 | + } |
| 78 | +} |
| 79 | +``` |
| 80 | + |
| 81 | +The only wiring left is one line in the module's `ConfigProvider`: |
| 82 | + |
| 83 | +```php |
| 84 | +use Dot\DependencyInjection\Factory\AttributedServiceFactory; |
| 85 | + |
| 86 | +'factories' => [ |
| 87 | + UserAvatarService::class => AttributedServiceFactory::class, |
| 88 | +], |
| 89 | +``` |
| 90 | + |
| 91 | +The same `AttributedServiceFactory` entry is repeated for every handler, service and middleware in the module. |
| 92 | +The attribute lists the container identifiers in constructor order, which is the order you must keep in sync by hand. |
| 93 | + |
| 94 | +### The same service with a hand-written factory |
| 95 | + |
| 96 | +The factory below is illustrative: dotkernel/api does not ship it, but it is what the same wiring looks like without the attribute. |
| 97 | + |
| 98 | +```php |
| 99 | +use Core\User\Repository\UserAvatarRepository; |
| 100 | +use Psr\Container\ContainerInterface; |
| 101 | + |
| 102 | +class UserAvatarServiceFactory |
| 103 | +{ |
| 104 | + public function __invoke(ContainerInterface $container): UserAvatarService |
| 105 | + { |
| 106 | + return new UserAvatarService( |
| 107 | + $container->get(UserAvatarRepository::class), |
| 108 | + $container->get('config'), |
| 109 | + ); |
| 110 | + } |
| 111 | +} |
| 112 | +``` |
| 113 | + |
| 114 | +Its `ConfigProvider` entry points to the new class, not to the shared one: |
| 115 | + |
| 116 | +```php |
| 117 | +'factories' => [ |
| 118 | + UserAvatarService::class => UserAvatarServiceFactory::class, |
| 119 | +], |
| 120 | +``` |
| 121 | + |
| 122 | +That is two files instead of one, and a second place to edit whenever the constructor changes. |
| 123 | +In return, the `new UserAvatarService(...)` call is checked statically: a wrong argument type or order is caught by your IDE and static analysis before the code runs. |
| 124 | +With the attribute, the same mistake shows up when the service is built. |
| 125 | + |
| 126 | +### Doctrine repositories |
| 127 | + |
| 128 | +Repositories are where attributes save the most repetition. |
| 129 | +In dotkernel/api, the repository declares which entity it serves: |
| 130 | + |
| 131 | +```php |
| 132 | +use Core\App\Repository\AbstractRepository; |
| 133 | +use Core\User\Entity\UserAvatar; |
| 134 | +use Dot\DependencyInjection\Attribute\Entity; |
| 135 | + |
| 136 | +#[Entity(name: UserAvatar::class)] |
| 137 | +class UserAvatarRepository extends AbstractRepository |
| 138 | +{ |
| 139 | +} |
| 140 | +``` |
| 141 | + |
| 142 | +The module `ConfigProvider` registers it with the dedicated factory: |
| 143 | + |
| 144 | +```php |
| 145 | +use Dot\DependencyInjection\Factory\AttributedRepositoryFactory; |
| 146 | + |
| 147 | +'factories' => [ |
| 148 | + UserAvatarRepository::class => AttributedRepositoryFactory::class, |
| 149 | + UserDetailRepository::class => AttributedRepositoryFactory::class, |
| 150 | + UserRepository::class => AttributedRepositoryFactory::class, |
| 151 | +], |
| 152 | +``` |
| 153 | + |
| 154 | +Without it, each repository needs its own factory that fetches the entity manager and asks it for the right repository, the same boilerplate repeated per entity. |
| 155 | + |
| 156 | +### Where a factory still wins |
| 157 | + |
| 158 | +Some objects cannot be described by a list of container identifiers. |
| 159 | +`ErrorResponseGeneratorFactory` in dotkernel/api reads a config value, with a fallback when the container has no config at all: |
| 160 | + |
| 161 | +```php |
| 162 | +class ErrorResponseGeneratorFactory |
| 163 | +{ |
| 164 | + public function __invoke(ContainerInterface $container): ErrorResponseGenerator |
| 165 | + { |
| 166 | + $config = $container->has('config') ? $container->get('config') : []; |
| 167 | + assert(is_array($config)); |
| 168 | + |
| 169 | + return new ErrorResponseGenerator($config['debug'] ?? false); |
| 170 | + } |
| 171 | +} |
| 172 | +``` |
| 173 | + |
| 174 | +Three things make this a factory job. |
| 175 | +`ErrorResponseGenerator` belongs to Mezzio, so you cannot annotate it. |
| 176 | +The `has()` check is conditional logic. |
| 177 | +The constructor receives a computed scalar, `$config['debug'] ?? false`, not something the container already holds. |
| 178 | + |
| 179 | +`EntityListenerResolverFactory` is another case: it builds an `EntityListenerResolver` that receives the container itself. |
| 180 | + |
| 181 | +```php |
| 182 | +class EntityListenerResolverFactory |
| 183 | +{ |
| 184 | + public function __invoke(ContainerInterface $container): EntityListenerResolver |
| 185 | + { |
| 186 | + return new EntityListenerResolver($container); |
| 187 | + } |
| 188 | +} |
| 189 | +``` |
| 190 | + |
| 191 | +An attribute cannot express "give me the container", and this resolver exists to look up Doctrine entity listeners on demand. |
| 192 | +That is a deliberate exception. |
| 193 | +It does not make the container a general-purpose dependency for your services. |
| 194 | + |
| 195 | +### Using both in one project |
| 196 | + |
| 197 | +The two approaches share the same `ConfigProvider`. |
| 198 | +dotkernel/api's `App` module, for example, wires its services with the attribute and still decorates selected services with delegators: |
| 199 | + |
| 200 | +```php |
| 201 | +'delegators' => [ |
| 202 | + GetIndexResourceHandler::class => [HandlerDelegatorFactory::class], |
| 203 | + ProblemDetailsMiddleware::class => [ProblemDetailsDelegatorFactory::class], |
| 204 | +], |
| 205 | +'factories' => [ |
| 206 | + GetIndexResourceHandler::class => AttributedServiceFactory::class, |
| 207 | + ErrorReportService::class => AttributedServiceFactory::class, |
| 208 | +], |
| 209 | +``` |
| 210 | + |
| 211 | +A service can be built by `AttributedServiceFactory` and still be wrapped by a delegator. |
| 212 | +Which factory applies to which service is decided entirely by the configuration. |
| 213 | + |
| 214 | +### The anti-pattern to avoid |
| 215 | + |
| 216 | +Both approaches keep the container out of your classes. |
| 217 | +This is what PSR-11 warns against: |
| 218 | + |
| 219 | +```php |
| 220 | +class UserAvatarService |
| 221 | +{ |
| 222 | + public function __construct( |
| 223 | + protected ContainerInterface $container, |
| 224 | + ) { |
| 225 | + } |
| 226 | + |
| 227 | + public function deleteAvatar(User $user): void |
| 228 | + { |
| 229 | + $repository = $this->container->get(UserAvatarRepository::class); |
| 230 | + // ... |
| 231 | + } |
| 232 | +} |
| 233 | +``` |
| 234 | + |
| 235 | +The dependencies are now hidden: the constructor signature no longer says what the class needs, and a missing service fails in the middle of a request instead of at construction. |
| 236 | +Tests also have to build a container just to run one method. |
| 237 | +Declare `UserAvatarRepository` in the constructor instead, using either approach from this article. |
| 238 | + |
| 239 | +## Conclusion |
| 240 | + |
| 241 | +Attributes and factories are two ways of delivering the same constructor injection. |
| 242 | +Default to `#[Inject]` when a class only needs services and config the container already holds: it means one file per service, dependencies visible next to the constructor, and uniform error messages. |
| 243 | +Reach for a factory when construction involves logic, when a class is not yours to annotate, or when you need a delegator or a decorated service. |
| 244 | +You do not have to choose once for the whole project, since the `ConfigProvider` decides per service. |
| 245 | + |
| 246 | +To go further, read the [dot-dependency-injection documentation](https://docs.dotkernel.org/dot-dependency-injection/v1/attributes-vs-factories/) and browse the [Dotkernel API](https://github.com/dotkernel/api) source for both styles in a working application. |
| 247 | + |
| 248 | +## FAQ |
| 249 | + |
| 250 | +**Q: Can I mix attributes and factories in the same project?** |
| 251 | +A: Yes. The `ConfigProvider` decides which factory builds each service, so some services can use `AttributedServiceFactory` while others use a hand-written factory. dotkernel/api does exactly that. |
| 252 | + |
| 253 | +**Q: Do attributes slow the application down?** |
| 254 | +A: There is a small cost: the attribute approach uses one `ReflectionClass` per service creation, while a hand-written factory has no reflection when the service is built. The documentation does not publish benchmark figures, so measure your own application if it matters. |
| 255 | + |
| 256 | +**Q: Why does `#[Inject]` lose type checking?** |
| 257 | +A: The attribute lists container identifiers as strings, such as `UserAvatarRepository::class` or `'config'`, and they are resolved at runtime. Static analysis cannot check that they match the constructor's parameter types or order, whereas a factory's `new` call is checked statically. |
| 258 | + |
| 259 | +**Q: When must I write a factory?** |
| 260 | +A: When construction is computed or conditional, when the class is a third-party class you cannot annotate, when you need a delegator or decorated service, or when the dependency is not a constructor argument. |
| 261 | + |
| 262 | +**Q: How does this relate to PSR-11 and the service locator anti-pattern?** |
| 263 | +A: PSR-11 says users should not pass a container into an object so that it can retrieve its own dependencies. Both approaches follow that: the container is used inside the factory, never inside your service, and your service receives its dependencies through the constructor. |
| 264 | + |
| 265 | +**Q: Does `#[Inject]` need Doctrine?** |
| 266 | +A: The package requires Doctrine ORM, but you can use `#[Inject]` for services that have nothing to do with Doctrine. `AttributedRepositoryFactory` and the `#[Entity]` attribute are the Doctrine-specific parts. |
0 commit comments