Dependency Injection в PHP на практике: простое объяснение с примерами

Комментариев: 0
13 мин.
Добавить в закладки

Dependency Injection — один из ключевых принципов современной разработки на PHP. В этой статье вы разберётесь, как он работает на практике, зачем нужен и как применять его в реальных проектах. Разберём понятные примеры, типичные ошибки и посмотрим как DI используется в Laravel и Symfony.

Когда разработчик впервые сталкивается с Dependency Injection (DI) в PHP, всё это нередко звучит слишком «архитектурно»: контейнеры, абстракции, интерфейсы, внедрение зависимостей. На словах кажется, будто речь идёт о чём-то сложном и нужном только в больших проектах.

Но на практике всё гораздо проще.

Dependency Injection — это не абстрактная теория и не «магия фреймворков». Это вполне прикладной подход, который помогает писать код так, чтобы его было проще поддерживать, тестировать и расширять. И чем раньше вы начнёте использовать DI осознанно, тем легче будет расти как разработчику.

В этой статье разберём:

  • что такое Dependency Injection простыми словами;
  • зачем он нужен в реальной разработке;
  • как выглядит DI в обычном PHP-коде без фреймворков;
  • какие ошибки чаще всего допускают начинающие разработчики;
  • как Dependency Injection реализован в Symfony и Laravel;
  • какие примеры можно использовать уже сегодня.

Что такое Dependency Injection простыми словами

Dependency Injection — это способ передавать объекту его зависимости извне, а не создавать их прямо внутри самого класса.

Звучит формально, поэтому лучше сразу посмотреть на пример.

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

<?php

class EmailSender
{
    public function send(string $to, string $message): void
    {
        // Отправка email
    }
}

class OrderService
{
    public function completeOrder(int $orderId, string $email): void
    {
        $mailer = new EmailSender();
        $mailer->send($email, "Заказ #{$orderId} завершён");
    }
}

На первый взгляд всё работает. Но здесь есть проблема: OrderService сам создаёт зависимость через new EmailSender().

То есть класс не просто выполняет свою бизнес-задачу, но ещё и решает, каким именно способом отправлять уведомление. В результате код становится жёстко связанным.

Теперь посмотрим на тот же пример через Dependency Injection.

<?php

interface NotificationSenderInterface
{
    public function send(string $to, string $message): void;
}

class EmailSender implements NotificationSenderInterface
{
    public function send(string $to, string $message): void
    {
        // Отправка email
    }
}

class OrderService
{
    public function __construct(
        private NotificationSenderInterface $sender
    ) {
    }

    public function completeOrder(int $orderId, string $email): void
    {
        $this->sender->send($email, "Заказ #{$orderId} завершён");
    }
}

Теперь OrderService не создаёт зависимость сам. Он просто говорит: «Чтобы я работал, мне нужен объект, который умеет отправлять уведомления».

Это и есть главная идея DI.

Зачем нужен Dependency Injection в PHP

У Dependency Injection есть вполне практичные преимущества, которые особенно заметны в реальных проектах.

1. Код становится слабосвязанным

Если класс зависит от интерфейса, а не от конкретной реализации, вы можете легко заменить одну реализацию на другую.

Сегодня уведомления отправляются по email. Завтра через какой-то мессенджер. Послезавтра — через очередь. Бизнес-логика при этом может вообще не измениться.

2. Код проще тестировать

Когда зависимости передаются извне, их можно подменять в тестах на фейки, стабы или моки. Не нужно подключать реальную БД, настоящий HTTP-клиент или отправку почты.

3. Конфигурация выносится из бизнес-логики

Ваши сервисы перестают знать о том, где лежит лог-файл, какой URL у внешнего API, какой драйвер у кеша и как создаётся объект подключения к базе.

4. Код проще поддерживать

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

Главный принцип Dependency Injection

Если сформулировать совсем коротко, то правило такое:

Класс не должен сам создавать свои зависимости. Он должен получать их снаружи.

Плохой вариант:

<?php

class UserService
{
    public function getUser(int $id): array
    {
        $pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');
        $stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
        $stmt->execute(['id' => $id]);

        return $stmt->fetch(PDO::FETCH_ASSOC);
    }
}

Хороший вариант:

<?php

class UserRepository
{
    public function __construct(
        private PDO $pdo
    ) {
    }

    public function findById(int $id): array|false
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
        $stmt->execute(['id' => $id]);

        return $stmt->fetch(PDO::FETCH_ASSOC);
    }
}

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

    public function getUser(int $id): array|false
    {
        return $this->repository->findById($id);
    }
}

Теперь каждый класс отвечает только за свою задачу:

  • UserRepository работает с БД;
  • UserService работает с логикой;
  • объект PDO создаётся снаружи.

Виды Dependency Injection в PHP

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

Constructor Injection

Это основной и самый рекомендуемый способ.

class ReportService
{
    public function __construct(
        private ReportRepository $repository,
        private LoggerInterface $logger
    ) {
    }
}

Почему это хороший вариант:

  • зависимость обязательна;
  • объект сразу создаётся в корректном состоянии;
  • зависимости видны прямо в сигнатуре конструктора;
  • такой код легко тестировать.

Именно constructor injection в большинстве случаев стоит использовать по умолчанию.

Method Injection

Иногда зависимость нужна только в одном конкретном методе.

class ImportService
{
    public function import(array $data, LoggerInterface $logger): void
    {
        $logger->info('Импорт начат');

        // Импорт данных
    }
}

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

Setter Injection

Иногда зависимость передаётся через отдельный setter-метод.

class NewsletterService
{
    private ?LoggerInterface $logger = null;

    public function setLogger(LoggerInterface $logger): void
    {
        $this->logger = $logger;
    }
}

Это менее надёжный вариант, потому что объект можно забыть корректно инициализировать. Поэтому в современном PHP-приложении setter injection используется реже.

Dependency Injection в PHP на реальном примере: работа с внешним API

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

Плохой вариант

<?php

class PaymentService
{
    public function charge(int $orderId, int $amount): array
    {
        $client = new \GuzzleHttp\Client([
            'base_uri' => 'https://payment.example.com',
            'timeout'  => 5,
        ]);

        $response = $client->post('/charge', [
            'json' => [
                'order_id' => $orderId,
                'amount' => $amount,
            ],
        ]);

        return json_decode((string) $response->getBody(), true);
    }
}

Сервис вроде бы работает, но в нём сразу несколько проблем:

  • HTTP-клиент создаётся прямо внутри метода;
  • конфигурация API зашита в код;
  • сложно тестировать;
  • нельзя быстро заменить реализацию;
  • класс знает слишком много об инфраструктуре.

Хороший production-подход

Сначала создадим контракт:

<?php

interface PaymentGatewayInterface
{
    public function charge(int $orderId, int $amount): array;
}

Дальше — конкретную реализацию:

<?php

use GuzzleHttp\ClientInterface;
use Psr\Log\LoggerInterface;

class ApiPaymentGateway implements PaymentGatewayInterface
{
    public function __construct(
        private ClientInterface $httpClient,
        private LoggerInterface $logger
    ) {
    }

    public function charge(int $orderId, int $amount): array
    {
        $this->logger->info('Charging order', [
            'order_id' => $orderId,
            'amount' => $amount,
        ]);

        $response = $this->httpClient->request('POST', '/charge', [
            'json' => [
                'order_id' => $orderId,
                'amount' => $amount,
            ],
        ]);

        return json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
    }
}

Теперь сервис оформления заказа:

<?php

class CheckoutService
{
    public function __construct(
        private PaymentGatewayInterface $paymentGateway
    ) {
    }

    public function checkout(int $orderId, int $amount): array
    {
        return $this->paymentGateway->charge($orderId, $amount);
    }
}

Что это даёт на практике:

  • CheckoutService не знает, какой HTTP-клиент используется;
  • его не волнуют таймауты, base URI и формат логирования;
  • в тестах можно подставить фейковый PaymentGatewayInterface;
  • инфраструктура отделена от бизнес-логики.

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

Пример Dependency Injection с репозиторием и логированием

Ещё один очень жизненный сценарий — обновление профиля пользователя и запись действий в лог.

Сначала контракт репозитория:

<?php

interface UserRepositoryInterface
{
    public function updateName(int $userId, string $name): void;
}

Реализация через PDO:

<?php

use PDO;

class PdoUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private PDO $pdo
    ) {
    }

    public function updateName(int $userId, string $name): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE users SET name = :name WHERE id = :id'
        );

        $stmt->execute([
            'name' => $name,
            'id' => $userId,
        ]);
    }
}

Теперь сервис:

<?php

use Psr\Log\LoggerInterface;

class ProfileService
{
    public function __construct(
        private UserRepositoryInterface $users,
        private LoggerInterface $logger
    ) {
    }

    public function changeName(int $userId, string $name): void
    {
        $this->users->updateName($userId, $name);

        $this->logger->info('User name changed', [
            'user_id' => $userId,
            'name' => $name,
        ]);
    }
}

Это хороший скелет:

  • логика обновления имени находится в сервисе;
  • доступ к данным вынесен в репозиторий;
  • логгер передаётся как зависимость;
  • классы можно тестировать отдельно.

Как Dependency Injection упрощает тестирование

Одна из главных причин использовать DI — это тестируемость.

Допустим, вы хотите протестировать ProfileService, но не хотите подключать реальную базу данных.

Можно сделать фейковый репозиторий:

<?php

class FakeUserRepository implements UserRepositoryInterface
{
    public array $updated = [];

    public function updateName(int $userId, string $name): void
    {
        $this->updated[] = [
            'userId' => $userId,
            'name' => $name,
        ];
    }
}

Теперь тестовая сборка выглядит очень просто:

<?php

use Psr\Log\NullLogger;

$repository = new FakeUserRepository();
$service = new ProfileService($repository, new NullLogger());

$service->changeName(5, 'Alex');

var_dump($repository->updated);

Без DI всё это было бы сложнее, потому что класс сам создавал бы свои зависимости и был бы жёстко привязан к конкретной реализации.

DI и контейнер зависимостей: это не одно и то же

Это важный момент, который часто путают.

Dependency Injection — это сам принцип.
Контейнер зависимостей — это инструмент, который помогает эти зависимости собирать.

То есть DI можно использовать даже вообще без фреймворка:

<?php

$pdo = new PDO($dsn, $user, $pass);
$repository = new PdoUserRepository($pdo);
$logger = new \Psr\Log\NullLogger();
$service = new ProfileService($repository, $logger);

Это уже Dependency Injection, хотя здесь нет ни Symfony, ни Laravel.

Контейнер нужен тогда, когда зависимостей становится много и собирать их вручную становится неудобно.

Частые ошибки при использовании Dependency Injection в PHP

Теперь разберём ошибки, которые особенно часто встречаются у начинающих разработчиков.

Создание зависимостей через new внутри сервиса

class OrderService
{
    public function __construct()
    {
        $this->logger = new FileLogger('/tmp/app.log');
    }
}

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

Передача контейнера вместо конкретных зависимостей

class SomeService
{
    public function __construct(private ContainerInterface $container)
    {
    }

    public function handle(): void
    {
        $logger = $this->container->get(LoggerInterface::class);
    }
}

Это уже похоже на service locator, а не на чистый DI.

Проблема в том, что по конструктору непонятно, какие зависимости реально нужны классу. Гораздо лучше явно передавать конкретные зависимости:

class SomeService
{
    public function __construct(private LoggerInterface $logger)
    {
    }
}

Интерфейсы ради интерфейсов

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

Например, вот это вполне может быть избыточным:

interface UserNameFormatterInterface
{
    public function format(string $name): string;
}

class UserNameFormatter implements UserNameFormatterInterface
{
    public function format(string $name): string
    {
        return trim($name);
    }
}

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

Слишком большой конструктор

Если у класса 8–10 зависимостей, проблема, скорее всего, не в DI, а в том, что сам класс делает слишком много.

class MegaService
{
    public function __construct(
        private A $a,
        private B $b,
        private C $c,
        private D $d,
        private E $e,
        private F $f,
        private G $g
    ) {
    }
}

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

Когда интерфейсы действительно полезны

Очень хороший практический ориентир такой:

Интерфейсы особенно полезны на границе с внешним миром.

Например:

  • работа с БД;
  • внешний HTTP API;
  • отправка email;
  • кеш;
  • файловое хранилище;
  • очередь;
  • платёжная система.

Пример с хранилищем:

<?php

interface StorageInterface
{
    public function put(string $path, string $content): void;
    public function get(string $path): string;
}

Дальше у вас могут быть разные реализации:

  • LocalStorage
  • S3Storage
  • CachedStorage

А основной код будет зависеть только от StorageInterface.

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

Реальный пример: декоратор с кешем

Dependency Injection особенно хорошо раскрывается, когда нужно добавлять дополнительное поведение поверх уже существующего сервиса.

Например, кеширование.

<?php

interface ProductRepositoryInterface
{
    public function findById(int $id): ?array;
}

Теперь декоратор:

<?php

use Psr\SimpleCache\CacheInterface;

class CachedProductRepository implements ProductRepositoryInterface
{
    public function __construct(
        private ProductRepositoryInterface $inner,
        private CacheInterface $cache
    ) {
    }

    public function findById(int $id): ?array
    {
        $cacheKey = "product:{$id}";

        $cached = $this->cache->get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        $product = $this->inner->findById($id);

        if ($product !== null) {
            $this->cache->set($cacheKey, $product, 3600);
        }

        return $product;
    }
}

Что здесь важно:

  • основной репозиторий не меняется;
  • кеширование добавлено отдельно;
  • поведение расширяется без переписывания бизнес-логики;
  • класс легко подключить через контейнер.

Dependency Injection в Symfony

Symfony использует service container и активно опирается на autowiring. Проще говоря, если тип зависимости указан явно и контейнер понимает, какой сервис нужно подставить, Symfony сделает это автоматически.

Простой сервис в Symfony

<?php

namespace App\Service;

use Psr\Log\LoggerInterface;

class InvoiceService
{
    public function __construct(
        private LoggerInterface $logger
    ) {
    }

    public function generate(int $orderId): void
    {
        $this->logger->info('Generating invoice', [
            'order_id' => $orderId,
        ]);
    }
}

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

Привязка интерфейса к реализации в Symfony

Допустим, у вас есть контракт:

<?php

namespace App\Contract;

interface PaymentGatewayInterface
{
    public function charge(int $orderId, int $amount): array;
}

И реализация:

<?php

namespace App\Service;

use App\Contract\PaymentGatewayInterface;

class StripePaymentGateway implements PaymentGatewayInterface
{
    public function charge(int $orderId, int $amount): array
    {
        return ['status' => 'ok'];
    }
}

Тогда в services.yaml можно указать, какую реализацию внедрять:

services:
    App\Contract\PaymentGatewayInterface: '@App\Service\StripePaymentGateway'

А дальше просто использовать интерфейс:

<?php

use App\Contract\PaymentGatewayInterface;

class CheckoutService
{
    public function __construct(
        private PaymentGatewayInterface $gateway
    ) {
    }
}

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

Dependency Injection в контроллерах Symfony

В Symfony зависимости можно передавать не только в сервисы, но и в контроллеры.

<?php

namespace App\Controller;

use App\Service\InvoiceService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class InvoiceController extends AbstractController
{
    #[Route('/invoice/{id}', name: 'invoice_generate')]
    public function generate(int $id, InvoiceService $invoiceService): Response
    {
        $invoiceService->generate($id);

        return new Response('OK');
    }
}

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

Dependency Injection в Laravel

Laravel тоже строит очень большую часть своей архитектуры вокруг service container. Если зависимость указана через type-hint и контейнер понимает, как её собрать, Laravel сделает это автоматически.

Простой сервис в Laravel

<?php

namespace App\Services;

use Psr\Log\LoggerInterface;

class InvoiceService
{
    public function __construct(
        protected LoggerInterface $logger
    ) {
    }

    public function generate(int $orderId): void
    {
        $this->logger->info('Generating invoice', [
            'order_id' => $orderId,
        ]);
    }
}

Dependency Injection в контроллере Laravel

<?php

namespace App\Http\Controllers;

use App\Services\InvoiceService;

class InvoiceController extends Controller
{
    public function __construct(
        protected InvoiceService $invoiceService
    ) {
    }

    public function show(int $id)
    {
        $this->invoiceService->generate($id);

        return response()->json(['status' => 'ok']);
    }
}

Laravel сам создаст InvoiceService, если все его зависимости зарегистрированы и понятны контейнеру.

Привязка интерфейса к реализации в Laravel

Один из самых частых сценариев — привязка контракта к классу в сервис-провайдере.

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Contracts\PaymentGatewayInterface;
use App\Services\StripePaymentGateway;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            PaymentGatewayInterface::class,
            StripePaymentGateway::class
        );
    }
}

После этого можно спокойно использовать интерфейс в сервисах:

<?php

namespace App\Services;

use App\Contracts\PaymentGatewayInterface;

class CheckoutService
{
    public function __construct(
        protected PaymentGatewayInterface $gateway
    ) {
    }

    public function checkout(int $orderId, int $amount): array
    {
        return $this->gateway->charge($orderId, $amount);
    }
}

Это один из самых практичных способов организовать код.

Symfony или Laravel: есть ли разница в DI

С точки зрения самой идеи — нет, принцип один и тот же.

И Symfony, и Laravel:

  • используют контейнер зависимостей;
  • поддерживают constructor injection;
  • умеют автоматически разрешать зависимости;
  • позволяют связывать интерфейсы с реализациями;
  • помогают отделять бизнес-логику от инфраструктуры.

Разница в стиле и привычках экосистемы:

  • в Symfony чаще встречается более явная конфигурация сервисов;
  • в Laravel чаще ощущается «магия удобства» и упор на conventions.

Но в обоих случаях основа одна: зависимости должны приходить в класс извне.

Практические рекомендации по Dependency Injection

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

1. Используйте constructor injection по умолчанию

Это самый понятный, безопасный и предсказуемый способ внедрения зависимостей.

2. Не создавайте инфраструктурные объекты внутри бизнес-классов

БД, HTTP-клиенты, логгеры, кеш, файловые сервисы и внешние API-клиенты лучше передавать через зависимости.

3. Не тащите контейнер в прикладной код

Контейнер должен собирать приложение, а не использоваться как глобальный реестр сервисов.

4. Используйте интерфейсы там, где есть смысл замены реализации

Особенно это касается внешних интеграций и инфраструктурных компонентов.

5. Не создавайте абстракции без реальной причины

Лишние интерфейсы только увеличивают объём кода и снижают читаемость.

6. Если у класса слишком много зависимостей — это повод для рефакторинга

Хороший DI не скрывает проблемы архитектуры, а помогает их увидеть.

Чеклист: правильно ли у вас внедрён Dependency Injection

Перед публикацией кода или ревью можно быстро пройтись по этому списку:

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

Если на большинство пунктов ответ «да», значит вы двигаетесь в правильную сторону.

Заключение

Dependency Injection в PHP — это один из тех инструментов, которые сначала кажутся «слишком архитектурными», а потом становятся просто нормой хорошего кода.

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

Если говорить совсем практично, то DI даёт вам три ключевых преимущества:

  • меньшую связанность кода;
  • удобное тестирование;
  • более простое развитие проекта.

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

И самое приятное здесь в том, что начать можно очень просто: перестаньте создавать зависимости внутри классов и начните передавать их через конструктор. Уже один этот шаг заметно улучшит качество кода.

Dependency Injection в PHP на практике: простое объяснение с примерами

Подписаться
Уведомить о
guest
0 комментариев