The advice that circulates as laravel best practices design patterns is usually justified as testability and reuse, with no threshold attached. That matters because the same pattern costs money the moment it needs an interface, a binding, and a test double. Use these patterns when the behavior is called from more than one entry point and carries branching or side effects; keep plain controller methods when the operation is one or two queries with no branching; avoid a pattern you cannot exercise through the public interface with two requests in under ten minutes, because that is how pattern sprawl starts.
A typical mid-sized Laravel app in this category has a service container with thirty-odd bindings, most of them resolving a class with no constructor arguments and no alternate implementation. This article gives you a threshold for deciding which of those earn their keep, and a five-command audit you can run against any existing pattern before you keep it or delete it.
Adopt a pattern when it is multiply called, carries real branching or side effects, and is verifiable through one public method. Skip it when it is a single-query pass-through, when the only implementer is the concrete class, or when its output cannot be reproduced headlessly inside a fast test suite.
Which Laravel design patterns pay for themselves?
Service classes, actions, repositories, policies, events, and job middleware all pay for themselves only when the wrapped behavior is called from multiple entry points, carries branching or side effects, and can be verified through one public method; otherwise a controller method plus a Form Request is cheaper to read and cheaper to change.
Three signals decide it:
- The behavior has more than one caller (web controller, Artisan command, queued job, or a second route). If there is exactly one caller, a class is indirection with no payoff.
- The behavior branches. Two or more decision points plus a side effect is where a method becomes hard to read and a named class starts to help.
- The behavior is verifiable in isolation. If you cannot assert on it with one method call and no HTTP request, you are not testing the pattern, you are testing the framework around it.
When two or three signals are true, the pattern earns its keep. One or zero, and you should leave the code where it is.
| Pattern | Cost per instance | Use when | Skip when |
|---|---|---|---|
| Service class | 1 file, 1 test double | Multiple callers with branching and side effects | Single-query pass-through from one controller |
| Action class | 1 file, small interface | One operation, one public method, called from jobs and HTTP | The operation is part of a larger transaction you own elsewhere |
| Repository | 1 interface, 1 binding, 1 fake | You swap drivers per environment under test | You use Eloquent as the only implementation and swap nothing |
| Policy | 1 file, 1 registration | Authorization rules are shared by controllers, jobs, and views | The rule is a single ownership check on one route |
| Domain event plus queued listener | 1 event, 1 listener, 1 queue hop | The side effect is slow, retryable, and not part of the response | The caller needs the side effect's result before it can respond |
The container binding cost is the one most often underestimated. Every interface you bind to a concrete class is one line in a service provider, one line in a fake for tests, and one more thing a new engineer has to trace before they understand what runs. That is a real cost, paid every time someone reads the code. For the mechanics of binding and resolution, see our walkthrough of Laravel service container bindings and contextual injection.
The Laravel documentation describes container binding and resolution directly; see Laravel service container documentation for the current binding API.
The interface-with-one-implementation trap
An interface whose only implementation is the concrete class next to it adds a binding, a test fake, and a layer of indirection while changing nothing about runtime behavior. It is the most common pattern mistake in Laravel codebases because it looks like good design and audits like dead weight.
You see it as a service provider full of lines like this, with no second class implementing the contract anywhere in the codebase or in any test:
// Laravel 12.x, app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->bind(
\App\Contracts\InvoiceTotalCalculator::class,
\App\Services\InvoiceTotalCalculator::class,
);
}
The fix is not to delete the class. It is to delete the contract and bind nothing, letting the container resolve the concrete class by autowiring. If you later need a second implementation, the interface takes one commit to add back, and until then you have three fewer things to trace.
This does not apply to contracts that Laravel itself resolves or that you swap per environment, such as a payment gateway fake in tests. The audit question is whether a second implementation exists today, not whether one might exist someday.
Audit a pattern in five commands
These five commands tell you whether a pattern in your codebase is load-bearing or decoration. Run them against one suspicious class before deciding whether to keep it, refactor it, or delete it, and record the output rather than trusting your memory of the code.
1. Count the callers
A pattern called from one place has no reuse payoff. Anything with a single caller is a candidate for inlining:
grep -rn "InvoiceTotalCalculator" app routes tests | wc -l
If the only hits are the class definition, the test file, and one controller, the pattern is not yet earning its interface.
2. Count the implementers
A contract with one implementer is usually not a contract. Count concrete classes that declare the interface:
grep -rn "implements InvoiceTotalCalculatorInterface" app
One result means the interface is documentation, not abstraction. Two or more means it is doing real work.
3. Trace the bindings
Bindings are invisible at the call site, so they get stale. List every container binding in the app in one pass:
grep -rn "\$this->app->bind\|\$this->app->singleton" app/Providers
Each line is a thing a new engineer must read before they understand what runs. If you cannot justify a binding in one sentence, delete it and let autowiring handle the class.
4. Check for hidden query cost
Patterns wrap queries, so a pattern audit is also an eloquent n+1 audit. Enable the query log in a test or a local request and print the count and the repeated shapes:
// Laravel 12.x, inside a test or tinker session
DB::enableQueryLog();
// exercise the endpoint or call the service
app(InvoiceTotalCalculator::class)->calculate($invoice);
collect(DB::getQueryLog())
->groupBy('query')
->map->count()
->sortDesc()
->take(5);
A service class that looks clean can still fire the same query fifty times. The query log catches that; reading the service constructor does not. The same N+1 shapes and their fixes are covered in our guide to fixing Eloquent N+1 problems.
5. Re-verify through the public interface
Delete the class's unit test and rerun only its feature or integration test. If the pattern is load-bearing, the integration test still passes and still has assertions on behavior. If it fails because the only thing that asserted on it was its own double, the pattern was decoration.
Where these patterns hide query problems
A service class makes eloquent n+1 more likely, not less, because the class hides which queries it fires behind a method name. The constructor tells you nothing about the query count; the query log does, and it is the only reliable check.
This is the failure mode with numbered steps and a symptom you can reproduce in a local test:
- Write a service method that loops over a collection and calls a relationship accessor inside the loop.
- Wrap the service call in a headless framework test that hits the real database.
- Log the query count from the test, and assert on the total number of queries as well as the response.
- Swap the lazy relationship access for an eager load and rerun the same test.
The observable symptom is a query count that grows with the number of rows in the collection, not with the number of distinct models in the request. A test that asserts on the response will still pass, which is why the count has to be asserted separately. The same reasoning applies to loops inside controllers.
None of this argues against using a service class. It argues for moving the query-count assertion into the test suite, where it catches the regression before the pattern does.
Skills matter more than pattern names
The laravel best practices skills that transfer between codebases are reading a service provider top to bottom, sampling the query log, and asserting query counts in tests. Knowing the name of a pattern is worth much less than being able to trace what it does at runtime.
That is also why copying a pattern library wholesale is a trap. A pattern that works in one app depends on assumptions about query volume, queue depth, and how often the operation is called. Those assumptions are not in the pattern's name, and they are exactly what changes when you move it.
If you do copy from an example, treat it the way you would treat any new dependency: run the five commands above on your own codebase before you believe it. The pattern-level guidance in Laravel best practices for production PHP apps is a reasonable baseline, but it still does not tell you when a pattern stops paying rent, which is what the signals above are for.
If you are looking for a starting point rather than a survey, treat the Laravel documentation as the reference and read it against your code, not the other way round. The Laravel documentation is the versioned source for anything this article states.
Common questions
Is a service class the same thing as a repository pattern?
No. A service class holds behavior, such as calculating a total or dispatching work. A repository wraps data access behind a contract, with bindings and a fake. The repository is the one worth skipping until you actually swap implementations per environment; the service class is often worth adopting earlier.
When should a controller stay fat in Laravel?
When the endpoint is one or two queries with no branching and a single caller. Splitting it adds a file, a binding, and a test double for no gain. The threshold is the three signals in this article: multiple callers, real branching, and a public interface you can verify in isolation.
How do I tell if a pattern is load-bearing?
Delete its unit test and rerun the feature or integration test that exercises it through the public interface. If the feature test still passes with real assertions, the pattern is load-bearing. If it only passed because the double asserted on it, the pattern is decoration and can go.
The bottom line
Patterns are a way to make branching and side effects testable, not a way to signal that code is serious. Adopt one when it has multiple callers, real branching, and a testable public interface. Skip it when it wraps a single query or owns a contract with one implementer. Verify the result in the query log, because the class that reads cleanest is the one most likely to hide an N+1 behind its method name.
