PHP 8 added attributes, and most codebases adopted them the way they had used docblock annotations: as metadata hanging off a class that some service reads at runtime with reflection.
That works. It is also the least interesting thing you can do with them.
The useful framing is that an attribute is a declaration of intent that a build step can collect. Once you collect at build time rather than read at request time, two things fall out: the wiring cannot drift from the code, and it costs nothing at runtime.
The problem being solved
Every framework needs to know which class handles what. The traditional answers are a configuration file, a service-provider method, or a naming convention.
All three fail the same way. The handler exists, the registration does not, and nothing complains until a message arrives in production and no subscriber picks it up. The failure is silent, it is at runtime, and it happens at the worst possible time — when the code is correct and only the bookkeeping is wrong.
A convention-based scan fixes the drift and introduces a different problem: now a rename changes behaviour, and a class in the wrong directory does nothing.
Declaring at the point of definition
The alternative is to put the registration on the thing being registered:
use Vortos\Cqrs\Attribute\AsCommandHandler;
#[AsCommandHandler]
final readonly class ApproveEntryHandler
{
public function __construct(
private EntryRepository $entries,
private Clock $clock,
) {}
public function __invoke(ApproveEntry $command): void
{
$entry = $this->entries->get($command->entryId);
$entry->approve($this->clock->now());
$this->entries->save($entry);
}
}
There is no file to update. The class and its registration cannot disagree, because they are the same edit. Delete the class and the registration goes with it — which is the case that convention scanning and config files both handle badly.
Collect once, at build time
The part that makes this worth doing is when the collection happens.
Scanning attributes with reflection on every request is how annotation libraries worked, and it is why they had a reputation for being slow. Do it once during the container build instead, and write the result into the compiled container:
- Build time: walk the source tree, read attributes, resolve each handler to the message type in its signature, emit a map.
- Request time: look up a key in an array.
Reflection cost per request: none. The dispatcher is a hash lookup.
One mechanism, many concerns
Once the collector exists, everything that needs registering uses the same machinery. In our own application code these are the attributes that carry real weight:
| Attribute | Declares |
|---|---|
#[AsCommandHandler] | This class handles this command |
#[AsQueryHandler] | This class answers this query |
#[AsEventHandler] | This method reacts to this domain event |
#[RegisterConsumer] | This class consumes from this stream |
#[Route] | This method answers this HTTP request |
#[RequiresPermission] | This endpoint needs this permission |
#[RateLimit] | This endpoint is bounded at this rate |
#[Scheduled] | This command runs on this cadence |
#[AuditLog] | This action is written to the audit trail |
The consistency matters more than any individual entry. A developer who has learned how one of them works has learned how all of them work, and the answer to “where is this wired up?” is always “on the thing itself”.
Where it goes wrong
Two failure modes are worth knowing about before you build this.
Attributes that carry logic. An attribute should declare what something is, not how it behaves. The moment an attribute argument starts encoding a strategy — retry curves, conditional branches — you have put program logic in a place where it cannot be tested or stepped through. Keep them declarative.
Cache invalidation in development. A compiled container that does not notice a new handler is a genuinely confusing ten minutes: the code is right, the build is stale, and nothing in the error says so. The container needs to be rebuilt when source changes in development, and built once in production. Getting this wrong is the main tax of the approach, and it is worth solving properly on day one rather than debugging repeatedly.
The general point
Attributes are usually described as a nicer syntax for docblock annotations. They are more useful understood as the place where a program declares its own structure — and a build step that reads those declarations can enforce things a runtime never gets the chance to.
The bug class that disappears is “the code was right but nobody wired it up”. That one is worth removing.