
How I Structure a Scalable Laravel Application (In Production)
After breaking enough Laravel apps in production, I stopped thinking in terms of controllers and started thinking in terms of structure.
Yesterday I wrote about 5 Laravel Mistakes I Made (and How I Fixed Them). This is the other side of that post. Not the mistakes, but the structure I now reach for by default. Most of those bugs simply cannot happen inside it.
Table of Contents
The Core Philosophy
Four rules. Everything else is a consequence.
- Controllers stay thin. They route requests. Nothing else.
- Business logic is explicit. If a behavior matters, it lives in a class named after it.
- Every component is testable in isolation. If I cannot unit test it without booting Eloquent, the design is wrong.
- Structure scales with complexity, not file count. Adding folders does not make code "scalable."
I have inherited "clean" Laravel apps with 40 service classes nobody trusted and 4 fat controllers everyone was afraid to touch. Both failed the same test: can a new engineer read one entry point and understand what the system does? If not, the structure is not working.
My Laravel Project Structure
Here is the layout I default to on any Laravel project that's expected to live longer than a year:
app/
├── Actions/ # single-purpose use cases (CreateOrder, RefundCharge)
├── Services/ # multi-step orchestration (CheckoutService, BillingService)
├── DTOs/ # typed data passed between layers
├── Http/
│ ├── Controllers/ # routing only, no logic
│ ├── Requests/ # validation and authorization
│ └── Resources/ # API response shapes
├── Models/ # Eloquent, kept lean
└── Repositories/ # only when raw queries get hairy Two folders are optional. Actions/ is for atomic, single-purpose use cases. Repositories/ only appears when I have queries too gnarly to live in a model scope. Adding either one before you need it just creates ceremony.
The non-negotiable folders are Services/, Requests/, and Resources/. Without those three, you do not have a scalable Laravel application. You have a Laravel app waiting to become unmaintainable.
Layer Breakdown
Controllers
Receive a validated request, hand it to a service or action, return a resource. That's it. If a controller method is longer than 10 lines, something is leaking out of the layer it belongs in.
Services
Where business logic lives. CheckoutService, OrderService, BillingService. Services orchestrate multiple steps and own the policy decisions. They depend on other services through constructor injection, never through facades, so they can be unit tested without Laravel running.
Actions
A single use case as a class with one public method. CreateOrder, SendInvoice, RefundCharge. I use Actions when the same operation is triggered from a controller, a queue job, and a console command, because all three call the same class and the behavior cannot drift.
Form Requests
Every input goes through one. Validation rules and authorization in the same place, where the request enters the system. The controller never sees an unvalidated payload.
API Resources
Every response goes through one. No raw ->toArray(), no manual array building. The Resource is the contract. When the underlying model changes, the Resource is the only place I update.
Optional layers
Repositories only when queries are complex enough to deserve their own home, usually for reporting modules. DTOs for moving data between services without dragging Eloquent models around. Both are tools, not requirements.
A Real Example: Checkout Flow
The cleanest version of a checkout endpoint I have ever shipped looks like this. The whole controller method is four lines.
// app/Http/Controllers/CheckoutController.php
public function store(StoreCheckoutRequest $request, ProcessCheckout $action)
{
$order = $action->execute(
CheckoutData::fromRequest($request),
$request->user(),
);
return new OrderResource($order);
}The Action does the actual work. It depends on three collaborators, each one isolated and mockable.
// app/Actions/ProcessCheckout.php
class ProcessCheckout
{
public function __construct(
private PaymentGateway $payment,
private InventoryService $inventory,
private OrderRepository $orders,
) {}
public function execute(CheckoutData $data, User $user): Order
{
$this->inventory->reserve($data->items);
$charge = $this->payment->charge($data, $user);
return $this->orders->createFromCharge($data, $charge, $user);
}
}The flow is dead obvious: Controller → Request (validation) → Action → Services → Repository → Resource. A new engineer can trace it in 30 seconds. Swapping Stripe for a different processor means changing one binding in a service provider, not touching the controller, and not editing the Action.
Notice what is not in the controller: no DB transactions, no payment SDK calls, no email queueing, no Eloquent. Every one of those is the kind of leak that turned my old CheckoutController into 600 lines of regret.
Mistakes I Don't Make Anymore
Each one of these maps directly to a layer in the structure above. That is not coincidence. The structure exists because the mistakes existed first.
- Fat controllers. Solved by Actions and Services. The controller has nowhere to put logic.
- Hidden logic in observers and events. Solved by making business logic explicit. Observers handle cross-cutting concerns only (audit, cache invalidation), never core flows.
- Inconsistent API responses. Solved by Resources. Every endpoint returns a Resource, every Resource has the same envelope.
- Eloquent in every corner. Solved by Repositories and DTOs at the boundaries. Eloquent stays inside Models and Repositories. The rest of the app sees plain objects.
- Logic that fires from a seeder by accident. Solved by calling Actions explicitly instead of relying on model events to do the work.
Principles I Follow
- Separation of concerns. Each layer has one job. If two layers want to do the same job, one of them is in the wrong place.
- Explicit over implicit. A function call I can grep for beats a magic event listener every time.
- Optimize where it matters. A repository is not premature optimization if your reporting query is 80 lines.
- Structure for change, not for today. The codebase you ship is not the one you maintain. Build it for the second engineer, not the first.
Closing Thoughts
Structure is not a productivity tool. It will not make you ship faster on day one. The first time you write a Form Request and a Resource for an endpoint that "could just be a one-liner," it feels like overhead.
It pays off the day someone other than you needs to extend the code. Or the day a payment provider changes their webhook. Or the day a regulator asks you to add an audit trail to every order mutation. By then, the structure decides whether the change takes 20 minutes or 20 hours.
There is no perfect Laravel architecture. There is only a structure that survives contact with the next requirement. The one above is mine.
Good structure doesn't make your app faster on day one. It makes it survivable when it grows.
If you're working with Laravel, how do you structure your projects?