Implementation of the idea of the blog post:
https://github.com/voku/itp-context/
Use PHP attributes when the rule belongs to the code
Every serious codebase has rules.
Not formatting rules. Those are easy. Let PHP-CS-Fixer, PHP_CodeSniffer, Rector, PHPStan, and your IDE handle that part.
I mean the rules that decide whether the system survives:
- external APIs stay behind adapters
- domain code does not know HTTP clients
- persistence goes through repositories
- legacy compatibility stays isolated
- security validation happens before data crosses a boundary
These rules exist.
The problem is where they live.
Usually, they live in someone’s head, an old ticket, a forgotten ADR, or a Confluence page that looks official and is already wrong.
That is documentation theater.
Everyone pretends the architecture is documented. Then someone changes the wrong class, bypasses the wrong boundary, and the team suddenly remembers:
“We agreed not to do it like that.”
Great.
Where?
Page 37 of the knowledge base.
Very useful. Very dead.
voku/itp-context solves a narrow problem: attach architecture rules to code with PHP attributes and resolve the rule definition directly from typed enums.
Not magic.
Just better placement.
Detached rules rot
Documentation that lives away from the code has one fatal flaw:
It does not fail.
- It does not fail builds.
- It does not appear in diffs.
- It does not stop a refactor.
- It does not warn a coding agent before it crosses a boundary.
- It waits somewhere else, slowly becoming archaeology.
That is fine for old pottery.
It is not fine when you need to know whether PaymentGateway is allowed to leak HTTP exceptions into your domain layer.
Architecture rules need two things:
- Proximity — the rule must be near the risky code.
- Identity — the rule must have a stable name tools can resolve.
A PHP attribute pointing to an enum case gives you both.
The model is simple:
code symbol
-> enum case
-> getDefinition()
-> statement / owner / rationale / refs / proof
The enum case is the rule identity.
The enum method is the rule definition.
That is the whole trick.
One boundary, one rule
Do not mix topics in examples.
If the rule is about an external API boundary, keep the example about that boundary.
<?php
declare(strict_types=1);
namespace Acme\Context;
use Acme\Tests\Architecture\ExternalApiBoundaryTest;
use ItpContext\Contract\RuleIdentifier;
use ItpContext\Enum\Tier;
use ItpContext\Model\RuleDef;
enum ArchitectureRules implements RuleIdentifier
{
case ExternalApiBoundary;
public function getDefinition(): RuleDef
{
return match ($this) {
self::ExternalApiBoundary => new RuleDef(
statement: 'Keep external API communication behind adapter boundaries.',
tier: Tier::Required,
owner: 'Team-Backend',
rationale: 'Domain and application code must not depend on HTTP clients, transport errors, or provider-specific response formats.',
verifiedBy: [
ExternalApiBoundaryTest::class,
],
refs: [
'docs/adr/external-api-boundaries.md',
],
),
};
}
}
That is enough.
The rule has a typed identity:
ArchitectureRules::ExternalApiBoundary
The rule has a definition:
self::ExternalApiBoundary => new RuleDef(...)
The rule has ownership, rationale, references, and proof.
No stringly-typed sadness.
No floating wiki paragraph.
No “ask Frank, he knows why this exists.”
The rule is project data.
The code marks the boundary
The annotation belongs on the central symbol.
Not every implementation.
Not every method.
The boundary.
<?php
declare(strict_types=1);
namespace Acme\Payment;
use Acme\Context\ArchitectureRules;
use ItpContext\Attribute\Rule;
#[Rule(ArchitectureRules::ExternalApiBoundary)]
interface PaymentGateway
{
public function authorize(PaymentRequest $request): PaymentResult;
}
That is the right place.
A developer changing payment integration code should find this.
A coding agent changing payment integration code should find this.
A reviewer should see it and know:
- provider-specific responses stay outside
- HTTP exceptions do not leak into domain code
- retries and logging belong near the adapter
- error translation happens at the boundary
One rule.
One boundary.
One clear example.
No UI.
No translation.
No unrelated method-level noise.
Why enums are the right home
Strings are a trap.
They always are.
This is fragile:
#[Rule('external-api-boundary')]
Because sooner or later, someone writes:
#[Rule('ExternalApiBoundary')]
#[Rule('external_api_boundary')]
#[Rule('ExternlApiBoundary')]
Congratulations.
You now have fake metadata.
Architecture rule identifiers should be symbols:
#[Rule(ArchitectureRules::ExternalApiBoundary)]
Now the IDE can autocomplete.
Static analysis can see it.
Refactoring tools can rename it.
Typos become errors.
That matters because an architecture rule is not text.
It is code-level context.
So model it like code.
The definition belongs to the enum
The enum should not only name the rule.
It should explain it.
That is what getDefinition() is for.
public function getDefinition(): RuleDef
{
return match ($this) {
self::ExternalApiBoundary => new RuleDef(
statement: 'Keep external API communication behind adapter boundaries.',
tier: Tier::Required,
owner: 'Team-Backend',
rationale: 'Domain and application code must not depend on HTTP clients, transport errors, or provider-specific response formats.',
verifiedBy: [
ExternalApiBoundaryTest::class,
],
refs: [
'docs/adr/external-api-boundaries.md',
],
),
};
}
That keeps the architecture identity and its meaning together.
Good.
Fewer moving parts.
Fewer places for rules to rot.
Fewer chances to pretend the documentation is still true.
The rule definition should answer five boring questions:
- What is the rule?
- How important is it?
- Who owns it?
- Why does it exist?
- What proves or explains it?
Boring questions prevent expensive confusion.
Validation makes it real
Metadata without validation is decoration.
Decoration is where architecture goes to die wearing a nice badge.
Run validation:
vendor/bin/itp-context-validate 'Acme\Context\ArchitectureRules'
The important question is simple:
Does every enum case expose a usable
RuleDef?
Put that into CI.
Do not trust humans to keep metadata clean manually.
Humans forget.
CI does not care about your feelings.
Good.
Export makes it usable
Once the rule is attached to code, it can be exported.
vendor/bin/itp-context-export var/itp-context src --exclude=vendor --exclude=tests
A compact exported context document can look like this:
id: Acme\Payment\PaymentGateway
title: PaymentGateway
source_path: src/Payment/PaymentGateway.php
kind: interface
rule_ids:
- Acme\Context\ArchitectureRules::ExternalApiBoundary
owners:
- Team-Backend
refs:
- docs/adr/external-api-boundaries.md
verified_by:
- Acme\Tests\Architecture\ExternalApiBoundaryTest
annotated_methods: []
rule_count: 1
That is useful context.
Small.
Searchable.
Owned by the repository.
Not buried in a wiki swamp.
Instead of writing this into an agent instruction:
Please follow our architecture.
Write this:
Before changing payment integration code, inspect the exported itp-context entry for Acme\Payment\PaymentGateway. Respect the ExternalApiBoundary rule and check its referenced proof artifacts.
That is better because it points to repository-owned data.
The prompt is not the architecture.
The repository is.
The prompt only tells the agent where to look.
Querying beats asking Frank
Once context is exported, it can be queried.
vendor/bin/itp-context-query var/itp-context --rule-id='Acme\Context\ArchitectureRules::ExternalApiBoundary'
vendor/bin/itp-context-query var/itp-context --owner='Team-Backend'
vendor/bin/itp-context-query var/itp-context --text='external api'
This matters.
Architecture knowledge should not require asking the one person who remembers why the adapter exists.
That person may be in a meeting.
Or on vacation.
Or gone.
Or worse: still here, but tired of explaining the same boundary for the eighth time this month.
Make the repository answer.
Where annotations belong
Annotate central discovery points.
Not the whole tree.
Good places:
- boundary interfaces
- adapter base classes
- application service roots
- persistence contracts
- legacy compatibility seams
- security input boundaries
Bad places:
- every concrete class
- every method
- every helper
- every place where somebody felt nervous
If every class is annotated, nothing is important.
That is not architecture.
That is Confluence with PHP syntax.
The generator should create useful scaffolding
The generator should help you start a rule without turning the project into paperwork.
vendor/bin/itp-context-generate Architecture ExternalApiBoundary src/Context Acme\\Context
That creates or extends the rule enum:
src/Context/ArchitectureRules.php
The generated placeholder should force the right questions:
- What is the statement?
- Who owns it?
- Why does it exist?
- What verifies it?
- Where can I read more?
That is useful scaffolding.
Not ceremony.
What this is not
- This is not a replacement for ADRs.
- It is not a replacement for tests.
- It is not a replacement for PHPStan.
- It is not a replacement for code review.
- It is not an AI framework.
- It is not going to fix bad architecture by sprinkling attributes on top like holy water.
It does one useful thing:
It connects architecture rules to the code symbols where those rules matter.
That is enough.
Small tools are allowed to be useful without pretending to solve the universe.
Best practices
Use itp-context like this:
- define broad rules
- use enum cases as rule identifiers
- put
RuleDefmetadata intogetDefinition() - keep attributes small
- annotate central symbols only
- add owners and rationale
- link ADRs, tests, docs, and proof artifacts
- validate enum definitions in CI
- export compact context
- query exported context when changing boundaries
- delete rules that do not change decisions
The last point matters.
A small rule enum people trust beats a huge rule enum everyone ignores.
Every time.
Summary
The problem is not Confluence.
The problem is architecture knowledge living somewhere else.
If a rule affects code, connect it to code.
voku/itp-context gives you a pragmatic way to do that:
- PHP attributes attach rules to code symbols
- enum cases give rules stable identities
-
getDefinition()keeps the rule definition beside the identity - validation checks whether enum cases expose usable definitions
- exports make context usable for humans and coding agents
- queries make architecture searchable instead of tribal
- the generator creates rule enums with useful placeholders
This is not about making PHP “AI-native.”
Good.
That phrase already smells like a conference booth.
The better goal is simpler:
Make architecture rules explicit enough that humans and tools can find them before they break them.
Because the real enemy was never the LLM.
The real enemy was architecture knowledge living somewhere else.
Usually in Confluence.
Beautiful, confident, and wrong.
Happy coding. 😊
Top comments (4)
I love the idea!
The thing I'm struggling with is, why separate the identifier and the definition?
That makes the enum.
That prevents typo errors in the catalogue.
Good idea, the reason is was splitters into two files was just that in my head there was a enum and something else. I picked up your idea, and updated the code, docs and this post + voku.github.io/itp-context_post/ 😋
"Documentation that doesn't fail doesn't work" is the right principle. A Confluence page saying "external APIs stay behind adapters" is a statement of intent. A PHP attribute that enforces it in the type system is an actual constraint. The gap between them is exactly the gap between having agreed on something and having guaranteed it.
The agent-legibility angle matters increasingly: as coding agents become part of normal development workflows, constraints that live in docs require the agent to know about the docs and apply them during generation. Constraints that live in the code are discovered automatically. Detached rules rot in human workflows; they become invisible in agent-assisted ones. The approach here — attaching rules to code and letting typed enums carry the definition — makes the architecture machine-readable without requiring any special integration.
Yep, that's what I wrote here. 😊