What Dependency Injection Actually Means in PHP
Dependency injection in PHP means passing dependencies into a class from outside rather than having the class create them itself. The class does not instantiate its own dependencies. Instead, something else provides them, usually through a constructor or a setter method.
This sounds like a small change, but it has a significant effect on how code behaves. When a class creates its own dependencies, it becomes tightly coupled to those specific implementations. Changing one of those dependencies means editing the class itself. When dependencies are injected, the class becomes more flexible and can work with different implementations without modification.
For a PHP developer working on projects that need to last, this approach makes a practical difference. It separates the concern of how something works from the concern of what it works with, which keeps code easier to modify over time.
The Problem With Classes That Create Their Own Dependencies
Consider a simple example. A UserNotifier class needs to send emails. A common mistake is to have the class create its own email service directly.
class UserNotifier
{
private $mailer;
public function __construct()
{
$this->mailer = new PHPMailerMailer();
}
public function notifyUser(User $user, string $message): void
{
$this->mailer->send($user->email, $message);
}
}
This works initially. The UserNotifier can send emails. But testing becomes difficult. Any test that uses UserNotifier will also call the real PHPMailerMailer, which means tests depend on a working email server, valid SMTP credentials, and network access. That is not a test. That is a fragile setup that will fail for the wrong reasons.
The same problem appears when the code needs to change. If the project switches from PHPMailer to a different mail library, every class that creates its own mailer must be edited. In a larger project, that can mean dozens of places to update, each with its own risk of breaking something.
Passing Dependencies In Instead of Creating Them
Dependency injection solves this by making the class accept its dependencies from outside. The class does not care where the mailer comes from. It only cares that it has one when it needs to send a message.
class UserNotifier
{
private MailerInterface $mailer;
public function __construct(MailerInterface $mailer)
{
$this->mailer = $mailer;
}
public function notifyUser(User $user, string $message): void
{
$this->mailer->send($user->email, $message);
}
}
The UserNotifier now depends on MailerInterface, which is an abstraction. Any class that implements that interface can be injected. In production, a real mailer goes in. In tests, a test double goes in instead.
Using a Test Double in Practice
A test double is a fake implementation used during testing. It behaves like the real thing but does not have side effects like sending actual emails or querying a live database.
class FakeMailer implements MailerInterface
{
public array $sentMessages = [];
public function send(string $to, string $message): void
{
$this->sentMessages[] = ['to' => $to, 'message' => $message];
}
}
Now a test can inject the fake mailer and verify the notifier behaves correctly.
public function testNotifyUserSendsCorrectEmail(): void
{
$fakeMailer = new FakeMailer();
$notifier = new UserNotifier($fakeMailer);
$user = new User('alex@example.com');
$notifier->notifyUser($user, 'Your order has shipped.');
$this->assertCount(1, $fakeMailer->sentMessages);
$this->assertEquals('alex@example.com', $fakeMailer->sentMessages[0]['to']);
$this->assertStringContainsString('shipped', $fakeMailer->sentMessages[0]['message']);
}
The test runs quickly, has no external dependencies, and clearly shows what the code is supposed to do. That is the practical benefit of dependency injection for testing.
Types of Dependency Injection in PHP
There are three common ways to inject dependencies into a class.
Constructor Injection
Dependencies are passed through the constructor. This is the most common approach and works well when a class needs its dependencies for its entire lifetime.
public function __construct(DatabaseConnection $db, LoggerInterface $logger)
{
$this->db = $db;
$this->logger = $logger;
}
Constructor injection makes dependencies explicit. Anyone reading the class can see what it needs to function. It also ensures the object is in a valid state before any methods are called.
Setter Injection
Dependencies are set through setter methods after the object is created. This is useful when a dependency might not always be needed, or when it needs to change during the object's lifetime.
public function setCache(CacheInterface $cache): void
{
$this->cache = $cache;
}
Setter injection requires more care. A class using setter injection should handle the case where a dependency has not been set, or document clearly that certain methods require it. Failing to do this leads to null reference errors at runtime.
Interface Injection
An interface defines a setter method that implementing classes must provide. The injecting code uses that interface to set the dependency. This approach is less common in PHP but appears in some frameworks and container systems.
interface InjectableLogger
{
public function injectLogger(LoggerInterface $logger): void;
}
class ReportGenerator implements InjectableLogger
{
private ?LoggerInterface $logger = null;
public function injectLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
}
For most PHP projects, constructor injection covers the majority of cases. Setter injection is useful for optional dependencies. Interface injection is typically reserved for framework integration.
How Dependency Injection Relates to Inversion of Control
Dependency injection is one form of inversion of control. Inversion of control means that the flow of a program is controlled by something external rather than by the code itself. With dependency injection, the control over which dependencies are used shifts from the class to the calling code or a container.
Dependency injection containers take this further by managing object creation and injection automatically. A container holds configuration about which concrete classes to use for each interface, and it constructs objects with the correct dependencies when requested.
$container = new Container();
$container->bind(MailerInterface::class, PHPMailerMailer::class);
$notifier = $container->make(UserNotifier::class);
The container resolves UserNotifier, sees it needs a MailerInterface, looks up the binding, creates a PHPMailerMailer, and injects it. The calling code does not need to know the details.
Containers are useful in larger applications where manually wiring dependencies becomes tedious. They also make it easier to swap implementations for different environments, such as using a fake mailer in tests and a real one in production.
Common Mistakes When Applying Dependency Injection
Dependency injection is straightforward in principle, but a few common mistakes can reduce its usefulness or create new problems.
- Injecting too many dependencies: A class that takes ten dependencies in its constructor is a sign that the class is doing too much. Consider splitting it into smaller classes with fewer responsibilities.
- Injecting the container itself: Passing a container into a class to resolve dependencies on demand defeats the purpose. The class still depends on the container, and testing becomes harder because the container must be mocked. Inject the specific dependencies instead.
- Using dependency injection without interfaces: Injecting a concrete class still creates coupling. If the class cannot be replaced without changing the consuming code, the flexibility is limited. Use interfaces for dependencies that might change.
- Creating service locators: A service locator is a static object that classes call to fetch dependencies. It hides dependencies from the constructor, making code harder to understand and test. Constructor injection makes dependencies visible and explicit.
Writing Testable PHP Classes: A Practical Workflow
Applying dependency injection to an existing codebase can feel overwhelming if the project is large. A practical approach is to write new classes with dependency injection from the start, and gradually refactor existing classes when they need changes or when tests are added.
For a new class, the workflow looks like this.
- Identify what the class needs to work: Does it need a database connection? Does it need to send notifications? Does it need to read configuration? These are candidates for dependencies.
- Define interfaces for external dependencies: If the class needs a mailer, define a
MailerInterfacefirst. This makes the contract explicit and allows different implementations. - Accept dependencies through the constructor: Write the constructor to accept the interface types. Do not create instances inside the class.
- Write tests using test doubles: Create fake implementations of the interfaces and inject them. Verify the class behaves correctly without touching real external services.
- Wire everything together at the entry point: In the controller, command, or bootstrap file, create the real implementations and inject them into the classes that need them.
This workflow keeps new code testable from the start. Over time, the codebase accumulates more testable code, and the risk of breaking existing functionality when making changes reduces significantly.
Dependency Injection and Long-Term Maintainability
The real value of dependency injection becomes apparent over months and years, not weeks. Projects that use it are easier to change because dependencies are explicit and replaceable. Adding a new implementation, swapping a service, or writing tests does not require rewriting the consuming classes.
This matters for business websites and applications that need ongoing maintenance. When requirements change, when third-party services are updated, or when new features are added, a codebase built with dependency injection adapts more smoothly. The cost and return on investment of a custom booking system, for example, depend partly on how maintainable the underlying code is. Hard-to-change code accumulates technical debt that eventually slows every addition.
Dependency injection is one piece of a larger picture that includes clear responsibilities, sensible abstractions, and consistent patterns. On its own it does not solve everything, but it creates a foundation that makes other good practices easier to apply.
Related practical reading
These related guides can help you connect this topic with the wider website, server, security, and support decisions around it.
- GraphQL in PHP vs REST: When GraphQL Is the Better Choice - useful background for related development decisions