Dependency Injection (DI) is a software design pattern that enables better modularity, testability, and maintainability in applications. It allows a class to receive its dependencies from external sources rather than creating them internally, promoting the inversion of control. In PHP, dependency injection has become a standard practice, especially in modern frameworks like Laravel, Symfony, and Zend Framework.
In this advanced tutorial, we will dive deeper into dependency injection concepts, cover different types of DI, and explore how to implement it effectively using PHP 8+ features, like union types, attributes, and other modern enhancements.
Understanding Dependency Injection
At its core, dependency injection is a technique where objects or services required by a class are injected into that class from the outside, typically through the constructor. This pattern decouples the object from its dependencies, making the code easier to maintain and test.
Let's first review the classic problem without dependency injection
class UserService {
private $mailer;
public function __construct() {
$this->mailer = new Mailer(); // tightly coupled dependency
}
public function sendWelcomeEmail(User $user): void {
$this->mailer->send("Welcome", $user->email);
}
}
In the above code, UserService
is responsible for creating an instance of Mailer
. This creates a tight coupling between UserService
and Mailer
, making it harder to modify or replace Mailer
(e.g., for testing or switching to a new mailer service).
With dependency injection, we pass Mailer
from outside the UserService
, leading to better flexibility:
class UserService {
private Mailer $mailer;
public function __construct(Mailer $mailer) {
$this->mailer = $mailer;
}
public function sendWelcomeEmail(User $user): void {
$this->mailer->send("Welcome", $user->email);
}
}
Types of Dependency Injection
There are three primary types of dependency injection in PHP:
- Constructor Injection: Dependencies are injected via the class constructor.
- Setter Injection: Dependencies are injected via setter methods.
- Interface Injection: Dependencies are injected via an interface (less common in PHP).
Let's explore each of these types in detail.
a) Constructor Injection
Constructor injection is the most common form of dependency injection. The required dependencies are passed through the class constructor when the object is created.
Example:
class Mailer {
public function send(string $subject, string $to): void {
// logic for sending an email
}
}
class UserService {
private Mailer $mailer;
public function __construct(Mailer $mailer) {
$this->mailer = $mailer;
}
public function sendWelcomeEmail(User $user): void {
$this->mailer->send("Welcome", $user->email);
}
}
$mailer = new Mailer();
$userService = new UserService($mailer);
In this example, Mailer
is injected into UserService
through the constructor. This allows us to easily replace Mailer
with a different implementation if needed, without modifying the UserService
class.
b) Setter Injection
In setter injection, dependencies are injected using setter methods rather than through the constructor. This allows you to set or change dependencies after the object has been instantiated.
Example:
class UserService {
private ?Mailer $mailer = null;
public function setMailer(Mailer $mailer): void {
$this->mailer = $mailer;
}
public function sendWelcomeEmail(User $user): void {
$this->mailer->send("Welcome", $user->email);
}
}
$mailer = new Mailer();
$userService = new UserService();
$userService->setMailer($mailer);
Setter injection offers more flexibility in cases where dependencies might need to be changed after object creation, though it also increases the risk of uninitialized properties.
c) Interface Injection
Interface injection is a less commonly used approach where the dependencies are injected via an interface, enforcing dependency injection as part of the contract. This is rarely used in PHP compared to the other two methods.
Example:
interface MailerAwareInterface {
public function setMailer(Mailer $mailer): void;
}
class UserService implements MailerAwareInterface {
private Mailer $mailer;
public function setMailer(Mailer $mailer): void {
$this->mailer = $mailer;
}
public function sendWelcomeEmail(User $user): void {
$this->mailer->send("Welcome", $user->email);
}
}
In this approach, any class implementing MailerAwareInterface
must implement the setMailer()
method, enforcing setter injection.
Advanced Dependency Injection Techniques
Now that we understand the basics of dependency injection, let's explore some advanced concepts and techniques to enhance our implementation.
a) Using Dependency Injection Containers (DIC)
Manually wiring up dependencies can become cumbersome in large applications. Dependency Injection Containers (DIC) help manage dependencies automatically by mapping interfaces to implementations, managing the lifecycle of objects, and handling complex dependency graphs.
Popular PHP frameworks like Laravel, Symfony, and Zend Framework come with their own DI containers.
Example using a basic DIC:
class Container {
private array $bindings = [];
public function set(string $abstract, callable $concrete): void {
$this->bindings[$abstract] = $concrete;
}
public function get(string $abstract) {
return $this->bindings[$abstract]();
}
}
$container = new Container();
$container->set(Mailer::class, fn() => new Mailer());
$container->set(UserService::class, fn() => new UserService($container->get(Mailer::class)));
$userService = $container->get(UserService::class);
In this example, the Container
manages the creation and injection of dependencies, making the application code cleaner and more modular.
b) Autowiring Dependencies
Many modern dependency injection containers support autowiring
, where the container automatically resolves dependencies based on type hints. This eliminates the need to manually specify the wiring of objects.
Example with Symfony DI Container:
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
$container = new ContainerBuilder();
$container->register(Mailer::class);
$container->register(UserService::class)
->addArgument(new Reference(Mailer::class));
$userService = $container->get(UserService::class);
Autowiring reduces boilerplate code and allows for automatic resolution of class dependencies based on their constructor type hints.
c) Handling Optional Dependencies
Sometimes, dependencies may be optional, and you don’t want them injected unless necessary. You can handle this using null
as the default value in the constructor.
class Logger {
public function log(string $message): void {
// Log message to a file or a service
}
}
class UserService {
private ?Logger $logger;
public function __construct(?Logger $logger = null) {
$this->logger = $logger;
}
public function sendWelcomeEmail(User $user): void {
// Sending email...
$this->logger?->log("Welcome email sent to {$user->email}");
}
}
In this example, Logger
is an optional dependency, and PHP’s nullsafe operator (?->
) ensures that the log
method is only called if Logger
is set.
d) Using PHP 8 Attributes for Dependency Injection
PHP 8 introduced attributes that allow developers to attach metadata to classes, properties, and methods. Attributes can be leveraged to define dependencies and streamline the DI process.
Example with Attributes:
#[Inject]
class Mailer {
// Mailer class implementation
}
class UserService {
#[Inject]
private Mailer $mailer;
public function sendWelcomeEmail(User $user): void {
$this->mailer->send("Welcome", $user->email);
}
}
// Dependency container using attributes can scan and inject Mailer based on #[Inject]
Attributes make the dependency injection process more declarative and reduce boilerplate code in the container setup.
e) Cyclic Dependency Prevention
Cyclic dependencies occur when two or more classes depend on each other, leading to an infinite loop of instantiation. It's essential to design your classes in such a way that prevents cyclic dependencies.
class A {
private B $b;
public function __construct(B $b) {
$this->b = $b;
}
}
class B {
private A $a;
public function __construct(A $a) {
$this->a = $a;
}
}
The above code will result in a cyclic dependency. To avoid this, consider restructuring your design, applying inversion of control more effectively, or using setter injection if one of the dependencies is optional or can be deferred.
Testing with Dependency Injection
One of the biggest advantages of dependency injection is that it makes your code more testable. By injecting dependencies, you can easily mock or stub them during testing.
Example with PHPUnit:
use PHPUnit\Framework\TestCase;
class UserServiceTest extends TestCase {
public function testSendWelcomeEmail(): void {
$mailerMock = $this->createMock(Mailer::class);
$mailerMock->expects($this->once())
->method('send')
->with('Welcome', 'test@example.com');
$userService = new UserService($mailerMock);
$userService->sendWelcomeEmail(new User('test@example.com'));
}
}
Here, Mailer
is mocked during the test, allowing us to focus on the behavior of UserService
without sending real emails.
Conclusion
Dependency Injection is a powerful pattern that promotes loose coupling, making applications easier to test, extend, and maintain. In this tutorial, we explored different types of dependency injection in PHP, along with advanced techniques like using DI containers, handling optional dependencies, preventing cyclic dependencies, and leveraging PHP 8 features such as attributes.
As you continue building PHP applications, adopting a robust dependency injection strategy will greatly enhance the scalability and maintainability of your codebase. Whether you're working with a framework or building custom solutions, DI should be a key part of your development practices.