← Dependency Injection

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.

InjectAsReadonly InjectAsMutable InjectAsFactory Config lint:di

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.

Container resolution at worker bootContainer resolution at worker boot. Class discovery: Finds managed classes. Injection metadata: Reads property attributes. Contract bindings: Selects implementations. Managed instance: Injects protected properties. Sealed container: Serves stable workersinspectresolveconstructvalidateClass discoveryFinds managed classesInjection metadataReads property attributesContract bindingsSelects implementationsManaged instanceInjects protectedpropertiesSealed containerServes stable workers
Container resolution at worker boot
#[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 __construct with 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 __construct is 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.

© Harold Abelson: "Programs must be written for people to read, and only incidentally for machines to execute."

Handler Application entry point
<?phpdeclare(strict_types=1);namespace App\Application\Handler\Container;use App\Application\Payload\Container\DiOverviewPayload;use App\Application\Resource\Page\DiOverviewResource;use Semitexa\Core\Attribute\AsPayloadHandler;use Semitexa\Core\Contract\TypedHandlerInterface;#[AsPayloadHandler(payload: DiOverviewPayload::class, resource: DiOverviewResource::class)]final class DiOverviewHandler implements TypedHandlerInterface{    public function handle(DiOverviewPayload $payload, DiOverviewResource $resource): DiOverviewResource    {        return $resource->withCanon(['readonly', 'mutable', 'factory', 'contracts']);    }}

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.

Support Semitexa
Built for developers who prefer control over magic. Your support helps keep it fast, open, and evolving.

Donate via PayPal