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.