Dependency injection
Objects in the application are assembled by the container. You declare what a
class needs — by type — and it builds that dependency, its dependencies in turn, and
hands over the finished tree. The word new all but disappears from application
code.
What dependency injection is and why
A dependency is any object a class cannot work without: a controller needs a service, a service needs a repository, a repository needs a database connection.
The problem. When a class builds its own dependencies, it has to know how each of them is assembled:
$service = new UserService(
new UserRepository(new Connection(env('DB_DSN'), env('DB_USER'), env('DB_PASS'))),
new Logger('user'),
);That chain repeats everywhere UserService is needed. Add an argument to the
repository and you edit every one of those places. Swapping an implementation — a
cache in production, a stub in a test — is only possible by changing code. And every
call rebuilds everything, including what should be one per process.
The solution. The class declares what it needs; how to build it is the container’s business:
class UserService
{
public function __construct(
private UserRepository $repo,
private LoggerInterface $logger,
) {}
}From then on UserService comes from the container, which builds the repository,
the connection and the logger itself. The class stops depending on how things are
assembled, and swapping an implementation becomes configuration rather than
editing.
This page is about using it
Here is what you need in order to write an application. The full treatment of the container — resolution, proxies, the reflection cache, providers — is in the winter-di package documentation.
How the container builds objects
At startup the scanner walks the project and remembers classes that carry a scope, along with configuration classes. It is the same walk that finds controllers and routes — there is no separate step.
Then, when something asks for an object, the container:
- checks whether a binding or a ready instance exists for that type;
- if not, reads the class constructor;
- builds each argument recursively, by its type;
- creates the object and fills properties marked
#[Autowired]; - caches the result if the scope calls for it.
You rarely ask for an object by hand: controllers, middleware, console command handlers and background tasks are all created by the framework.
The scan is at startup, not per request
The project walk happens once, at boot. With DEBUG=false the resulting class list
is also written to di.php so the filesystem is not walked again; that affects
startup speed, not behaviour. A new class is picked up by a restart — in
development php call run dev does that for you.
Declaring a dependency
There are four ways, and they are not equivalent: the first is the norm, the rest solve particular problems.
Through the constructor
The basic way; no attribute required. Declaring the type is enough:
<?php
namespace Main;
use Psr\Log\LoggerInterface;
class UserService
{
public function __construct(
private UserRepository $repo,
private LoggerInterface $logger,
) {}
public function find(int $id): User
{
$this->logger->info('lookup', ['id' => $id]);
return $this->repo->findOrFail($id);
}
}Write it this way wherever the constructor is yours to use: dependencies are visible
in the signature, the class can be assembled by hand in a test, and readonly
properties remain possible.
Through a property — #[Autowired]
When the constructor is taken or unavailable, the dependency goes straight into a property:
use Flytachi\Winter\DI\Attribute\Autowired;
class PostController extends Controller
{
#[Autowired] private PostService $service;
#[Autowired] private LoggerInterface $logger;
}The property may be private — the container fills it immediately after creating
the object, before control reaches any of your methods.
In controllers this is the only way
Controller::__construct() is declared final and takes no arguments, so
constructor injection is impossible there and #[Autowired] is the only option. In
services, repositories and middleware the choice is yours.
Being specific — #[Inject]
Type alone does not answer everything: an interface does not say which
implementation to use, and a number or a string cannot be resolved by type at all.
#[Inject] covers those cases — it works on a constructor argument and on a
property alike.
use Flytachi\Winter\DI\Attribute\Inject;
class ReportService
{
public function __construct(
// 1. by type — the same as no attribute, just explicit
#[Inject] private CacheInterface $cache,
// 2. a specific implementation, bypassing the shared binding
#[Inject(FileCache::class)] private CacheInterface $fallback,
// 3. a value by key, placed into the container beforehand
#[Inject('config.timeout')] private int $timeout,
) {}
}The second form is for when one class needs a different implementation from everybody else. The third is the only way to receive a scalar: the container cannot tell numbers and strings apart by type.
Deferred — #[Lazy]
#[Lazy] substitutes a proxy for the object; the real instance is created on
first use.
Its main job is breaking a cycle. If A needs B and B needs A, ordinary
resolution recurses forever and fails. Marking one side #[Lazy] is enough:
use Flytachi\Winter\DI\Attribute\Lazy;
class SmsSendService
{
public function __construct(
#[Lazy] private PeerService $peer, // built on first use
) {}
}A proxy needs a concrete class
The proxy stands in for a class, so a bare interface cannot be proxied — there is
nothing to stand in for. Name a concrete type, or combine with a hint:
#[Inject(RedisCache::class), Lazy].
A dependency cycle is nearly always a sign that two classes need a third. #[Lazy]
solves the problem, but it is worth checking first whether the classes should be
split. The detailed treatment is in the
package documentation.
Scopes
A scope answers the question of how many instances of a class exist and how long they live.
| Attribute | How many instances | What for |
|---|---|---|
| none (default) | A new one per injection | Anything holding state |
#[Singleton] |
One per worker process | Stateless classes: repositories, clients, factories |
#[Request] |
One per request; per coroutine under Swoole | The current request’s data |
#[Transient] |
A new one per injection — stated explicitly | When you want to say so in code |
The default is not a singleton
A class with no attribute is built afresh on every injection. That is a safe
default — a shared object cannot accidentally leak between requests — but the price
is visible: a service that opens a connection or holds a warmed cache will do it
again and again. Mark such classes #[Singleton] deliberately.
If you come from Spring or Laravel, where services are shared by default, this behaviour is the opposite of what you expect.
Request data — #[Request]
The instance lives for exactly one request and is visible to everyone who asks for it within that request. Concurrent requests each get their own — under Swoole the isolation is per coroutine; under PHP-FPM a request is a process anyway.
<?php
namespace Main;
use Flytachi\Winter\DI\Attribute\Request;
#[Request]
class AuthContext
{
private ?User $user = null;
public function setUser(User $user): void { $this->user = $user; }
public function user(): User
{
return $this->user ?? throw new \LogicException('No authenticated user');
}
}The typical use: authentication middleware puts the user here, controllers and services read it. That pattern is walked through step by step on the Middleware page.
The dangerous combination is caught at boot
A singleton holding a #[Request] dependency is a hidden bug: the first request’s
object would be stuck inside it for the worker’s whole life and handed to everyone
else. Winter walks the dependency graph at load time and refuses to start, showing
the chain A → $prop: B.
Configuration — #[Configuration] and #[Bean]
Automatic assembly is enough while concrete classes are involved. But an interface does not say which implementation to take; an object from a third-party library may need unusual assembly; a connection string has to come out of the environment. All of that is described in a configuration class.
<?php
namespace Main;
use Flytachi\Winter\Kernel\App\Attribute\Bean;
use Flytachi\Winter\Kernel\App\Attribute\Configuration;
use Flytachi\Winter\Kernel\App\Attribute\Value;
#[Configuration]
final class AppConfig
{
#[Bean]
public function cache(#[Value('REDIS_URL')] string $url): CacheInterface
{
return new RedisCache($url);
}
#[Bean]
public function mailer(
#[Value('MAIL_HOST', 'localhost')] string $host,
LoggerInterface $logger,
): MailerInterface {
return new SmtpMailer($host, $logger);
}
}The class needs no registration — the same scan finds it. From then on any class
asking for CacheInterface receives the RedisCache assembled here.
How it works:
| Element | What it does |
|---|---|
#[Configuration] on a class |
Declares the class a collection of factories; it becomes a singleton itself |
#[Bean] on a method |
Makes the method a factory; the binding key is the return type |
| An ordinary method parameter | Autowired from the container, as in a constructor |
#[Value('KEY', $default)] |
Substitutes a value from .env |
The method must return an object and must declare a return type — the binding is built from it.
Bean scope
By default a bean is a singleton: the method runs once and the result is reused. Note the difference from ordinary classes, where the default is the opposite.
use Flytachi\Winter\Kernel\App\Scope;
#[Bean(scope: Scope::Transient)] // a new one on every resolve
public function query(): QueryBuilder
{
return new QueryBuilder();
}
#[Bean(scope: Scope::Request)] // one per request
public function correlation(): CorrelationId
{
return new CorrelationId(bin2hex(random_bytes(8)));
}Several beans of one type
When you need two of the same type, the by-type binding is distinguished by name:
#[Bean(name: 'cache.redis')]
public function redis(): CacheInterface { /* ... */ }
#[Bean(name: 'cache.file')]
public function file(): CacheInterface { /* ... */ }Such a bean is taken by naming the key:
public function __construct(
#[Inject('cache.redis')] private CacheInterface $cache,
) {}Bindings from code
Sometimes a binding is easier to describe imperatively — assembled in a loop, or chosen by a condition. For that there is a provider: a class the container hands itself to so that bindings can be written into it.
$c->singleton(CacheInterface::class, RedisCache::class); // one per process
$c->request(AuthContext::class); // one per request
$c->transient(QueryBuilder::class); // a new one every time
$c->bind(MailerInterface::class, fn(Container $c) => new SmtpMailer(env('MAIL_HOST')));
$c->set('config.timeout', (int) env('APP_TIMEOUT', 30)); // a ready value| Method | What it does |
|---|---|
singleton($abstract, $concrete) |
A binding with one instance per process |
request($abstract, $concrete) |
A binding with one instance per request |
transient($abstract, $concrete) |
A new instance on every resolve |
bind($abstract, $concrete) |
A class or a factory closure |
set($id, $value) |
A ready value by key — what #[Inject('key')] receives |
contextual($abstract, $factory) |
A different implementation depending on who is asking |
make($abstract, $overrides) |
Build an object by hand |
register($providerClass) |
Attach a provider holding a group of bindings |
contextual() is the least ordinary of them: the factory receives the consuming
class name and can return something different. That is exactly how the logger works
— an #[Autowired] LoggerInterface arrives already named after the class it was
injected into.
Which to choose
#[Configuration] with #[Bean] covers most needs: the bindings are visible in one
file, the types are checked, the arguments are autowired. The imperative methods are
for cases where the set of bindings is computed at runtime.
When the container cannot build
All six failures arrive as PSR-11 exceptions, and the message names the actual cause — they are fixed in six different places:
| What you asked for | Exception | What to do |
|---|---|---|
| a name that exists nowhere | NotFoundException |
class not found — check the autoloader and that the package is installed |
| an interface with no binding | NotFoundException |
bind an implementation: bind(), singleton() or #[Bean] |
| a trait | NotFoundException |
ask the container for the class that uses it |
| an abstract class | ContainerException |
bind it to a concrete one |
| an enum | ContainerException |
hand back a specific case through a factory |
| a private constructor | ContainerException |
provide it through a factory — bind() with a closure, or #[Bean] |
The split follows PSR-11 exactly: NotFoundException means “there is nothing to build”,
ContainerException means “found, but it cannot be built”. Which is why they can be
caught apart:
try {
$gateway = $container->get(PaymentGateway::class);
} catch (NotFoundExceptionInterface $e) {
// nothing to build — no binding, or the class is not installed
} catch (ContainerExceptionInterface $e) {
// found, but not buildable — abstract, enum, private constructor, circular
}The most expensive misreading
Class [Dep\Main\ClientRepository] not found is not a container problem. The class is
not on disk: a typo in the name, a package that was never installed, or an autoloader that
cannot reach it — a common one being a path repository whose symlink did not survive the
trip into a Docker container.
#[Autowired] has nothing to do with it: the container builds any class by
reflection, third-party ones from vendor/ included, and no prior scanning is needed for
that.
An exception from a constructor stays yours: if the body of __construct() throws, it
arrives exactly as thrown — the container does not relabel it, because that would bury
the one message saying where the break actually is.
Tooling — call di
To see what the container knows about the project, and to manage the cache:
php call di show # every class in the container cache
php call di show Main\Service # only those matching a name fragment
php call di build # walk the project once and build the cache
php call di clean # delete the cache and the generated proxies
php call di async # #[Async] methods and the state of their proxiesThe command earns its keep when a class “is not found”: if it is absent from show,
it never reached the scan — check that the file is not under resources/ or
storage/ and that the class is not abstract.
Next
- Winter DI — full reference — resolution, proxies, providers
- Controllers — where
#[Autowired]is mandatory - Middleware — how the
#[Request]context gets filled - Configuration — the rest of the application’s settings