TL;DR
- Dependency injection is a mechanic: a class receives its collaborators instead of building them. Dependency inversion is a design choice: depend on an abstraction, not a concrete class. They are not the same thing.
- Laravel’s container does DI for you automatically through constructor type-hints. You only write a binding when you invert a dependency onto an interface.
- Most classes do not need an interface. Add one at a boundary that changes or needs a test seam — payments, mail, storage, external APIs — not reflexively for every service.
- The anti-pattern that undoes all of it: resolving dependencies with
app()deep inside methods instead of injecting them. That is a service locator, and it hides exactly what you were trying to make explicit.
I once inherited a Laravel codebase where every service had an interface. UserServiceInterface, InvoiceServiceInterface, ReportServiceInterface — each with exactly one implementation, each bound one-to-one in a service provider that was four hundred lines long. The team thought they were “following SOLID.” What they had actually built was a codebase where jumping to a method’s definition landed you on an interface, every time, and you had to go find the binding to learn what really ran. The abstraction added indirection and removed nothing.
That project taught me the distinction this article is about. Dependency injection and dependency inversion get used as synonyms, and blurring them is how you end up with an interface for every class. They solve different problems, and knowing which one you actually need is what keeps a Laravel codebase clean as it grows.
DI and DIP are not the same thing
Dependency injection means a class receives the things it depends on rather than constructing them. In Laravel that is almost always constructor injection, and the container builds the dependency for you:
class RegisterUser
{
public function __construct(
private Mailer $mailer,
) {}
}That is already an improvement over new Mailer(...) inside the method — the class no longer owns the wiring. But notice it still depends on a concrete Mailer. You have injected the dependency without inverting it.
Dependency inversion is the second, separate step: make the high-level class depend on an abstraction it owns, so the concrete implementation becomes a detail you can swap.
interface Mailer
{
public function send(Message $message): void;
}
class RegisterUser
{
public function __construct(
private Mailer $mailer, // now an interface
) {}
}The class now states a requirement (“something that can send a message”) instead of naming a vendor. SMTP, Mailgun, SES, or a fake in tests all satisfy it. That is the inversion: the direction of the dependency flipped from “high-level code depends on low-level detail” to “both depend on a contract.”
How Laravel’s container actually resolves this
For plain DI you write no configuration at all. When the container builds RegisterUser, it reads the constructor’s type-hints and recursively resolves each one. A concrete class with its own resolvable dependencies just works — this is why most Laravel controllers and jobs need zero binding.
Method injection works the same way. Type-hint a dependency on a controller action and the container supplies it per request:
public function store(Request $request, Mailer $mailer)
{
// $mailer resolved by the container for this call
}The moment you depend on an interface, the container can no longer guess. An interface has no constructor to build. You have to tell it which implementation to use, and that is the only reason bindings exist:
// In a service provider's register() method
$this->app->bind(Mailer::class, MailgunMailer::class);Now anywhere Mailer is type-hinted, the container hands over a MailgunMailer. Switching the whole application to SES is one line here, not a find-and-replace across the codebase. That single-point-of-change is the entire payoff of inversion.
singleton() shares state for the whole request
bind() builds a fresh instance every time; singleton() builds once and reuses it. A singleton that holds mutable state — a cached user, an accumulating array — leaks that state across everything resolved in the same request. Use singleton() for stateless collaborators and connection-like objects, not for anything that mutates per call.
Contextual binding: when two consumers need different implementations
The case that justifies an interface most clearly is when the same contract has to resolve differently depending on who is asking. Laravel handles this with contextual binding, and it is the feature that makes inversion pay for itself:
$this->app->when(PublicUploadController::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('s3'));
$this->app->when(PrivateReportController::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('local'));Both controllers ask for the same Filesystem abstraction; the container gives each the right disk. Without the interface there is nothing to vary. This is the difference between an interface that earns its place and one that just adds a hop.
When an interface earns its place — and when it is noise
The codebase from the opening went wrong by treating “add an interface” as always-good. It is a trade: an interface buys flexibility and a test seam, and costs a layer of indirection and a binding to maintain. Spend it where the flexibility is real.
| Situation | Interface? | Why |
|---|---|---|
| External boundary (payments, mail, storage, SMS) | Yes | The vendor will change, and you want a fake in tests |
| Multiple real implementations today | Yes | That is literally what the abstraction is for |
| A swap you can name a concrete reason for | Yes | “We will move from SES to Postmark next quarter” |
| A small, self-contained service with one implementation | No | Inject the concrete class; add the interface the day you need a second |
| A value object or pure domain calculation | No | Nothing to invert; it has no infrastructure dependency |
The honest default in Laravel: inject concrete classes, and reach for an interface when a class sits on the boundary between your domain and the outside world. “Might need it someday” is not a reason; “the payment provider lives behind this” is.
Testing across the boundary
This is where inversion repays the cost most directly. Because consumers depend on the contract, a test can substitute a fake without touching the code under test. The container makes the swap a single call:
public function test_registration_sends_a_welcome_message(): void
{
$mailer = new FakeMailer();
$this->app->instance(Mailer::class, $mailer);
$this->post('/register', [...]);
$this->assertCount(1, $mailer->sent);
}No SMTP, no network, no mocking framework gymnastics — the real RegisterUser runs with a fake collaborator because it only ever depended on the interface. That is the testability people credit to DI but that actually comes from inversion: a concrete dependency cannot be swapped this cleanly.
Mistakes I see in real Laravel code
An interface for every class
FooServiceInterface with one implementation adds indirection and removes nothing. Add the interface when a second implementation or a test seam is real.
Service locator instead of injection
Calling app(Mailer::class) deep in a method hides the dependency from the constructor. Type-hint it instead so the class declares what it needs.
Leaking framework types across the contract
An interface whose methods take an Eloquent model or a Request is not abstracting anything. Pass plain data or value objects across the boundary.
God “Manager” interfaces
A ServiceManagerInterface with fifteen unrelated methods is not a contract. Split it by the actual capability each consumer needs.
Stateful singletons
Binding a mutable object as singleton() leaks state across the request. Reserve singletons for stateless or connection-like services.
Binding in the wrong place
Resolving services inside register() runs before all providers are loaded. Put bindings in register(), resolution in boot().
FAQ
- What is the difference between dependency injection and dependency inversion?
- Dependency injection is a technique: a class receives its dependencies from outside instead of creating them. Dependency inversion is a design principle: high-level code depends on an abstraction rather than a concrete class. You can inject a concrete class (DI without inversion); inversion is the extra step of depending on an interface.
- Do I need an interface for every service in Laravel?
- No. Inject concrete classes by default. Introduce an interface when a dependency sits on an external boundary (payments, mail, storage), when you genuinely have multiple implementations, or when you need a clean test seam. An interface with a single implementation usually just adds indirection.
- Why does Laravel resolve some classes without any binding?
- The container reads constructor type-hints and builds concrete classes automatically, resolving their dependencies recursively. You only need a binding when you type-hint an interface, because an interface has no constructor for the container to build.
- What is the service locator anti-pattern?
- Calling
app()orresolve()to fetch dependencies inside a method instead of injecting them through the constructor. It hides what a class depends on and makes the class harder to test, which is the opposite of what dependency injection is meant to achieve. - When should I use bind() versus singleton()?
- Use
bind()when each consumer should get a fresh instance, which is the safe default. Usesingleton()only for stateless collaborators or connection-like objects you want to reuse for the whole request. A singleton holding mutable state leaks that state across everything resolved after it.
Related articles
- Laravel Cashier and Stripe: what breaks in real subscription flows applies this exact boundary pattern to keep Stripe-specific code out of controllers.
- The importance of security in web development covers why a single, well-defined boundary for external integrations is easier to audit and secure.




