DEPENDENCY INJECTION FEATURE
Readonly Injection
One explicit DI path, one shared worker instance — fast at runtime and stable under reload.
Feature Guide
A quick orientation block that answers the essential questions: what this feature does, how it works, why it matters, and the key concepts behind it.
What this does
The default tier — one instance per worker, injected into a protected property, with optional injection for dependencies that may not be installed.
Worker Scope
One boot, reused per request
This handler receives readonly services through visible property attributes only. Their object IDs stay stable for the life of the worker.
| Service | Scope | Object ID |
|---|---|---|
| DemoCatalogService | worker |
#460
|
| DemoSourceCodeReader | worker |
#461
|
| DemoExplanationProvider | worker |
#462
|
Object IDs stay stable across executions, so readonly services avoid repeated allocation and do not depend on hidden constructor wiring.
Verified against Semitexa Ultimate 2026.09.19.1020
Readonly Injection
#[InjectAsReadonly] is the tier you want unless you have a reason to want another. The container resolves the dependency once during boot and hands every consumer the same instance for the life of the worker process.
#[InjectAsReadonly]
protected ResourceMetadataRegistry $registry;
Three structural requirements, all enforced:
- The property is protected. Private hides it from the injector; public invites mutation from outside.
- The type is a named class or interface. Union types,
mixedand scalars have nothing to resolve. - The declaration is in the class itself, not in a trait —
TraitInjectionRulerejects injection attributes inside traits.
There is no assignment and no initialisation: the property is declared and left alone. The container fills it before anything can call the object.
When the binding is missing
By default this is a hard failure at boot, not a null at runtime:
Cannot inject {class}::${property}: container has no binding for {type}.
That is deliberate. A missing dependency stops the worker starting rather than surfacing as a null dereference on whichever request first touches that path.
Optional injection
Two ways to say "fill this if you can":
#[InjectAsReadonly(optional: true)]
protected ?SearchIndexer $indexer;
A nullable property type is itself recorded as optional, so ?SearchIndexer behaves the same way without the argument. Either way, when the container has no binding the property is simply skipped.
The escape hatch is narrow on purpose. Only the missing binding case is forgiven — the structural rules above still throw, because a private property or an untyped one is a mistake that a default of null would only hide.
Reach for it when a dependency comes from a package that may not be installed. Do not reach for it to quieten a boot error you have not read.
Readonly cannot depend on execution-scoped
Injecting an execution-scoped type into a readonly property throws. The reason is structural: the shared instance would capture one execution's clone and hand it to every later execution — precisely the cross-request contamination the tiers exist to prevent. If a shared service needs per-request data, it should receive it as a method argument, or the class itself belongs in the execution-scoped tier.