The Interface With One Implementation
You added it so the thing could be swapped out later. Later arrived and nothing was. Counting implementations is the wrong test, and where the interface lives tells you far more.
Somewhere in the codebase there is an OrderRepositoryInterface. Next to it, in the same folder, there is an EloquentOrderRepository. In a service provider there is a line binding one to the other, and that line has not changed since it was written.
The interface was added so the persistence layer could be swapped out later. It is now later. Nothing has been swapped, nothing is going to be, and every time someone follows a call from a controller they land on a method signature with no body and have to jump again to find out what actually happens.
This is the article where the series has to be honest with itself, because everything so far has argued for moving things apart, and this is the case where the profession’s default advice is wrong often enough to be worth attacking directly.
Name what you are buying
An interface buys you one of three things. Not two, not a general sense of cleanliness. Three, and you should be able to say which one before you add it.
Runtime substitution. You genuinely run more than one implementation. A filesystem driver that is S3 in production and local on a developer machine. A payment gateway you actually have two of because you took on a second market. If this is real, the interface is not a question, it is the mechanism.
Test substitution. You want a fake in a test. This is the reason people give most often, and in Laravel it is the weakest of the three, which I will come back to.
Declaring a direction. You want the thing on the inside to stop depending on the thing on the outside, so you define the contract on the inside and let the outside implement it. This is dependency inversion, it has nothing to do with substitution, and it is the one people are usually reaching for without having the words.
If you cannot say which of those three you are buying, you have not bought anything. The interface is a habit.
Laravel already faked it for you
The test substitution argument deserves attacking on its own, because it is the one that sounds most responsible.
The reasoning goes: my code calls out to something slow or external, so I need to be able to replace it in a test, so I need an interface. In a framework without a container and a well developed set of test doubles that would be sound. Laravel is not that framework.
Http::fake(['api.stripe.com/*' => Http::response(['id' => 'ch_1'], 200)]);Queue::fake();Bus::fake();Mail::fake();Storage::fake('invoices');Event::fake([OrderRefunded::class]);Notification::fake();Every one of those replaces an edge in a test with no interface, no binding and no second class. The framework already inverted those dependencies for you, and it did it at the facade so you did not have to restructure anything to benefit.
So when somebody says the interface is there for testing, the useful question is: what does the test look like without it? If the answer is one of the lines above, the interface is not paying for testing. It is paying for nothing and being credited for testing.
There is a real exception, and it is your own outbound integration code. If you have wrapped an external API in a client class of your own, Http::fake() fakes the HTTP but not your client’s behaviour, and a fake implementation of your own client can be genuinely more expressive than asserting on request payloads. That is a real reason. It is also a much narrower one than “we use interfaces for testability”.
Counting implementations is the wrong test
Part three argued that counting a model’s lines tells you nothing useful. The same trap is set here, in a different shape.
The obvious metric for an interface is how many classes implement it, and one implementation looks like a smell. But run that metric over a package you trust and it falls apart immediately.
# every interface declared under a path, and how many classes implement itgrep -rlE "^\s*(final\s+)?interface\s" app/ --include=*.php | while read -r f; do name=$(basename "$f" .php) impls=$(grep -rlE "implements[^{]*\b${name}\b" app/ --include=*.php | grep -vFx "$f" | wc -l) printf '%2d %-34s %s\n' "$impls" "$name" "$(dirname "$f")"done | sort -nPoint that at vendor/laravel/fortify/src and almost every contract comes back with zero implementations. CreatesNewUsers, LoginResponse, ResetsUserPasswords, the whole Contracts directory.
By the naive metric those are the worst interfaces in the ecosystem. They are in fact exactly right, and the reason is that Fortify is not supposed to implement them. You are. Fortify declares what it needs and your application supplies it, so the package depends on your code without knowing anything about it.
Zero implementations inside the thing that declares the interface is not a warning. It is the signature of an interface that is doing its job.
The question is who owns it
Which gives you the measurement that actually works, and it is about location rather than count.
Look at where the interface is defined and where it is implemented, and ask which of those two things is allowed to know about the other.
app/Repositories/OrderRepositoryInterface.phpapp/Repositories/EloquentOrderRepository.phpSame directory, same layer, same commit. Nothing has been inverted here. Anything that depended on EloquentOrderRepository now depends on OrderRepositoryInterface, which lives in the same place and changes at the same time for the same reasons. The dependency graph is unchanged and there is one more file in it.
app/Domain/Billing/ChargesCards.php <- interface, defined by the domainapp/Infrastructure/Stripe/StripeCharges.php <- implementation, out at the edgeThis one did something. The domain says what it needs in its own vocabulary, the edge supplies it, and the arrow that used to run from the middle out to Stripe now runs from Stripe in toward the middle. Delete the Stripe directory and the domain still compiles. That is a property you can check, and you want it whether or not a second gateway ever exists.
Same construct, same count, completely different value, and the difference is which side of the boundary the file sits on.
I have made the general version of this argument before in The Pattern-First Trap in PHP, which is about not starting from patterns. This is the same idea pointed backwards: you already started from one, so how do you tell whether it earned anything.
When one implementation is right
“Delete your interfaces” is as bad a rule as the one it replaces, so here is when to keep one.
Keep it when the interface is owned by the inner side and implemented by the outer side, as above. The count is irrelevant.
Keep it when it is a published contract. If the implementation lives in a package other people install, the interface is the API and the number of implementations in your repository says nothing.
Keep it when you are enforcing the boundary with something. Pest’s architecture testing can assert that a namespace only depends on what it is allowed to, and an interface is what gives that assertion something to name.
arch('the domain does not reach for the framework') ->expect('App\Domain') ->not->toUse(['Illuminate\Support\Facades', 'App\Models']);That test is the thing that makes the direction real. Without it the direction is a convention, and conventions decay quietly.
Delete it when the interface and its only implementation live in the same folder, are bound one to one in a provider, and no test uses a different implementation. That combination has no runtime substitution, no direction, and its test substitution claim can be checked in about a minute by grepping the test suite for the interface name.
How to delete one safely
The removal is less frightening than it looks, and PHPStan does most of it.
Replace the type hints with the concrete class, delete the binding, delete the interface, run static analysis. Everything that was relying on the abstraction fails immediately and locally, and there is no runtime surprise waiting, because the container was only ever resolving one thing.
The one thing to watch for is a test that binds a different implementation. If the suite has $this->app->bind(OrderRepositoryInterface::class, FakeOrderRepository::class) anywhere, the interface has been buying test substitution after all, and you should decide whether the fake is better than the framework’s own faking before you take it away.
grep -rn "OrderRepositoryInterface" tests/If that returns nothing, the abstraction has never been used as an abstraction.
What to do on Monday
Run the interface listing against app/. For each one, answer the question in one sentence: runtime substitution, test substitution, or direction.
The ones where the answer is “direction” are worth checking against their location, because that is the claim most likely to be aspirational. If the interface sits in the same folder as its implementation, the direction was never established, and you can either move it to the side that should own it or admit it was decoration.
The ones where you cannot produce a sentence at all are the ones to delete, and you will find that deleting them is a smaller change than adding them was.
Next in this series: the codebase that was completely fine until somebody added a second one, and why a second anything is the only trigger in this series worth acting on.
Why Is This Hard To Change?
You are reading Part 5 of 7 in this learning series.
Keep Reading
Building Research
A desktop research workspace in Laravel and NativePHP. Streaming SSE into a queued job, distilling reports with a local model, and why cosine similarity cannot tell a paraphrase from a contradiction.
Aug 2026 · 22 min read
LaravelEvery Feature Touches Ten Files
Ten files open for a one line change is either layering working correctly or one idea smeared across a codebase. The count does not tell you which, and git history does.
Aug 2026 · 4 min read
LaravelIt Was Fine Until We Added A Second One
Every trigger in this series has been a second something. That is not a coincidence, and it is the only signal in here reliable enough to act on.
Aug 2026 · 8 min read