Package · di

Mental model

Two ideas explain almost everything in this container. First: a type hint is a wiring instruction — the container reads it and builds the whole object graph. Second: a scope decides whether each object in that graph is shared or freshly built. Hold those two, and the rest follows.

Idea 1 — a type hint is the wiring

When you ask for a class, the container looks at its constructor. Every typed parameter is a dependency it must supply, so it resolves each one the same way — recursively — until it reaches classes with no dependencies. Then it builds back up.

text
make(UserService::class)
└─ needs UserRepository
     └─ needs DatabaseConnection
          └─ (no dependencies) → new DatabaseConnection
     ← new UserRepository(db)
← new UserService(repo)

You wrote no wiring code for this. The declared types are the configuration:

php
class UserService
{
  public function __construct(private UserRepository $repo) {}
}

The practical consequence: you stop calling new for services. You describe what a class needs, and the container assembles it. Interfaces are the one case that needs help — the container can’t guess which implementation you mean, so you tell it once with a binding (see Service providers).

Idea 2 — a scope decides sharing

Building the graph is only half the story. The other half is identity: when two classes both need a DatabaseConnection, do they get the same one or their own? That’s the scope.

Scope Identity Built
singleton one shared instance per process once, then cached
request one per HTTP request / coroutine once per request
transient a new instance every time on every resolution

A class picks its scope with an attribute (#[Singleton], #[Request], #[Transient]), or you set it with a binding. With no attribute and no binding the scope is transient — the safe default, because a fresh object can never leak state.

Why the default is transient

A shared object that holds per-request state (the current user, a request id) leaks that state across requests — a real hazard in long-running Swoole workers. Defaulting to transient means nothing is shared until you deliberately say so. Full guidance in Scopes.

Putting them together

Resolution walks the graph (Idea 1); at each node the scope decides whether to reuse a cached instance or build a new one (Idea 2). So a single #[Singleton] DatabaseConnection shared by many repositories is built once, while a #[Transient] QueryBuilder is fresh for each holder — all in the same resolution.

What follows from this

  • Attributes on properties — sometimes you can’t inject through the constructor (a base class owns it). #[Autowired] / #[Inject] on a property inject after construction, still by type. See Injecting into properties.
  • A real cycle is an error — if A needs B and B needs A, recursion can’t terminate. The container detects it and throws. When the cycle is legitimate, #[Lazy] injects a proxy so one side resolves later — see Breaking circular dependencies.
  • The consumer can shape a dependency — because the container knows which class it is injecting into, a contextual() factory can tailor the instance per consumer (a logger named after its user). See Per-consumer logger.

When to reach for it

Great for wiring services, controllers, commands by typeNot for passing runtime data (ids, request payloads) into constructors
  • ✅ Assembling stateless services and their dependencies without boilerplate.
  • ✅ Giving classes clear lifetimes (shared vs per-request vs fresh).
  • ❌ Threading request-specific values through constructors — pass those as method arguments or via make() overrides, not as autowired dependencies.

Next