
What I Abstract in Laravel (and What I Don't)
I used to abstract everything. Then I had to maintain it.
The first "clean architecture" Laravel codebase I shipped had eight layers between the controller and the database. Repository, RepositoryInterface, Service, ServiceInterface, Manager, DTO, Mapper, Eloquent. A junior dev once asked me, with a completely straight face, where to put the actual where clause. I did not have a good answer. The query was real. The abstraction was not.
This is the sixth post in a Laravel series. I have written about the mistakes I stopped making, the structure I use by default, performance work in production, API design that survives change, and authentication and authorization. This one is the layer underneath all of them: which abstractions earn their keep, and which ones are just ceremony copied from a Clean Architecture talk.
This is where most systems don't get cleaner. They get harder to understand.
Table of Contents
The Real Problem
Abstraction is not free. Every layer you add is a layer somebody has to read, debug, mock in tests, and step through with a stack trace at 11pm during an incident.
The cost shows up everywhere. A new engineer's first PR takes two days because they had to learn five interfaces to add one field. A simple bug fix means jumping through four files to find the line that actually issues the SQL. A "small refactor" balloons because every change has to ripple through three layers that exist for no reason except symmetry.
Over-abstraction does not look like a problem on day one. It looks responsible. It looks senior. It is the cleanest code review of the sprint. Six months later it is the code nobody wants to touch, and the only person who can explain why the OrderManagerFactoryProvider exists has left the company.
Laravel best practices are not "use every pattern from a Java book." They are "use the framework's affordances, abstract when the cost of duplication is real, and stop one layer earlier than you think you should."
What I Used to Do
The mistakes I made early, in chronological order of pain:
- A
Repositoryclass for every model. Each one wrapped Eloquent and added zero behavior. I called it "swappable." Nothing was ever swapped. - Interfaces in front of every service. Bound 1-to-1 in a service provider. The interface had one implementation, forever, and made the codebase 30% larger.
- DTOs for everything. Including for places where the validated request data was already shaped correctly. The DTO was a copy of the array with a constructor.
- A
Managerfor every domain noun.OrderManager,UserManager,PaymentManager. Each one wrapped two services and forwarded calls. Pure indirection. - Abstracting "just in case." The classic. We might switch from MySQL to Postgres one day. We did not. We never do.
The pattern under all of these is the same: I was paying for flexibility I did not need with complexity I could feel. Clean architecture in Laravel is not a checklist. It is a series of trade-offs.
What I Abstract Now
Three things, almost always.
Services for multi-step workflows
If an operation touches more than one model, more than one external system, or has any non-trivial sequencing, it gets a service. CheckoutService, BillingService, OnboardingService. The service owns the transaction boundary and the error handling. The controller calls it and gets out of the way.
class CheckoutService
{
public function __construct(
private InventoryService $inventory,
private PaymentGateway $payments,
) {}
public function process(Cart $cart, User $user): Order
{
return DB::transaction(function () use ($cart, $user) {
$this->inventory->reserve($cart);
$charge = $this->payments->charge($cart->total, $user);
return Order::createFromCharge($cart, $charge, $user);
});
}
}That is worth a class. Three steps, one transaction, real coordination.
Actions for single use cases shared across entry points
If the same operation runs from a controller, a queue job, and a console command, it gets an Action. One class, one public method, one responsibility. The behavior cannot drift between callers because there is only one caller from the framework's point of view.
I do not create an Action for code that runs in exactly one place. That is just a method with a longer commute.
External integrations, always
Payment gateways, email providers, SMS APIs, file storage, anything I do not own. Wrap them behind an interface defined in your domain language. Not because you will switch providers (you usually will not), but because the third party will change their interface eventually, and you want one file to update, not forty.
interface PaymentGateway
{
public function charge(Money $amount, User $user): Charge;
public function refund(Charge $charge, ?Money $partial = null): Refund;
}When Stripe deprecates a parameter or the new gateway has a slightly different shape, the rest of the codebase does not notice.
What I Don't Abstract
This is the section that gets disagreement. So be it.
Repositories, by default
Eloquent already implements the repository pattern. Order::where('user_id', $id)->latest()->get() is a repository call. Wrapping it in OrderRepository::findRecentByUser($id) adds a file, an interface, a binding, and zero capability.
I add a repository when, and only when, the queries are complex enough to deserve their own home. Reporting modules. Multi-table aggregations. Search with dynamic filters. If the implementation is "passes through to Eloquent," the repository should not exist.
Simple CRUD
A POST /tags endpoint that creates a row, validates two fields, and returns a Resource does not need a service. The controller, a Form Request, and a Resource are enough. Adding a TagService for Tag::create($request->validated()) is comedy.
Premature interfaces
I do not introduce an interface until there is a second implementation, or a clear test isolation reason. interface UserService { ... } with one class behind it is yellow tape. Laravel's container handles concrete types fine.
Thin logic that fits in the model
A fullName() accessor on User is an accessor. It does not need a UserNameFormatter service. If the logic is two lines and lives with its data, leave it there. Models are allowed to have behavior. They are not just dumb rows.
A Real Example
A real codebase I inherited. The path from "create order" to "row in the database" looked like this:
OrderController
-> OrderManager
-> OrderService
-> OrderRepository (interface)
-> EloquentOrderRepository
-> Order::create(...) Five files. Six method hops. Zero behavior added at any layer except the last. The OrderManager and OrderService had identical method signatures with names like createOrder and create. The interface had one implementation. The repository was a passthrough.
The refactor took one afternoon. The clarity lasted much longer:
OrderController
-> CreateOrder (Action)
-> Order::create(...)Two files. One real abstraction (the Action, because the same operation also ran from a webhook handler and a console import). The Eloquent call moved back to where it always belonged. The test suite shrank by 40% and got faster. Nobody missed any of the deleted classes.
The lesson was not "Manager is a bad pattern." It was that every layer needs to justify its existence with behavior, not symmetry.
The Trade-offs
Abstraction buys flexibility. It pays for that flexibility in cognitive load, indirection, file count, and onboarding time. The trade is worth it when the flexibility is needed soon and is hard to retrofit. It is not worth it when the flexibility is theoretical.
Some heuristics that have aged well for me:
- Rule of three. I do not abstract a pattern until I have seen it three times. Two cases is a coincidence. Three is a shape.
- Layers should add capability, not just type signatures. If a class only forwards calls, delete it.
- Optimize for the second engineer. The codebase you ship is not the one you maintain. Build it for the person who will read it cold.
- Patterns are descriptive, not prescriptive. "Repository" is a name we give to something that has emerged. It is not a contract you have to fulfill before you can write a query.
Key Principles I Follow
- Abstract when duplication is real. Not when it is imagined.
- Abstract when behavior coordinates. Not when it forwards.
- Do not abstract for the future. YAGNI applies harder in Laravel than in most stacks because the framework already gives you so much.
- Prefer clarity over architectural purity. A reader who can find the SQL in 10 seconds beats a class diagram that wins design awards.
- Keep layers minimal. Every one of them is a tax on every future change.
Closing Thoughts
Avoiding over-engineering in Laravel is not about being a minimalist. It is about being honest. Honest that most "for flexibility" code never gets the flexibility used. Honest that an interface with one implementation is a comment with extra steps. Honest that the pattern from the conference talk was solving a problem your app does not have.
Good code is not the most abstract code. It is the easiest code to understand and the easiest code to change. Sometimes those goals demand a service layer, an action class, and a gateway interface. Often they do not. The judgment call is the job.
Over-engineered code does not fail loudly. It just slows everything down.
Where do you draw the line with abstraction in Laravel?