docs/adr/001-context-keys-and-annotation-lifecycle.md
Accepted
The Context class uses dynamic properties (via __get with prototypical inheritance) to carry metadata about where an annotation was found or created. Understanding these keys is essential for writing processors that create, move, or remove annotations.
Set by the analyser, inherited via the parent chain.
| Key | Type | Meaning |
|---|---|---|
filename | string | Absolute path to the PHP file |
line | int | Line number |
character | int | Column offset |
namespace | string | PHP namespace |
uses | array | Import aliases (['Alias' => 'Full\\Class']) |
class | string | Enclosing class name |
interface | string | Enclosing interface name |
trait | string | Enclosing trait name |
enum | string | Enclosing enum name |
method | string | Enclosing method name |
property | string | Enclosing property name |
static | bool | Whether the method/property is static |
extends | string|array | Parent class or interfaces extended |
implements | array | Interfaces implemented |
comment | string | The raw PHP DocComment |
reflector | \Reflector | Reflection object for the element |
scanned | array | Details from file scanner (ReflectionAnalyser) |
| Key | Type | Meaning |
|---|---|---|
nested | AbstractAnnotation|null | The parent annotation this one is nested inside. null means explicitly not nested (top-level for merge purposes). Absent (not set) means the same as null but via inheritance — is('nested') returns false. |
annotations | list<AbstractAnnotation> | All annotations registered on this context. Shared by annotations at the same source location. |
| Key | Type | Meaning |
|---|---|---|
generated | bool | The annotation/context was created by a processor, type resolver, or serializer (not from source scan). |
version | string | The OpenAPI version in use (set on root context). |
logger | LoggerInterface | PSR logger (guaranteed set when using Generator). |
other | list<AbstractAnnotation> | Non-OpenApi annotations found at this location. |
nested Keyis('nested') worksContext::is() calls property_exists() — it checks whether the property is set directly on this context instance (not inherited from parent). This distinction drives processor behaviour:
is('nested') === true: The property exists on this context. The annotation has an explicit nesting declaration.is('nested') === false: The property is not set. MergeIntoOpenApi/MergeIntoComponents treat this as "top-level, merge into root".| Value | is('nested') | Meaning |
|---|---|---|
AbstractAnnotation instance | true | This annotation is a child of that parent |
null | true | Explicitly marked as having no parent (e.g. parameter-level attributes that should not be merged into root) |
| (not set) | false | Top-level — eligible for merge into OpenApi/Components |
nested is setAbstractAnnotation::__construct() (line 110): Creates a child context ['nested' => $this] for annotations passed as constructor properties.
AbstractAnnotation::merge() (line 156): Same pattern for annotations merged into _unmerged.
AttributeAnnotationFactory (line 76): Sets 'nested' => null for parameter-level attributes (#[Property], #[Parameter], #[RequestBody] on method parameters) that should not be merged into root.
Processors (e.g. MergeJsonContent): Should update _context when relocating an annotation to reflect its new parent.
nested$annotation->_context->nested to find the parent (Response/RequestBody/Parameter) and check instanceof.$annotation->_context->is('nested') === false to find top-level annotations eligible for merging into root.Analysis::addAnnotation($annotation, $context) registers in two places:
$this->annotations (SplObjectStorage) — keyed by annotation, value is context$context->annotations[] — array on the context objectAnalysis::removeAnnotation($annotation) removes from both:
SplObjectStorageWhen a processor creates a new annotation, it must register it with the analysis via addAnnotation(). This ensures the annotation is discoverable by subsequent processors (via getAnnotationsOfType()) and that its context is properly linked into the tree.
$context = new Context(['nested' => $parent, 'generated' => true], $parent->_context);
$annotation = new OA\Schema(['_context' => $context, ...]);
$analysis->addAnnotation($annotation, $context);
Key points:
Context for each annotation — never share a single context instance across multiple annotations. Each annotation needs its own context because addAnnotation() appends to $context->annotations[]. Sharing a context causes unrelated annotations to appear in each other's context, which confuses validation and cleanup. The context's parent chain provides inheritance of location keys (file, class, method), so per-annotation contexts are lightweight._context with 'generated' => true and 'nested' => $parent so the annotation is correctly positioned for validation.addAnnotation() — this registers in both the SplObjectStorage (making it findable by type) and $context->annotations (linking it to its source location).$parent->merge([$annotation]) if the annotation should be nested into the parent's $_nested mapping or end up in _unmerged. This is the normal path for annotations that the parent "owns".addAnnotation() directly (without merge) when the annotation will be consumed by a later processor (e.g., creating a JsonContent that MergeJsonContent will transform). The later processor is responsible for cleanup.If you only call addAnnotation() without placing the annotation into the tree (i.e., it's not reachable from the root OpenApi object via properties or _unmerged), it will be findable via getAnnotationsOfType() but won't be validated or serialized.
When a processor transforms an annotation (e.g., JsonContent becomes a Schema inside a MediaType), it must:
_context — set a new context with nested pointing to the new parent, so tree-walking validation sees it in the correct location._unmerged — so the old parent's validation doesn't warn about unexpected children.$analysis->removeAnnotation() so it's no longer discoverable via getAnnotationsOfType() and the old context's annotations array is cleaned up.Example (from MergeJsonContent):
// Create the new parent
$mediaType = new OA\MediaType(['schema' => $jsonContent, ...]);
// Update context to reflect new position in tree
$jsonContent->_context = new Context(['nested' => $mediaType, 'generated' => true], $mediaType->_context);
// Remove from old parent's _unmerged
array_splice($parent->_unmerged, $index, 1);
// Remove from analysis registry (cleans up SplObjectStorage + old context->annotations)
$analysis->removeAnnotation($jsonContent);
Analysis::validate() does NOT iterate the SplObjectStorage. It calls collectAnnotations() which walks the annotation tree starting from $this->openapi, following all non-blacklisted object properties recursively. This means:
_context->nested value determines whether validation considers the annotation correctly placed.$_parents = [] (like JsonContent) have no valid parent — if still reachable during tree walking, their context must point to a valid parent or they should be transformed into a type that has valid parents.Processors that consume/transform annotations must perform full cleanup:
_context to reflect the annotation's new position in the tree_unmergedremoveAnnotation()The nested context key should use null (not false) to indicate "explicitly no parent" — this matches the declared @property OA\AbstractAnnotation|null type.