DEPENDENCY INJECTION FEATURE
DI Canon
One canonical DI path for container-managed classes: explicit properties, explicit lifecycles, deterministic boot.
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
Semitexa has one canonical dependency injection model for container-managed framework objects: protected property injection via explicit attributes.
How it works
The container creates the object, injects configuration and service properties, validates the whole graph at boot, and rejects hidden or competing dependency paths. Readonly services are shared per worker, execution-scoped services are cloned per execution, factories stay explicit, and contracts resolve through declared ownership metadata.
Why it matters
The goal is not DI flexibility. The goal is deterministic behavior in a long-running runtime. When the framework uses one visible injection path, boot stays reviewable, graceful reloads stay reliable, and large refactors stop failing because one class quietly used a different dependency pattern.
Key concepts
- canonical DI path
- The single allowed dependency path for container-managed framework objects: explicit attribute-based property injection.
- container-managed framework object
- A discovered framework class such as a service, repository, handler, or listener that the container instantiates and validates.
- execution-scoped
- A lifecycle where a fresh clone is used for one HTTP request, console command, or async execution and then discarded.
- boot-time validation
- The container validates dependency bindings and lifecycle rules during boot so ambiguity fails early and loudly.
Semitexa Canon
One path per concern
The container-managed model is deliberately narrow so boot, tooling, and reload behavior stay deterministic even after large cross-package changes.
Semitexa does not optimize for infinite DI flexibility. It optimizes for clarity under change.
That tradeoff matters because the same application must survive worker reuse, graceful reload, static analysis, and large LLM-assisted refactors without hidden dependency channels.
| Concern | Canonical path | Why it exists |
|---|---|---|
| Service dependency |
#[InjectAsReadonly] or #[InjectAsMutable]
|
A dependency is visible where the class uses it. |
| Scalar config |
#[Config]
|
Configuration stops leaking in through constructor arguments or magic env reads — values arrive on typed #[Config] properties instead. |
| Lifecycle | worker-shared or execution-scoped | State boundaries stay explicit in a long-running worker model. |
| Variant selection | contract metadata or closed-world factory | Selection stays reviewable instead of becoming runtime magic. |
The rest of the DI section shows each piece of this canon in isolation: readonly services, execution scope, factories, and contracts.
Verified against Semitexa Ultimate 2026.09.19.1020
DI Canon
Semitexa has exactly one dependency injection channel for container-managed classes: protected properties carrying an injection attribute.
#[AsService]
final class WebhookConfig
{
#[Config(env: 'WEBHOOK_TIMEOUT_SECONDS', default: 30)]
protected int $defaultTimeoutSeconds;
#[Config(env: 'WEBHOOK_MAX_ATTEMPTS', default: 5)]
protected int $defaultMaxAttempts;
}
That is Semitexa\Webhooks\Configuration\WebhookConfig, trimmed to two of its properties.
Four attributes, and nothing else, feed a container-managed object:
| Attribute | Target | What arrives |
|---|---|---|
#[InjectAsReadonly] |
property | the worker-scoped shared instance — see readonly |
#[InjectAsMutable] |
property | the execution-scoped clone — see mutable |
#[InjectAsFactory] |
property | a ContractFactory selecting among implementations — see factory |
#[Config] |
property | a scalar read from the environment — see configuration |
The constructor rule, exactly
The container builds container-managed objects with newInstanceWithoutConstructor(). Two consequences follow, and they are easy to conflate:
- A
__constructwith parameters is rejected. The container treats it as an attempt to smuggle in a second DI channel and refuses to build the class. - A parameterless
__constructis tolerated but never called. This is the one that bites: initialisation you put there silently does not run. Initialise in property declarations instead.
Constructors are entirely unrestricted on anything the container does not manage — value objects, DTOs, payloads, resources, entities. The rule is about container-managed classes, not about PHP.
Boot validation, and the two tools that enforce it
The graph is validated at boot, so an unresolvable dependency fails the worker rather than the first request that touches it. The container is also sealed after boot: calling set() afterwards throws ContainerSealedException. There is no runtime registration and no service locator.
You do not have to wait for boot to find out:
bin/semitexa lint:di
[OK] All 363 container-managed classes pass DI lint.
It reports the precise reason per class — a constructor with parameters, an unannotated service property, two competing injection attributes on one property.
PHPStan carries the same rules into the editor. The ones you are most likely to meet:
| Rule | Rejects |
|---|---|
StaticContainerAccessRule |
reaching for the container statically instead of injecting |
UnannotatedServicePropertyRule |
a service-typed property with no injection attribute |
ExecutionScopedWithoutAttributeRule |
per-execution state on a class that never opted into execution scope |
TraitInjectionRule |
injection attributes inside a trait — declare them in the consuming class |
Why one path
The goal is not flexibility. In a long-running Swoole worker, mixed DI styles turn into boot fragility and cross-request state leaks. One visible channel keeps the dependency graph locally readable, lets boot validation and static analysis reject ambiguity before runtime, and makes large refactors survivable.
How it works
The container creates the object, injects configuration and service properties, validates the whole graph at boot, and rejects hidden or competing dependency paths. Readonly services are shared per worker, execution-scoped services are cloned per execution, factories stay explicit, and contracts resolve through declared ownership metadata.
Why it matters
The goal is not DI flexibility. The goal is deterministic behavior in a long-running runtime. When the framework uses one visible injection path, boot stays reviewable, graceful reloads stay reliable, and large refactors stop failing because one class quietly used a different dependency pattern.
Key concepts
- canonical DI path
- The single allowed dependency path for container-managed framework objects: explicit attribute-based property injection.
- container-managed framework object
- A discovered framework class such as a service, repository, handler, or listener that the container instantiates and validates.
- execution-scoped
- A lifecycle where a fresh clone is used for one HTTP request, console command, or async execution and then discarded.
- boot-time validation
- The container validates dependency bindings and lifecycle rules during boot so ambiguity fails early and loudly.