A lamp doesn't care which power plant generated its electricity — it depends on the standard socket, not the plant. High-level code should depend on an abstraction the same way, so the concrete implementation underneath it can change freely.
Laravel's service container makes Dependency Inversion the path of least resistance — across every Laravel project I've built, from the Wallet backend at Zion Business Group to UNinnova and Pharmalytics, high-level classes take an interface through the constructor and let the container decide which concrete implementation to bind, rather than the class reaching for a specific driver itself. It's a principle I lean on daily, not just one I can describe.
Trade-off: jumping to the interface in an editor no longer tells you which concrete class actually runs — you have to check the container's bindings to know whether Mailer resolves to a real mail driver or a test fake.
Before
class NotificationService
{
private MailgunClient $client;
public function __construct()
{
$this->client = new MailgunClient(config('services.mailgun.key'));
}
public function notify(string $email, string $message): void
{
$this->client->send($email, $message);
}
}After
interface Mailer
{
public function send(string $email, string $message): void;
}
class NotificationService
{
public function __construct(private Mailer $mailer) {}
public function notify(string $email, string $message): void
{
$this->mailer->send($email, $message);
}
}
// container binds Mailer -> MailgunMailer, swappable per environment