Dependency Injection в PHP на практике: простое объяснение с примерами
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;
}Дальше у вас могут быть разные реализации:
LocalStorageS3StorageCachedStorage
А основной код будет зависеть только от 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-код. Для среднего уровня — это уже обязательная база, без которой сложно строить поддерживаемую архитектуру.
И самое приятное здесь в том, что начать можно очень просто: перестаньте создавать зависимости внутри классов и начните передавать их через конструктор. Уже один этот шаг заметно улучшит качество кода.
