Open almost any codebase that is a few years old and you will find at least one controller that is 600 lines long. It validates input, calculates totals, sends emails, writes to three tables and formats the response. Next to it there might be a "service" that only forwards calls, and a repository that wraps Model::find() for no clear reason.
All of these come from the same question that is rarely answered clearly: where should this piece of logic live? This guide gives you a practical answer, using Laravel examples, though the same ideas apply to Node, .NET, Django or any layered backend.
The Short Answer
Each layer has one job:
Controller: speak HTTP. Receive the request, hand it to the right place, and return a response.
Service: run the business use case. Apply the rules, coordinate the steps, and decide what happens.
Repository: talk to storage. Fetch and save data, and hide complicated queries behind clear method names.
If you remember only one rule, make it this: business decisions belong in the service layer, not in the controller and not in the database layer.

What Belongs in a Controller
A controller is an adapter between the web and your application. It should be thin enough that you could replace HTTP with a CLI command or a queue job and reuse everything underneath it. A good controller does:
Authentication and authorization checks (often via middleware or policies).
Input validation, ideally through a Form Request.
Calling one service method or action.
Turning the result into a response: JSON, a redirect, a view, or an HTTP status code.
Here is what a thin controller looks like:
class OrderController extends Controller
{
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder)
{
$order = $placeOrder->handle(
customer: $request->user(),
items: $request->validated('items'),
);
return new OrderResource($order);
}
}Notice what is missing: no price calculations, no stock checks, no emails. The controller does not know how an order is placed; it only knows who to ask.
Warning signs of a fat controller
Methods longer than 20 to 30 lines.
Database transactions opened inside the controller.
The same logic copied into an API controller, a web controller and an Artisan command.
ifstatements that encode business rules, such as discounts, approval limits or status transitions.
What Belongs in a Service
The service layer (some teams call these actions or use cases) is where your application actually does its work. It holds the rules that would still exist if your app had no web interface at all. A service typically:
Enforces business rules: "a customer cannot order more than the available stock", "invoices above a limit need approval".
Coordinates multiple steps inside a transaction.
Calls other services, repositories, or external APIs.
Dispatches events or jobs for side effects such as emails and notifications.
class PlaceOrder
{
public function __construct(
private ProductRepository $products,
private OrderRepository $orders,
) {}
public function handle(User $customer, array $items): Order
{
return DB::transaction(function () use ($customer, $items) {
$products = $this->products->lockForUpdate(array_column($items, 'product_id'));
foreach ($items as $item) {
$product = $products[$item['product_id']];
if ($product->stock < $item['quantity']) {
throw new InsufficientStock($product);
}
$product->decrement('stock', $item['quantity']);
}
$order = $this->orders->createForCustomer($customer, $items, $products);
OrderPlaced::dispatch($order);
return $order;
});
}
}This class can be called from a controller, a queue job, a seeder or a test, and the rules stay the same everywhere. That reuse is the main reason the layer exists.
Keep services focused
A single OrderService with 40 methods is just a fat controller in a different folder. Prefer small, use-case-shaped classes such as PlaceOrder, CancelOrder or ApproveInvoice. They are easier to name, test and find.
What Belongs in a Repository
A repository hides how data is stored and retrieved. Its methods should read like questions the business asks, not like SQL:
class OrderRepository
{
public function overdueForCustomer(int $customerId): Collection
{
return Order::query()
->where('customer_id', $customerId)
->where('status', OrderStatus::Unpaid)
->where('due_date', '<', now())
->with('items.product')
->orderBy('due_date')
->get();
}
}Good candidates for a repository are complex or reused queries, queries that must always eager-load certain relations to avoid N+1 problems, reporting queries with joins and aggregates, and data access you may later move to a cache, a read replica or another service.
What does not belong there: business decisions. A repository can return "unpaid orders past their due date". It should not decide whether those customers get blocked. That is a rule, and rules live in services.
Do you even need repositories in Laravel?
Honest answer: not always. Eloquent already is an abstraction over the database, and wrapping every find, create and update in a repository adds files without adding value. A practical middle ground many teams use:
Use Eloquent directly in services for simple reads and writes.
Use query scopes on the model for small, reusable filters.
Introduce a repository or query class when a query is complex, reused in several places, or performance-sensitive.
The goal is not to follow a pattern perfectly. It is to keep complicated data access in one predictable place.
A Simple Decision Checklist
When you are about to write a piece of logic, ask these questions in order:
Is it about HTTP (request, headers, status codes, redirects, sessions)? Put it in the controller, middleware or a Form Request.
Is it a business rule or a multi-step process? Put it in a service or action class.
Is it about how data is queried or persisted? Put it in a model scope, a repository or a query class.
Is it about how data is presented? Put it in an API Resource, a view or a presenter.
Would you need it again from a job or command? Then it definitely does not belong in the controller.
Common Mistakes to Avoid
Pass-through services
A service method that only calls $this->repository->find($id) adds a layer without adding logic. If there is no rule to apply, the controller can call a simple query directly, or you can skip the extra class until a rule appears.
Services that know about HTTP
If your service accepts a Request object or returns a JsonResponse, it is tied to the web layer and cannot be reused cleanly. Pass plain values or a small data object (DTO) instead, and let the controller build the response.
Repositories that leak query builders
Returning a raw query builder from a repository lets callers add any condition they like, which defeats the purpose. Return results, or offer specific, well-named methods.
Rules hidden in models and observers
Model events are handy, but important business rules buried in an observer are easy to miss and hard to test. Keep critical rules visible in the service where the use case is handled.
How This Scales in ERP-Style Systems
In ERP and back-office applications, the same entity is touched from many entry points: the admin panel, a public API, scheduled jobs, imports and approval workflows. Keeping rules in services is what stops these entry points from drifting apart. When "an invoice above the approval limit must go to a manager" lives in one SubmitInvoice action, every entry point gets the same behaviour, and a change to the rule is a change to one class.
It also makes testing easier. You can unit test a service with fake repositories or a test database, without spinning up HTTP requests for every scenario.
Conclusion
Controllers translate HTTP, services make decisions, and repositories fetch and store data. You do not need a repository for every model or a service for every endpoint, but you do need a clear home for business rules. Start with thin controllers and small action classes, add repositories only when queries get complicated, and your codebase will stay readable as the requirements keep coming.
