Three lines of YAML in a file two directories away, for one integer that one service reads. That was the first thing I converted when I started moving a project’s service config from YAML into attributes, to see where the line is.

public function __construct(
    #[Autowire('%env(int:IMPORT_BATCH_SIZE)%')]
    private int $batchSize,
) {}

Now the class tells you everything about itself. Same with #[TaggedIterator] for collecting all implementations of an interface, and #[AsDecorator] for wrapping a service. These are local facts. A local fact belongs next to the code it describes. When I open the class, I want to stop searching.

Then the bad case, on the same project. Someone put #[When(env: 'prod')] and environment-specific wiring on half the services. To understand what runs in production you now open twenty classes and read attributes on each. Which mailer is real and which is a null object, which cache backend, what is decorated in test. This is policy. Policy should sit in one file you can read top to bottom. YAML, PHP config, does not matter. One place.

The rule I ended up with: if the configuration answers “what does this class need”, attribute. If it answers “how is this application assembled”, central config. The second question has one answer per environment and it deserves one page.

Attributes are not a new idea, annotations did the same for routing years ago. What is new is that container support is finally complete enough that YAML becomes optional. Optional, not forbidden. Keep it for the assembly.

I found the line by crossing it first.