Если вы писали хоть сколько-нибудь сложный код, то наверняка сталкивались с зависимостями между классами. Эта статья поможет понять, как сделать работу с такими зависимостями чистой и управляемой.

Цель статьи — дать начинающим PHP-разработчикам практическое понимание того, как работает внедрение зависимостей (DI) и контейнер внедрения зависимостей (DI-контейнеры), а также показать, как эти принципы применяются в современных фреймворках.

Для понимания примеров необходимы знания базового синтаксиса php.

Полный код примеров можно посмотреть в репозитории.

Статья состоит из двух частей:

  • В первой части будут рассмотрены основные концепции внедрения зависимостей (DI), осознание и приведен пример реализации собственного DI-контейнера.

  • Во второй части будут рассмотрены реализации DI-контейнеров в популярных PHP-фреймворках: Symfony, Laravel и Yii3.

Часть 1. Как работает DI и DI-контейнер в PHP: понятный путь от нуля до autowiring.

Шаг 1. Пример корзины интернет-магазина.

Давайте предположим, перед вами стоит задача реализовать функциональность подсчёта стоимости корзины в интернет-магазине. В общем, задача простая, перебрать все товары в корзине, просуммировать их стоимость.

Базовые классы для реализации:

- Модель данных - CartItem. Описывает товар в корзине и управляет его количеством и стоимостью.

class CartItem
{
    public function __construct(
        public readonly int $id,
        private int $count,
        private int $price
    ) {}

    public function getCost(): int
    {
        return $this->price * $this->count;
    }
}

- Слой хранения - SimpleStorage. Представляет простое хранилище данных корзины в сессии. Отвечает за сохранение и загрузку списка товаров.

class SimpleStorage
{
    public function __construct(
        private readonly string $key
    ) {}

    /**
     * @return CartItem[]
     */
    public function load(): array
    {
        return isset($_SESSION[$this->key])
            ? unserialize($_SESSION[$this->key], ['allowed_classes' => [CartItem::class]])
            : [];
    }

    /**
     * @param CartItem[] $data
     */
    public function save(array $data): void
    {
        $_SESSION[$this->key] = serialize($data);
    }
}

- Слой логики расчёта - SimpleCalculator. Отвечает за вычисление общей стоимости всех товаров в корзине.

class SimpleCalculator
{
    /**
     * @param CartItem[] $items
     * @return int
     */
    public function getCost(array $items): int
    {
        $cost = 0;
        foreach ($items as $item) {
            $cost += $item->getCost();
        }
        return $cost;
    }
}

- Слой бизнес-логики - Cart.

Теперь необходимо реализовать функционал. Как это можно решить быстро? 1. Получить список товаров и хранилища. 2. Рассчитать общую стоимость корзины.

class Cart
{
    /** @var CartItem[] */
    private array $items = [];

    private bool $loaded = false;

    public function getCost(): int
    {
        $this->loadItems();

        return (new SimpleCalculator())->getCost($this->items);
    }

    private function loadItems(): void
    {
        if ($this->loaded) {
            return;
        }

        $this->items = (new SimpleStorage('cart'))->load();
        $this->loaded = true;
    }
}

Точка входа в приложение:

$cart = new Cart();
echo $cart->getCost() . PHP_EOL;

Проблема зависимостей

Как работает реализованный выше код? Потребовалось хранилище - создали new SimpleStorage и начали работать с хранилищем. Потребовался калькулятор - создали new SimpleCalculator и начали работать с калькулятором.

Класс Cart жестко зависит от конкретных реализаций SimpleStorage и SimpleCalculator, из-за этого возникнут проблемы при масштабировании и тестировании.

Получается, класс Cart содержит в себе и бизнес-логику и создает нужные зависимости.

Если, например, появится необходимость изменить хранилище SimpleStorage на DbStorage, или калькулятор SimpleCalculator на, скажем, DiscountCalculator, то придётся менять код внутри Cart, нарушая принцип открытости/закрытости (Open/Closed Principle, OCP) из SOLID.

Шаг 2. Получение зависимостей извне

Давайте улучшим код. Пусть зависимости, которые нужны компоненту Cart, передавались извне, а не создавались в нем.

class Cart
{
    /** @var CartItem[] */
    private array $items = [];
    
    private bool $loaded = false;

    public function __construct(
        private readonly SimpleCalculator $calculator,
        private readonly SimpleStorage $storage
    )
    {}

    public function getCost(): int
    {
        $this->loadItems();

        return $this->calculator->getCost($this->items);
    }

    private function loadItems(): void
    {
        if ($this->loaded) {
            return;
        }

        $this->items = $this->storage->load();
        $this->loaded = true;
    }
}

Также необходимо изменить код точки входа:

$cart = new Cart(new SimpleCalculator(), new SimpleStorage('cart'));
echo $cart->getCost() . PHP_EOL;

Сейчас мы получили явное внедрение зависимостей.

Cart больше не создаёт ни SimpleCalculator, ни SimpleStorage внутри себя. Эти компоненты передаются в конструктор, то есть ответственность за создание объектов перенесена наружу.

Внедрение зависимостей (dependency injection, DI) - грубо говоря, всё сводится к тому, что зависимости создаются вне объекта и передаются ему уже готовыми. Сам объект не отвечает за создание зависимостей, он просто использует их.

Есть несколько способов внедрения зависимостей: через отдельный метод-сеттер или через конструктор.

Внедрение зависимости через конструктор имеет преимущества:

  • Нельзя забыть передать зависимость в нуждающийся объект.

  • Зависимость легко увидеть, посмотрев на конструктор.

Теперь наша корзина стала чище.

Однако есть некоторые неудобства. В частности, наша Корзина жестко зависит от конкретной реализации хранилища SimpleStorage и подсчета итоговой суммы SimpleCalculator или их наследников, с измененным поведением (что уже удобнее для тестирования).

Однако при масштабировании нам нужно больше гибкости - именно поэтому следующим шагом станет использование интерфейсов.

Шаг 3. Зависимость от интерфейсов

А что, если мы решим хранить данные в БД? Или, например, хранить корзину не авторизованных пользователей в сессии, а авторизованных — в базе данных?

А если сумму считать с учетом какой-нибудь скидки?

Давайте изменим код класса Cart таким образом, чтобы он зависел не от конкретной реализации, а мог работать со всеми объектами, реализующими нужные интерфейсы. Для этого создадим интерфейсы:

interface StorageInterface
{
    /** 
     * @return CartItem[] 
     */
    public function load(): array;

    /**
     * @param CartItem[] $data 
     */
    public function save(array $data): void;
}

interface CalculatorInterface
{
    /**
     * @param CartItem[] $items
     * @return int
     */
    public function getCost(array $items): int;
}

Теперь класс SimpleStorage должен реализовать интерфейс StorageInterface, а класс SimpleCalculator - CalculatorInterface:

class SimpleStorage implements StorageInterface
{
    // Код не меняется.
}

class SimpleCalculator implements CalculatorInterface
{
    // Код не меняется.
}

Класс Cart:

class Cart
{
    public function __construct(
        private readonly CalculatorInterface $calculator,
        private readonly StorageInterface $storage
    )
    {}
    
    // Остальной код не меняется.
}

Теперь мы используем на полную полиморфизм, можем в любой момент поменять хранилище или калькулятор.

Также на этом шаге мы добились инверсии зависимостей.

Принцип Dependency Inversion Principle (DIP) — это принцип из SOLID, который гласит, что модули верхнего уровня не должны зависеть от модулей нижнего уровня — все должны зависеть от абстракций.

По сути, DI (Dependency Injection) — это один из подходов к реализации инверсии управления IoC (Inversion of Control), который помогает реализовать DIP (Dependency Inversion Principle).

Отступление

IoC (Inversion of Control) — это архитектурный принцип, при котором контроль за выполнением программы (или созданием объектов, управлением зависимостями, жизненным циклом) переносится от пользовательского кода к внешней системе (фреймворку, контейнеру и т. п.).

Также одним из подходов к реализации инверсии управления IoC (Inversion of Control) является SL (Service Locator) - когда объект сам запрашивает свои зависимости из специального реестра.

- При DI: зависимости создаются (или конфигурируются) где-то снаружи и передаются объекту;

- При SL: зависимости тоже создаются снаружи (например, заранее регистрируются в Service Locator-е), но объект сам знает, что ему нужно сходить в Service Locator и получить их. Например, клас Cart выглядел бы так:

class Cart {
    public function __construct(ServiceLocator $locator) {
        $this->storage = $locator->get(StorageInterface::class);
    }
}

Шаг 4. Создание простого DI контейнера

На предыдущих шагах мы добились того, что Cart больше не зависит от конкретных реализаций, а работает с абстракциями. Но остаётся важная проблема — управление зависимостями.

Предположим, нам необходимо использовать корзину в нескольких местах в коде, например:

  • в контроллере каталога товаров (чтобы показать сумму и количество),

  • на странице оформления заказа (checkout),

  • в тестах и фоновом анализе.

В каждом из этих случаев нам нужно создавать экземпляр Cart и передавать в него CalculatorInterface и StorageInterface, а они, в свою очередь, могут иметь свои зависимости.

Это приводит к «ручной сборке дерева зависимостей» — неудобно, сложно и легко ошибиться.

А что если нужен будет один и тот же экземпляр Cart в разных местах? Хотя из сессий и БД все равно будут одни и те же данные, но все же.

Было бы круто, если нужный нам объект создавался "на лету" там, где он нам потребуется.

Это и есть роль DI контейнера.

Требования к контейнеру внедрения зависимостей описаны в стандарте PSR-11: Container Interface.

Этот стандарт определяет минимальный контракт, которому должен соответствовать любой DI контейнер, чтобы быть совместимыми между библиотеками и фреймворками. А именно, он описывает методы: get() и has().

Давайте реализуем свой простой DI-контейнер и посмотрим, как он упростит нам жизнь.

Для этого создадим класс Container, который реализует Psr\Container\ContainerInterface из PSR-11.

Дополнительно кроме методов get() и set(), реализуем свой метод set, который будет регистрировать анонимную функцию (создания нужного объекта).

Контейнер по нашему требованию будет создавать и возвращать нужный объект:

class Container implements ContainerInterface
{
    private array $definitions = [];

    public function set($id, $callback): void
    {
        $this->definitions[$id] = $callback;
    }

    public function get($id)
    {
        if (!$this->has($id)) {
            throw new ServiceNotFoundException('Undefined service: ' . $id);
        }
        return call_user_func($this->definitions[$id], $this);
    }

    public function has(string $id): bool
    {
        return isset($this->definitions[$id]);
    }
}

class ServiceNotFoundException extends \Exception implements NotFoundExceptionInterface
{
}

Такой подход позволит регистрировать хоть сколько компонентов под разными именами, передавая только функции, которые их будет создавать, вместо того чтобы сразу их создавать.

Изменим код точки входа:

//___________ этот код можно вынести куда-нибудь в конфигурационный файл
$container = new Container();
$container->set(SimpleStorage::class, fn () => new SimpleStorage('cart'));

$container->set(SimpleCalculator::class, fn () => new SimpleCalculator());

$container->set(Cart::class, fn (Container $container) => new Cart(
    $container->get(SimpleCalculator::class),
    $container->get(SimpleStorage::class))
);
//___________

$cart = $container->get(Cart::class);
echo $cart->getCost() . PHP_EOL;

Получился простейший вариант получения зависимостей через контейнер. Мы регистрируем сервисы, используя в качестве идентификатора название полное название класса.

Требуемые объекты создаются только в тот момент, когда их запрашивают. Таким образом, можно зарегистрировать множество объектов и не бояться переполнения памяти.

Однако весь код по созданию объектов мы перенесли в анонимную функцию и регистрируем их. Это тоже не совсем удобно.

А было бы круто, если наш контейнер сам умел бы парсить конструктор класса перед созданием объекта, и если там указана какая-то зависимость, то автоматически создал бы.

Тогда при регистрации сервиса не нужно было бы передавать анонимную функцию.

Шаг 5. Автоматическое разрешение зависимостей (autowiring)

Реализовать autowiring можно с помощью рефлексии — она позволит контейнеру "заглянуть" в конструктор класса и автоматически создать все необходимые зависимости.

Давайте расширим наш контейнер: если сервис зарегистрирован как строка (имя класса), то создавать его через рефлексию, иначе — вызывать анонимную функцию.

class Container implements ContainerInterface
{
    private array $definitions = [];

    public function set(string $id, string|callable $callback): void
    {
        $this->definitions[$id] = $callback;
    }

    public function get($id)
    {
        if (!$this->has($id)) {
            throw new ServiceNotFoundException('Undefined service: ' . $id);
        }

        $definition = $this->definitions[$id];

        $component = is_string($definition)
            ? $this->make($definition)
            : $definition($this);

        if (!$component) {
            throw new ContainerException('Undefined component ' . $id);
        }

        return $component;
    }

    public function has(string $id): bool
    {
        return isset($this->definitions[$id]);
    }

    private function make(string $definition): ?object
    {
        if (!class_exists($definition)) {
            return null;
        }

        $reflection = new ReflectionClass($definition);
        $arguments = [];
        if (($constructor = $reflection->getConstructor()) !== null) {
            foreach ($constructor->getParameters() as $param) {
                $paramClass = $param->getType();

                $arguments[] = $paramClass ? $this->get($paramClass->getName()) : null;
            }
        }

        return $reflection->newInstanceArgs($arguments);
    }
}

PSR-11: Container Interface предусматривает, что идентификатором сервиса может быть любая допустимая PHP строка, состоящая как минимум из одного символа, которая однозначно идентифицирует элемент внутри контейнера. На практике чаще всего идентификаторами выступают имена интерфейсов.

Давайте изменим код регистрации сервисов, в соответствии с последними улучшениями:

$container = new Container();
$container->set(StorageInterface::class, fn () => new SimpleStorage('cart'));
$container->set(CalculatorInterface::class, SimpleCalculator::class);
$container->set(Cart::class, Cart::class);

$cart = $container->get(Cart::class);
echo $cart->getCost() . PHP_EOL;

Теперь наш контейнер умеет автоматически разрешать зависимости с помощью рефлексии.

Но есть проблема: при каждом вызове $container->get(Cart::class) создается новый экземпляр класса Cart.

PSR-11: Container Interface предусматривает:

Два последовательных вызова get с одним и тем же идентификатором ДОЛЖНЫ возвращать одно и то же значение. Однако, в зависимости от конфигураций, могут быть возвращены разные значения, поэтому НЕ СТОИТ полагаться на получение одного и того же значения при двух последовательных вызовах.

Давайте реализуем такой функционал, при котором во время регистрации сервиса была бы возможность указывать параметр, определяющий, как будут возвращаться сервис: при каждом вызове новый или же один и тот же.

Шаг 6. Контроль создания сервиса

Контейнер должен уметь управлять жизненным циклом сервиса:

  • Singleton — один экземпляр создаётся и используется повторно.

  • Prototype — создаётся новый экземпляр при каждом запросе.

Можно реализовать это по-разному:

  • Завести два метода: set() и setShared(). При регистрации сервисов через метод set(), при получении будет возвращаться новое значения. А через метод setShared() - новый объект будет создан только при первом вызове, а последующие вызовы будут возвращать уже созданный экземпляр.

  • Добавить флаг $isShared в set(), по умолчанию сделать true - возвращать одно и то же значение при последовательных вызовах. Если для какого-то сервиса нужно, чтоб он каждый раз создавался указать false.

Выберем первый вариант - два метода:

class Container implements ContainerInterface
{
    private array $definitions = [];
    private array $shared = [];

    public function set(string $id, string|callable $callback): void
    {
        $this->shared[$id] = null;
        $this->definitions[$id] = [
            'value' => $callback,
            'shared' => false,
        ];
    }

    public function setShared(string $id, string|callable $callback): void
    {
        $this->shared[$id] = null;
        $this->definitions[$id] = [
            'value' => $callback,
            'shared' => true,
        ];
    }

    public function get($id)
    {
        if (!$this->has($id)) {
            throw new ServiceNotFoundException('Undefined service: ' . $id);
        }

        if (isset($this->shared[$id])) {
            return $this->shared[$id];
        }

        if (array_key_exists($id, $this->definitions)) {
            $definition = $this->definitions[$id]['value'];
            $shared = $this->definitions[$id]['shared'];
        } else {
            $definition = $id;
            $shared = false;
        }

        $component = is_string($definition)
            ? $this->make($definition)
            : $definition($this);

        if (!$component) {
            throw new ContainerException('Undefined component ' . $id);
        }

        if ($shared) {
            $this->shared[$id] = $component;
        }

        return $component;
    }

    public function has(string $id): bool
    {
        if (isset($this->definitions[$id])) {
            return true;
        }
        if (class_exists($id)) {
            return true;
        }
        return false;
    }

    private function make(string $definition): ?object
    {
        if (!class_exists($definition)) {
            return null;
        }

        $reflection = new ReflectionClass($definition);
        $arguments = [];
        if (($constructor = $reflection->getConstructor()) !== null) {
            foreach ($constructor->getParameters() as $param) {
                $paramClass = $param->getType();

                $arguments[] = $paramClass ? $this->get($paramClass->getName()) : null;
            }
        }

        return $reflection->newInstanceArgs($arguments);
    }
}

Метод get() проверяет, есть ли уже созданный экземпляр. Если есть — возвращает его, иначе создаёт новый.

Метод has() - будет возвращать true в двух случаях: есть зарегистрированный сервис под указанным идентификатором или есть класс с указанным названием.

Регистрация сервисов:

$container = new Container();
$container->set(StorageInterface::class, fn () => new SimpleStorage('cart'));
$container->set(CalculatorInterface::class, SimpleCalculator::class);
$container->setShared(Cart::class, Cart::class);

Чтобы убедиться, что сервис Cart является Singleton, добавим публичное свойство description:

class Cart
{
    public string $description = 'default value';
    
    // Остальной код не меняется.
}

Теперь можем проверить:

/** @var Cart $cart */
$cart = $container->get(Cart::class);
echo $cart->description . PHP_EOL; // default value
$cart->description = 'this object is singleton';

$cart = $container->get(Cart::class);
echo $cart->description . PHP_EOL; // this object is singleton

В итоге мы добились того, что при регистрации сервиса через метод setShared(), контейнер сохраняет созданные ранее объекты-сервисы и при повторном запросе сервиса не создавать вторую его копию, а возвращать ранее созданный объект.

Шаг 7. Регистрация скалярных параметров

Сейчас наш контейнер умеет внедрять только классы, используя getType() и рекурсивный вызов get().

Но если конструктор принимает скалярные параметры (например, string, int, bool и т.п.), он подставляет null, что работает только, если есть значения по умолчанию.

У нас как раз есть сервис SimpleStorage, у которого конструктор в качестве аргумента принимает строку.

Если мы зарегистрируем сервис просто указав имя класса $container->set(StorageInterface::class, SimpleStorage::class);, возникнет ошибка: Undefined service: string.

Контейнер не сможет создать SimpleStorage, потому что не знает, какое значение передать в параметр типа string.

Поэтому мы решали эту проблему регистрацией зависимости с параметром $container->set(StorageInterface::class, fn () => new SimpleStorage('cart'));.

Однако мы хотим добиться того, чтобы это работало и с autowiring, без ручного объявления.

Можно это решить несколькими способами, например,

  • Разделять регистрацию параметров и регистрацию сервисов.

  • Передача параметров при регистрации как дополнительный аргумент метода set() и setShared().

Реализуем первый вариант - разделим регистрацию параметров и регистрацию сервисов. Заведем отдельное свойство $parameters, добавим методы setParameter и getParameter. А в методе make() добавим обработку параметров.

class Container implements ContainerInterface
{
    private array $definitions = [];
    private array $parameters = [];
    private array $shared = [];

    public function setParameter(string $name, mixed $value): void
    {
        $this->parameters[$name] = $value;
    }

    public function getParameter(string $name): mixed
    {
        if (!array_key_exists($name, $this->parameters)) {
            throw new \InvalidArgumentException("Parameter '$name' not found.");
        }
        return $this->parameters[$name];
    }

    public function set(string $id, string|callable $callback, array $arguments = []): void
    {
        $this->shared[$id] = null;
        $this->definitions[$id] = [
            'value' => $callback,
            'shared' => false,
            'arguments' => $arguments,
        ];
    }

    public function setShared(string $id, string|callable $callback, array $arguments = []): void
    {
        $this->shared[$id] = null;
        $this->definitions[$id] = [
            'value' => $callback,
            'shared' => true,
            'arguments' => $arguments,
        ];
    }

    public function get($id)
    {
        if (!$this->has($id)) {
            throw new ServiceNotFoundException('Undefined service: ' . $id);
        }

        if (isset($this->shared[$id])) {
            return $this->shared[$id];
        }

        if (array_key_exists($id, $this->definitions)) {
            $definition = $this->definitions[$id]['value'];
            $shared = $this->definitions[$id]['shared'];
            $arguments = $this->definitions[$id]['arguments'];
        } else {
            $definition = $id;
            $shared = false;
            $arguments = [];
        }

        $component = is_string($definition)
            ? $this->make($definition, $arguments)
            : $definition($this);

        if (!$component) {
            throw new ContainerException('Undefined component ' . $id);
        }

        if ($shared) {
            $this->shared[$id] = $component;
        }

        return $component;
    }

    public function has(string $id): bool
    {
        if (isset($this->definitions[$id])) {
            return true;
        }
        if (class_exists($id)) {
            return true;
        }
        return false;
    }

    private function make(string $definition, array $forcedArguments = []): ?object
    {
        if (!class_exists($definition)) {
            return null;
        }

        $reflection = new ReflectionClass($definition);
        $arguments = [];
        if (($constructor = $reflection->getConstructor()) !== null) {
            foreach ($constructor->getParameters() as $index => $param) {
                if (array_key_exists($index, $forcedArguments)) {
                    $arg = $forcedArguments[$index];

                    // Поддержка %param%
                    if (is_string($arg) && preg_match('/^%(.+)%$/', $arg, $matches)) {
                        $arg = $this->getParameter($matches[1]);
                    }

                    $arguments[] = $arg;
                    continue;
                }

                $paramClass = $param->getType();

                $arguments[] = $paramClass ? $this->get($paramClass->getName()) : null;
            }
        }

        return $reflection->newInstanceArgs($arguments);
    }
}

Теперь можем зарегистрировать параметр cart_store:

$container = new Container();
$container->setParameter('cart_store', 'cart');
$container->set(StorageInterface::class, SimpleStorage::class, ['%cart_store%']);
$container->set(CalculatorInterface::class, SimpleCalculator::class);
$container->setShared(Cart::class, Cart::class);

$cart = $container->get(Cart::class);
echo $cart->getCost() . PHP_EOL;

Второй вариант предлагаю попробовать реализовать самостоятельно.

После реализации всех семи шагов у нас получился примитивный DI контейнер. Да, конечно, его нужно еще дорабатывать, добавить проверки типа: "если зарегистрировать 2 сервиса под одинаковым именем должна вызывать выброс исключения ContainerException, так как у каждого сервиса должно быть свое, уникальное имя", и так далее, однако считаю, что для понимания принципа работы DI контейнера, мы реализовали достаточно.

В первой части статьи мы прошли путь от жёстких зависимостей до гибкого DI-контейнера с autowiring и управлением жизненным циклом.


Часть 2. Реализации DI-контейнеров в популярных PHP-фреймворках

Конечно, современный разработчик не будет писать свой DI контейнер. В реальных проектах используются готовые и мощные решения.

Поэтому, когда мы разобрались с принципами работы DI-контейнеров, давайте посмотрим, как они реализованы в популярных PHP-фреймворках.

Symfony

Установка компонента dependency-injection фреймворка Symfony через композер: composer require symfony/dependency-injection.

Регистрация сервисов через Symfony\Component\DependencyInjection\ContainerBuilder:

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

$container = new ContainerBuilder();
$container->setParameter('cart_store', 'cart');

$container->register(StorageInterface::class, SimpleStorage::class)
    ->addArgument('%cart_store%')
    ->setShared(false);

$container->register(CalculatorInterface::class, SimpleCalculator::class)
    ->setShared(false);

$container->register(Cart::class, Cart::class)
    ->addArgument(new Reference(CalculatorInterface::class))
    ->addArgument(new Reference(StorageInterface::class))
    ->setShared(true);

Обратите внимание, аргумент конструктора класса SimpleStorage мы зарегистрировали как отдельный параметр: $container->setParameter('cart_store', 'cart');, а затем сослались на него addArgument('%cart_store%').

Также можно передавать напрямую addArgument('cart').

Вызов метода setShared(false) означает, что каждый раз при запрашивании сервиса будет создаваться новый экземпляр.

Так как сервис Cart зависит от сервисов хранилища и калькулятор, то необходимо указать контейнеру внедрить зависимости при инициализации сервиса Cart.

В Symfony можно использовать конфигурацию YAML (чаще встречается на практике). Для этого нужно установить компонент Config: composer require symfony/config.

Пример файла services.yaml:

parameters:
  cart_store: cart

services:
  StorageInterface:
    class: App\Storage\SimpleStorage
    arguments: ['%cart_store%']
    shared: false

  CalculatorInterface:
    class: App\Calculator\SimpleCalculator

  App\Cart:
    class: App\Cart
    arguments: ['@CalculatorInterface', '@StorageInterface']

Код загрузки yaml:

$container = new \Symfony\Component\DependencyInjection\ContainerBuilder();
$loader = new \Symfony\Component\DependencyInjection\Loader\YamlFileLoader(
    $container,
    new Symfony\Component\Config\FileLocator(__DIR__)
);
$loader->load('services.yml');


/** @var Cart $cart */
$cart = $container->get(Cart::class);
echo $cart->getCost() . PHP_EOL;

Итак, мы рассмотрели два базовых приёма работы с контейнером Symfony:

Также есть способ через атрибуты.

Этих подходов достаточно для понимания базовых принципов DI в Symfony и создания собственных примеров.

Важно понимать, что в реальных проектах Symfony контейнер создаётся и управляется самим фреймворком, а сервисы внедряются автоматически — напрямую запрашивать сервисы у контейнера вручную, как в примерах, обычно не требуется.

Symfony поддерживает автоматическое внедрение зависимостей (autowiring), когда вам не нужно указывать аргументы вручную — контейнер сам определит, что нужно передать. Для этого достаточно в конфигурации включить опцию autowire: true. Аналогично autoconfigure: true позволяет автоматически применять теги и т.п.

Laravel

Установка компонента dependency-injection фреймворка Laravel через композер: composer require illuminate/container.

Регистрация сервисов в Laravel осуществляется через класс Illuminate\Container\Container():

$container = new \Illuminate\Container\Container();

$container->bind(StorageInterface::class, fn () => new SimpleStorage('cart'));
$container->bind(CalculatorInterface::class, fn () => new SimpleCalculator());
$container->singleton(Cart::class, fn ($container) => new Cart(
    $container->make(CalculatorInterface::class),
    $container->make(StorageInterface::class)
));

Что здесь стоит отметить:

  • нет "параметров" как в Symfony — просто передаём нужные значения в конструктор (можно через замыкания (closure)).

  • Вместо register + addArgument — используется bind() с фабрикой (анонимной функцией).

  • Для синглтона — вызываем singleton(), аналог setShared(true) в Symfony.

Контейнер Laravel сам вызовет make() и внедрит нужные зависимости.

Использование DI контейнера Laravel представлено здесь.

Yii3

Установка компонента dependency-injection фреймворка yii3 через композер: composer require yiisoft/di.

Контейнер из пакета yiisoft/di работает с массивом конфигураций. Регистрация сервисов выглядит следующим образом:

$config = ContainerConfig::create()
    ->withDefinitions([
        StorageInterface::class => [
            'class' => SimpleStorage::class,
            '__construct()' => ['cart'],
        ],
        CalculatorInterface::class => SimpleCalculator::class,
        Cart::class => [
            '__construct()' => [
                Reference::to(CalculatorInterface::class),
                Reference::to(StorageInterface::class),
            ],
        ],
    ]);

$container = new Container($config);

Можно сохранить определения (definitions) в .php файле.

По умолчанию все сервисы являются экземплярами singleton. Если нужно получать новый объект каждый раз, необходимо управлять: 'definitionScope' => \Yiisoft\Di\Definition\DefinitionScope::PROTOTYPE.

Использование DI контейнера Yii3 представлено здесь.

Следует отметить, что использование контейнера напрямую — плохая практика.

Гораздо лучше положиться на автоматическое подключение, предоставляемое Injector, доступным в пакете yiisoft/injector.

Во второй части статьи мы рассмотрели DI контейнеры фреймворков Symfony, Laravel, Yii3 и пришли к выводу, что в реальных проектах используют готовые DI-контейнеры фреймворков, но понимание их работы помогает писать более качественный код.


Заключение

В статье шаг за шагом были рассмотрены понятия: внедрение зависимостей (DI), контейнер внедрения зависимостей (DI-контейнер) и автоматическое разрешение зависимостей (autowiring). Также затронули понятия инверсии управления (IoC) и принцип инверсии зависимостей (DIP).

Мы разработали собственный контейнер, который показал, как можно автоматически разрешать зависимости (autowiring) с помощью рефлексии, контролировать создание сервисов (одиночные экземпляры – Singleton, или новые при каждом запросе – Prototype) и даже обрабатывать скалярные параметры. Разумеется, наш самописный контейнер является лишь учебным примером, требующим дальнейшей доработки для использования в реальных проектах.

Также рассмотрели реализацию DI контейнеры фреймворков Symfony, Laravel, Yii3.

В конечном итоге понимание DI и работы DI-контейнеров — это ключевой навык для любого PHP-разработчика, стремящегося писать чистый, поддерживаемый, тестируемый и масштабируемый код. Эти знания не только помогут вам создавать более качественные приложения, но и позволят глубже разобраться в архитектуре современных фреймворков и библиотек.

Комментарии (4)


  1. FanatPHP
    06.07.2025 09:21

    Большое спасибо за статью! Особенно порадовало упоминание Yii3.

    Но не могу удержаться от небольшого замечания, тем более, что этот код уж очень диссонирует с толковостью остальной статьи своей вопиющей бессмысленностью. Да, я про try/catch/echo $e->getMessage(). С большой натяжкой его наличие можно оправдать отключённым при разработке информированием об ошибках. Но в этом случае человеку надо садиться учить самые основы, а не внедрение зависимостей. А если информирование об ошибках включено - как это и должно быть - то этот кусок кода становится полностью бессмысленным, поскольку РНР прекрасно выведет сообщение об ошибке и без каких-либо телодвижений со стороны разработчика.

    Плюс опять же непонятно, зачем заведомо ухудшать информативность сообщения об ошибке, вырезая из неё такую информацию, как место возникновения ошибки, трассировка стека и тип исключения. Зачем было городить разные типы исключений на каждый чих, если мы всё равно информацию о типе не используем?

    Ну и в довершение надо всегда помнить, что такой блок зачастую переезжает из примеров в прямиком в продакшен, где становится уже попросту вредным, вываливая внутренние ошибки клиенту.

    Я понимаю, что PHPStorm, как и другие анализаторы, постоянно дёргает за штаны, "Караул, неотловленное исключение, всё пропало!". Но на мой взгляд, это одна из инспекций, которая приносит гораздо больше вреда, чем пользы - приводя к такому вот подходу, "написать абы что, лишь бы мамка не ругалась". Надо не вестись, а наоборот, укорачивать этой инспекции ручки в настройках, ну или хотя бы аннотациями.


    1. second-cat-engineer Автор
      06.07.2025 09:21

      Спасибо за замечание, поправил. Действительно, блок try/catch здесь бессмыслен.


  1. FanatPHP
    06.07.2025 09:21

    Ещё такой вопрос возник при прочтении. В случае, если у нас есть две разных реализации интерфейса, как контейнер с автоматическим разрешением зависимостей различает, какую именно использовать? Я правильно понимаю, что чистого решения у этой проблемы нет, и мы в итоге всё равно указываем конкретный класс - только не при вызове, а где-то в конфигурации?


  1. samizdam
    06.07.2025 09:21

    Реализовал подобное лет 10 назад, для для проекта на zend 1-2 https://github.com/FreeElephants/php-di Чтобы распутать статические вызовы и сделать код тестируемым.

    С тех пор хорошо библиотека хорошо себя показала в проде в проектах на самописах или без больших фреймворков, где нужен простой di.

    Symfony с yaml, имхо проигрывает нативным php-конфигам, особенно с сахаром и типизацией последних версий.