Use attributes instead of neon to register services
I want to open a discussion about registering services with attributes above classes instead of neon files.
I know about the idea behind the current way, I was there 13 years ago when it was conceived 😅 The idea is that classes shouldn't care about DI, about the way they are constructed. They should only care about asking for dependencies in constructor.
People should be encouraged to register the same class multiple times with different arguments, for reusability and to truly distinguish OOP from "class-based programming".
But in practice 90 % of services are one-off autowired classes. And the reality is that .neon files become huge mess with thousands of lines of code in big applications. Something like this: https://github.com/phpstan/phpstan-src/blob/2.1.17/conf/config.neon
I prototyped and implemented these attributes in phpstan-src. Thanks to this I was able to cut my config.neon to just 250 lines (https://github.com/phpstan/phpstan-src/blob/4997fb752ac4d4c7cc32eecca5d26a2d14d04dd8/conf/config.neon). Autowired services are now registered via attributes, and the non-autowired services are in a separate file (https://github.com/phpstan/phpstan-src/blob/4997fb752ac4d4c7cc32eecca5d26a2d14d04dd8/conf/services.neon).
A service like this:
-
class: PHPStan\File\FileMonitor
arguments:
analyseFileFinder: @fileFinderAnalyse
scanFileFinder: @fileFinderScan
analysedPaths: %analysedPaths%
analysedPathsFromConfig: %analysedPathsFromConfig%
scanFiles: %scanFiles%
scanDirectories: %scanDirectories%
Can now be registered like this:
#[AutowiredService]
final class FileMonitor
{
/**
* @param string[] $analysedPaths
* @param string[] $analysedPathsFromConfig
* @param string[] $scanFiles
* @param string[] $scanDirectories
*/
public function __construct(
#[AutowiredParameter(ref: '@fileFinderAnalyse')]
private FileFinder $analyseFileFinder,
#[AutowiredParameter(ref: '@fileFinderScan')]
private FileFinder $scanFileFinder,
#[AutowiredParameter]
private array $analysedPaths,
#[AutowiredParameter]
private array $analysedPathsFromConfig,
#[AutowiredParameter]
private array $scanFiles,
#[AutowiredParameter]
private array $scanDirectories,
)
{
}
...
}
A service like this:
-
class: PHPStan\Reflection\SignatureMap\NativeFunctionReflectionProvider
arguments:
reflector: @betterReflectionReflector
Can now be registered like this:
#[AutowiredService]
final class NativeFunctionReflectionProvider
{
/** @var NativeFunctionReflection[] */
private array $functionMap = [];
public function __construct(
private SignatureMapProvider $signatureMapProvider,
#[AutowiredParameter(ref: '@betterReflectionReflector')]
private Reflector $reflector,
private FileTypeMapper $fileTypeMapper,
private StubPhpDocProvider $stubPhpDocProvider,
private AttributeReflectionFactory $attributeReflectionFactory,
)
{
}
...
}
If the original neon service has autowired key, I implemented as as attribute parameter. From:
-
class: PHPStan\PhpDoc\DefaultStubFilesProvider
arguments:
stubFiles: %stubFiles%
composerAutoloaderProjectPaths: %composerAutoloaderProjectPaths%
autowired:
- PHPStan\PhpDoc\StubFilesProvider
to:
#[AutowiredService(as: StubFilesProvider::class)]
final class DefaultStubFilesProvider implements StubFilesProvider
{
/**
* @param string[] $stubFiles
* @param string[] $composerAutoloaderProjectPaths
*/
public function __construct(
private Container $container,
#[AutowiredParameter]
private array $stubFiles,
#[AutowiredParameter]
private array $composerAutoloaderProjectPaths,
)
{
}
...
}
But what I like best is how I did the implement key. The attribute is called GeneratedFactory. From:
-
implement: PHPStan\Reflection\FunctionReflectionFactory
arguments:
parser: @defaultAnalysisParser
to:
#[GenerateFactory(interface: FunctionReflectionFactory::class)]
final class PhpFunctionReflection implements FunctionReflection
{
public function __construct(
private InitializerExprTypeResolver $initializerExprTypeResolver,
private ReflectionFunction $reflection,
#[AutowiredParameter(ref: '@defaultAnalysisParser')]
private Parser $parser,
...
)
{
}
The downside of implement in neon is that the arguments do not belong to FunctionReflectionFactory, but in reality to the return type of FunctionReflectionFactory::create(). With the GenerateFactory attribute, we can put AutowiredParameter above the parser parameter which is much more intuitive and closer together.
This is just some food for thought, maybe nette/di can introduce something like that in the future. In phpstan-src I was able to implement all of this (and a little bit more PHPStan-specific stuff) in around 130 lines of code: https://github.com/phpstan/phpstan-src/blob/4997fb752ac4d4c7cc32eecca5d26a2d14d04dd8/src/DependencyInjection/AutowiredAttributeServicesExtension.php
What helped me a lot too is the https://github.com/olvlvl/composer-attribute-collector package.
Since I don't deal with such problems, I need to explain it better. Do I understand correctly that this mainly concerns parameters, which are something different than objects? Or does this also concern passing services? What is insufficient about the current autowiring, what exactly is it missing? What is actually the benefit of removing N lines from the configuration file and adding N longer lines to the code file? I understand that you have it there, but I don't see it that way.
ping @ondrejmirtes
Generally when speaking about code quality, IMHO it's better to have 35 files times 100 lines of code instead of 1 file with 3000 lines of code. It's 500 more but it's more manageable.
Looking at reactions on GitHub I think people would like this. There was also a short discussion about this on Pehapkari Slack (https://pehapkari.slack.com/archives/C2R30BLKA/p1749196847871209).
Also it shows strength of nette/di that I could implement this myself with a custom compiler extension. But having it part of nette/di would make people excited I think.
I think the attributes would be a nice alternative to the search (https://doc.nette.org/en/dependency-injection/configuration#toc-search) extension which relies on conventions like directory structure and class naming conventions.
When you have one large neon file with all registered services, you need to rely on fulltext search to be able to find something in it. Being able to replace a - service neon entry with an attribute above the class looks pretty natural.
Unrelated to nette/di I've also seen people using attributes to register presenter routes - which is better than having one large file with all the route definitions:
#[Route('/kontakt')]
public function actionContact(): void
hello, @ondrejmirtes and @dg
i have implemented something similiar with very limited capability in a private project.
here, i have published the relevant code and added some of my notes in the readme https://github.com/slepic/olvlvl-composer-attribute-collector-nette-demo
maybe there is something you could find useful or convincing why people might want this feature
cheers
@ondrejmirtes please try to talk with me like with a small stupid child.