Getting started

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.

Injection by typeConfiguration #[Configuration] / #[Bean]Package winter-di

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:

php
$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:

php
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:

  1. checks whether a binding or a ready instance exists for that type;
  2. if not, reads the class constructor;
  3. builds each argument recursively, by its type;
  4. creates the object and fills properties marked #[Autowired];
  5. 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:

main/UserService.php
<?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:

php
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.

php
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:

php
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.

main/AuthContext.php
<?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.

main/AppConfig.php
<?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.

php
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:

php
#[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:

php
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.

php
$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:

php
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:

bash
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 proxies

The 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