Interfaces and Dependency Injection in PHP
How I use interface definitions and dependency injection in PHP to build loosely coupled classes, illustrated with practical examples.
Imagine you’re writing a class that needs to send email. If you hard-code new SwiftMailer() inside it, that class becomes tightly coupled to SwiftMailer. The day comes when you need to swap out the library, or you want to avoid sending real emails during testing — and suddenly you have to reach back into that class and change it.
Interfaces and dependency injection are two concepts used together to solve exactly this problem. I’ve been applying them more deliberately in my own projects over the past few months, and this post is a write-up of what I’ve learned.
What is an interface?
An interface is a contract that defines what a class can do — not how it does it. Any class that implements the interface is required to provide all the methods declared in it.
<?php
interface MailerInterface
{
public function gonder(string $alici, string $konu, string $icerik): bool;
}
Let’s write two different classes that implement this interface:
<?php
class SmtpMailer implements MailerInterface
{
public function gonder(string $alici, string $konu, string $icerik): bool
{
// Send email via a real SMTP connection
return mail($alici, $konu, $icerik);
}
}
class LogMailer implements MailerInterface
{
public function gonder(string $alici, string $konu, string $icerik): bool
{
// Write to a log file instead of sending a real email
file_put_contents('mail.log', "[{$alici}] {$konu}\n", FILE_APPEND);
return true;
}
}
Both classes satisfy MailerInterface. From the outside, they are interchangeable: both expose a gonder() method.
What is dependency injection?
Dependency injection means that instead of a class creating the objects it needs internally, those objects are passed in from the outside. Constructor injection is the most common approach.
<?php
class UserNotifier
{
private MailerInterface $mailer;
public function __construct(MailerInterface $mailer)
{
$this->mailer = $mailer;
}
public function kayitOlduBildir(string $email): bool
{
return $this->mailer->gonder(
$email,
'Kaydınız tamamlandı',
'Sisteme başarıyla kaydoldunuz. Hoşgeldiniz!'
);
}
}
UserNotifier no longer knows about SmtpMailer. It only knows about MailerInterface. Which concrete implementation is used gets decided from the outside:
<?php
// Production: real email
$notifier = new UserNotifier(new SmtpMailer());
$notifier->kayitOlduBildir('[email protected]');
// Development: log only
$notifier = new UserNotifier(new LogMailer());
$notifier->kayitOlduBildir('[email protected]');
UserNotifier never changes. Its behavior is shaped entirely by the object injected into it.
This pattern can feel overly indirect at first. But think about it: when you need to move from SwiftMailer to another library, you never touch UserNotifier. You write a new SmtpMailer class, PHP verifies it satisfies the interface, and you’re done. When you test UserNotifier, you don’t need to connect to a real mail server. Those are the two problems it solves.
When you write tests, the difference becomes obvious
When you want to test UserNotifier, you don’t want to send real emails. Thanks to the interface, you can write a dedicated test implementation:
<?php
class FakeMailer implements MailerInterface
{
public array $gonderilen = [];
public function gonder(string $alici, string $konu, string $icerik): bool
{
$this->gonderilen[] = compact('alici', 'konu', 'icerik');
return true;
}
}
// Test code
$fakeMailer = new FakeMailer();
$notifier = new UserNotifier($fakeMailer);
$notifier->kayitOlduBildir('[email protected]');
assert(count($fakeMailer->gonderilen) === 1);
assert($fakeMailer->gonderilen[0]['alici'] === '[email protected]');
No real SMTP connection, tests run fast, and you can verify exactly what was sent.
Without the interface, writing this test would require either modifying SmtpMailer at test time or adding conditional logic inside UserNotifier. Both are bad options.
You don’t need interfaces everywhere
If a class is only ever going to have one implementation and there’s no plan to change it, adding an interface creates unnecessary complexity. The situations where interfaces earn their keep are:
- Things that will have multiple implementations (payments, email, storage, etc.).
- Dependencies where you’ll want to substitute a fake/mock during testing.
- Behaviors expected to be externally configurable.
Adding an interface to every class is not a requirement — using them in the right places is what matters.
When I was starting out, my mistake was adding an interface to everything. UserRepository had a UserRepositoryInterface. OrderService had an OrderServiceInterface. After a while the project had twice as many files as real classes, most interfaces had a single implementation, and none of them ever changed. Letting go of that unnecessary abstraction took some time.
The Laravel container makes this easy
In Laravel, I use the service container to bind an interface to a concrete class:
// Inside AppServiceProvider or a dedicated provider
$this->app->bind(MailerInterface::class, SmtpMailer::class);
Now whenever UserNotifier is resolved through the container, Laravel automatically satisfies the MailerInterface dependency with SmtpMailer. You can also bind a different implementation based on the environment:
if (app()->environment('production')) {
$this->app->bind(MailerInterface::class, SmtpMailer::class);
} else {
$this->app->bind(MailerInterface::class, LogMailer::class);
}
This registration happens once. From that point on, no matter where you resolve UserNotifier, the correct mailer is injected automatically.
Using these two concepts together feels a bit abstract at first. But once you put them into practice and see how much more flexible the codebase becomes, the logic clicks into place.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.