DEPENDENCY INJECTION FEATURE
Mutable Injection
Execution-scoped services get a fresh clone every run — safe state without contaminating the worker.
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
#[ExecutionScoped] opts a class into a per-execution clone; #[InjectAsMutable] marks the properties re-injected on that clone.
Execution Scope
Fresh clone for every execution
Mutable services can safely accumulate transient state because the container clones them for each execution context.
Any state collected during one execution is discarded when that HTTP request, console command, or async job completes.
#[InjectAsMutable]
protected ExecutionBag $bag;
// Each execution gets a fresh clone:
// $this->bag !== <previous execution\'s bag>
Use mutable injection for execution-specific context objects, not for shared worker services.
Verified against Semitexa Ultimate 2026.09.19.1020
Mutable Injection
Two attributes work together here, and mixing them up is the usual mistake:
#[ExecutionScoped]goes on the class. It says: build a prototype at boot, then clone it for every execution.#[InjectAsMutable]goes on a property. It says: this dependency is re-injected on each clone, so it is safe to hold per-execution state.
#[AsService]
#[ExecutionScoped]
final class IriBuilder
{
#[InjectAsReadonly]
protected ResourceMetadataRegistry $registry;
#[InjectAsMutable]
protected Request $request;
}
That is Semitexa\Core\Resource\IriBuilder, and it shows both tiers in one class: the metadata registry is worker-shared because it has no per-execution state, while the request is re-injected on every clone.
The #[AsService] is not redundant here. #[ExecutionScoped] sets the tier; it does not make a class container-managed on its own. #[AsService] is what puts the class under the container in the first place — see declaring a service.
An execution is a request, a console run, or an async job
"Execution scope" is not "request scope". The same boundary applies to a console command run and to an async job, which is what makes it the right unit in a long-running worker.
Most classes never write #[ExecutionScoped]
It is implied by the three attributes that already mark per-execution work:
#[AsPayloadHandler]#[AsEventListener]#[AsPipelineListener]
So a handler is already execution-scoped and simply uses #[InjectAsMutable] for the things that change per request:
#[AsPayloadHandler(payload: ..., resource: ...)]
final class DefaultNotFoundPageHandler
{
#[InjectAsMutable]
protected Request $request;
}
Write #[ExecutionScoped] explicitly only for a class that needs per-execution state without being a handler or listener — IriBuilder above is exactly that case.
What goes wrong without it
A worker-scoped service that quietly accumulates per-request state is the classic long-running-runtime bug: the second request sees the first request's data, and only under load. PHPStan's ExecutionScopedWithoutAttributeRule flags per-execution state on a class that never opted in, which is why the failure usually shows up in analysis rather than in production.
The inverse mistake is cheaper but still real: marking something execution-scoped that has no state at all just buys a clone per request for nothing. Shared is the default for a reason — see readonly injection.