Your first MVC project felt like a small miracle.
You learned three nouns (controller, model, view) and the framework handed you a working app. Routes mapped to controllers, controllers spoke to models, models read and wrote the database, and the view was whatever the controller returned. Five files, one feature, twenty minutes. It felt like the framework had figured out architecture so you didn't have to.
Then a year went by.
Now you're looking at a controller that's 280 lines long. It validates input, parses a CSV, calculates a discount, charges a card, sends two emails, queues a webhook, writes to three tables, and finally returns JSON. The model class next to it has 40 methods, half of which take a $context array because nobody could decide if the logic should live in the controller or the model, and "the model" was at least closer to the data.
Nothing is technically wrong. Everything is impossible to change.
This is what happens when a pattern designed for displaying things meets an application that has to do things. MVC doesn't break. It just runs out of vocabulary.
The Version Of MVC You Met First
When teaching materials show MVC, they show this:
class PostController extends Controller
{
public function show(Post $post)
{
return view('posts.show', ['post' => $post]);
}
public function index()
{
return view('posts.index', [
'posts' => Post::query()->latest()->paginate(20),
]);
}
}
The controller does almost nothing. It takes a request, asks the model for data, hands the data to the view. The model knows about the database. The view knows about HTML. The controller knows that there's an HTTP request and that someone wants posts.
This works beautifully for the first thing every framework demo builds: a CRUD app where the request is the operation. GET /posts means "show posts." POST /posts means "save a post." You can read the controller out loud and the English version is the same as the code.
The trouble is that real applications stop being like that around month four.
How Controllers Get Fat
Real features arrive on top of CRUD operations, not next to them. A user clicks "checkout," which sounds like one action, but on the server, it's seven things in a row that all have to succeed or all have to be undone.
Here's how that conversation usually starts. You begin with a perfectly reasonable controller:
public function store(Request $request)
{
$cart = Cart::find($request->input('cart_id'));
$order = Order::create(['cart_id' => $cart->id, 'total' => $cart->total]);
return response()->json($order);
}
Then product asks for a discount code. You add it.
Then finance asks you to record tax based on the shipping address. You add it.
Then the payment processor needs to be charged before the order is final. You add a call to the payment client.
Then you have to email the customer a receipt. You add that.
Then a flag goes off in product analytics: items aren't being decremented from inventory until the next overnight job, and people are double-buying. You add an inventory write.
Then someone notices that if the email service is down, the order still saves but the customer never knows. You wrap things in a transaction. Then you realize the email is outside the database, so the transaction doesn't help. You add a queue job.
Six weeks in, your controller is this:
public function store(Request $request)
{
$validated = $request->validate([/* 30 lines of rules */]);
$cart = Cart::with('items.product')->findOrFail($validated['cart_id']);
if ($cart->user_id !== $request->user()->id) {
return response()->json(['error' => 'Forbidden'], 403);
}
$discount = null;
if (!empty($validated['discount_code'])) {
$discount = Discount::where('code', $validated['discount_code'])
->where('expires_at', '>', now())
->first();
if (!$discount) {
return response()->json(['error' => 'Invalid discount'], 422);
}
}
$subtotal = $cart->items->sum(fn ($i) => $i->price * $i->quantity);
$discountAmount = $discount ? $subtotal * $discount->percent / 100 : 0;
$tax = $this->taxFor($validated['shipping_address'], $subtotal - $discountAmount);
$total = $subtotal - $discountAmount + $tax;
DB::beginTransaction();
try {
foreach ($cart->items as $item) {
$product = Product::lockForUpdate()->find($item->product_id);
if ($product->stock < $item->quantity) {
DB::rollBack();
return response()->json(['error' => 'Out of stock'], 409);
}
$product->decrement('stock', $item->quantity);
}
$order = Order::create([
'user_id' => $request->user()->id,
'cart_id' => $cart->id,
'subtotal' => $subtotal,
'discount' => $discountAmount,
'tax' => $tax,
'total' => $total,
]);
$charge = $this->payments->charge($total, $validated['payment_token']);
$order->update(['payment_id' => $charge->id, 'status' => 'paid']);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Checkout failed', ['cart' => $cart->id, 'error' => $e->getMessage()]);
return response()->json(['error' => 'Checkout failed'], 500);
}
SendOrderReceipt::dispatch($order);
NotifyWarehouse::dispatch($order);
return response()->json($order, 201);
}
Read that and ask: what is this method about?
The honest answer is that it's about six different things. Pricing. Authorization. Inventory. Persistence. Payment. Notification. Each of those concepts has its own rules, its own future changes, and its own people who care about it. They've all ended up inside one HTTP handler because the framework gave you exactly one place to put work that happens during a request.
That's the fat controller problem. It's not really about size. A 200-line controller that does one thing well is fine. The smell is unrelated concerns piled into one method because the structure had no other slot for them.
"Just Move It To The Model"
The most common first answer is to push logic into the model.
public static function checkout(User $user, Cart $cart, array $input): self
{
// ... all the logic from the controller, now on the Order class
}
This feels right for about a week. The controller gets thin again. The logic has a home. The home is named after a domain noun, not an HTTP verb. Senior engineer instincts say yes.
Then the next feature lands and the seams show.
The pricing rules don't really belong on Order. They apply during checkout, but also when you preview a cart, when you generate a quote, when you display "you'd save $X with this code" before the customer commits. So either pricing lives on Order and gets called from places where there is no order yet, or you copy it onto Cart, or you create a static helper that quietly stops being part of any model at all.
The inventory check has the same problem. It belongs to the checkout flow, not to Order. The Order is what results from a successful checkout. Putting the check on the model conflates the operation with the artifact it produces.
And then there's the testability tax. To unit-test Order::checkout, you have to instantiate users, carts, payment clients, mailers, because the model now reaches for all of them. The model used to be the thing you could trust to be small. Now it's the thing you can't run without booting half the app.
Active Record models are excellent at being a thin wrapper around a row of data with a few domain-meaningful methods on it. They are not excellent at being the place where multi-step transactional operations live. Trying to make them do that produces what people charitably call a "rich domain model" and uncharitably call a "god model."
What MVC Quietly Assumed
The original MVC paper from the 1970s was about desktop UIs for Smalltalk. Models were domain objects, views drew them on screen, controllers took mouse and keyboard input and translated it into model changes. The problem space was one user, one window, one mostly-synchronous interaction.
When MVC was lifted into web frameworks decades later, the words came along but the problem changed underneath them. A web "controller" is not what Smalltalk meant by a controller. It's an HTTP request handler that happens to be the place the framework lets you put code that runs during a request. A web "model" is not what Smalltalk meant either; it's whatever your ORM gives you, which is usually a row mapper plus a few methods.
The thing the original pattern didn't anticipate was services: stateful, dependency-having, logic-rich objects that aren't a row in the database and aren't a renderer. Most real applications need them. MVC just doesn't have a slot for them, so they end up squatting in the controller or the model.
Once you see that, the question stops being "is this controller too big?" and starts being "where is the slot for the work that isn't HTTP and isn't a row?"
Adding A Service Layer
A service is the slot. It's a class (or in some languages, a module of functions) whose job is to express one domain operation in terms of the data it needs and the data it produces. It doesn't know what an HTTP request is. It doesn't know what a JSON response is. It doesn't know what a queue worker is. Anything that does know (the controller, the queue handler, the artisan command) calls into the service to actually do the work.
The same shape works in every backend framework. Here's the checkout flow as a service in three languages:
class CheckoutService
{
public function __construct(
private PaymentClient $payments,
private InventoryService $inventory,
private PricingService $pricing,
) {}
public function checkout(User $user, Cart $cart, CheckoutInput $input): Order
{
$this->ensureOwner($user, $cart);
$price = $this->pricing->priceCart($cart, $input->discountCode, $input->shippingAddress);
return DB::transaction(function () use ($user, $cart, $price, $input) {
$this->inventory->reserve($cart->items);
$order = Order::create([
'user_id' => $user->id,
'cart_id' => $cart->id,
'subtotal' => $price->subtotal,
'discount' => $price->discount,
'tax' => $price->tax,
'total' => $price->total,
]);
$charge = $this->payments->charge($price->total, $input->paymentToken);
$order->markPaid($charge->id);
return $order;
});
}
}
export class CheckoutService {
constructor(
private readonly payments: PaymentClient,
private readonly inventory: InventoryService,
private readonly pricing: PricingService,
private readonly db: Database,
) {}
async checkout(user: User, cart: Cart, input: CheckoutInput): Promise<Order> {
this.ensureOwner(user, cart);
const price = await this.pricing.priceCart(cart, input.discountCode, input.shippingAddress);
return this.db.transaction(async (tx) => {
await this.inventory.reserve(cart.items, tx);
const order = await tx.orders.insert({
userId: user.id,
cartId: cart.id,
subtotal: price.subtotal,
discount: price.discount,
tax: price.tax,
total: price.total,
});
const charge = await this.payments.charge(price.total, input.paymentToken);
await tx.orders.markPaid(order.id, charge.id);
return { ...order, status: 'paid', paymentId: charge.id };
});
}
}
class CheckoutService:
def __init__(
self,
payments: PaymentClient,
inventory: InventoryService,
pricing: PricingService,
) -> None:
self.payments = payments
self.inventory = inventory
self.pricing = pricing
def checkout(self, user: User, cart: Cart, data: CheckoutInput) -> Order:
self._ensure_owner(user, cart)
price = self.pricing.price_cart(cart, data.discount_code, data.shipping_address)
with transaction.atomic():
self.inventory.reserve(cart.items)
order = Order.objects.create(
user=user,
cart=cart,
subtotal=price.subtotal,
discount=price.discount,
tax=price.tax,
total=price.total,
)
charge = self.payments.charge(price.total, data.payment_token)
order.mark_paid(charge.id)
return order
The shape is the same in every language: take typed inputs, validate domain invariants, orchestrate other services, write to the database in a transaction, return a domain result. Nothing in those classes mentions a request, a response, a status code, or a route.
The controller becomes what the framework actually wants it to be, an HTTP adapter:
public function store(CheckoutRequest $request, CheckoutService $checkout)
{
$order = $checkout->checkout(
$request->user(),
Cart::findOrFail($request->validated('cart_id')),
CheckoutInput::fromRequest($request->validated()),
);
return new OrderResource($order);
}
Five lines that you can read out loud. Validate the request with a form request, call the service, return a resource. That's it. The 280-line method became a 5-line method, and the logic didn't disappear. It moved somewhere it could be tested in isolation, called from a queue worker if you ever queue checkouts, and reused by a future internal admin tool.

What Goes Where, Stated Plainly
Once a service layer exists, the rules for where things belong stop being negotiable. They become close to mechanical:
The controller owns the HTTP boundary. Parse the request body. Authenticate the user. Run input validation that's about shape: is this a valid email, is this an integer between 1 and 100. Call exactly one service method. Map the result (or the exception) onto a status code and a response body. If a controller method has a second if statement that isn't about HTTP, it's already too big.
The service owns the operation. Receive typed input. Check business rules: is this user allowed to do this, is this cart already paid, is this product still in stock. Orchestrate other services. Wrap the database work in a transaction. Return a domain object or throw a domain exception. Services don't know about requests, queues, or CLI commands; they're called by all three.
The model (or repository, depending on how you draw the line) owns persistence. Define the shape of a row. Provide query scopes that are meaningful in the domain ("active subscriptions", "orders in the last 30 days"). Don't put cross-aggregate orchestration on a model. If a method needs to touch three other tables to do its job, it's a service operation, not a model method.
The view owns presentation. In a JSON API that's an API resource, a serializer, or a typed DTO that gets JSON.stringify'd. In a server-rendered app it's the actual template. The view should be able to render any model the service hands it without making a single database call.
When You Don't Need A Service Layer
You can make a small project worse by introducing a service layer too early.
If your endpoint is GET /posts/:id and the body is Post::find($id), wrapping that in a PostService::findById accomplishes nothing. You've added a class, an injection, and a layer of indirection so that future-you can swap implementations, except you won't, and even if you did, you'd swap the model, not the service. CRUD over a single resource is what MVC was designed for. Use it.
The signal that you actually need a service layer isn't a number. It's a list of small smells, any two of which are enough:
The same operation needs to run from more than one entry point. Checkout from the web, checkout from a mobile API, checkout from an admin "place order on behalf of customer" tool, checkout from a recurring-subscription cron job. The moment a second caller appears, the controller stops being the right home. It owns the HTTP path, but the operation is bigger than HTTP.
A controller method has two or more reasons to fail that aren't input validation. "User isn't allowed", "cart is already paid", "stock is insufficient", "payment was declined", each of those is a domain failure mode, not a request error. When you see four if branches that each return a different status code for a different domain reason, the operation has its own logic that wants its own home.
You're writing tests that need to construct a fake Request to exercise business rules. That's the tell. The rules don't actually need a request. You're constructing one because the rules live behind a controller. Move them down a layer and the tests get smaller and the rules get sharper.
A change touches three controllers at once. Pricing rules live in the checkout controller, the cart controller, and the quote controller. When the marketing team changes how a discount stacks with a tax line, you have to remember three places. That's an operation that wants to be a service.
If none of those have happened yet, don't refactor speculatively. The strongest argument for a service layer is the actual pain of not having one, and that pain is also what teaches the team where the boundaries actually are.
Beyond Services: One Operation Per Class
Once a service exists, the next question is how big it should grow. A CheckoutService with one checkout method is reasonable. A CheckoutService with checkout, cancel, refund, addItem, removeItem, applyDiscount, calculateShipping, and recalculateTotals is starting to be the same fat-controller problem at a different layer.
A common next step is to break each domain operation into its own class. Laravel calls these Actions, Rails calls them Interactors, Django people often write them as a function or a class with a single __call__, and hexagonal-architecture folks call them Use Cases. The shape is the same:
export class CheckoutUseCase {
constructor(
private readonly payments: PaymentClient,
private readonly inventory: InventoryService,
private readonly pricing: PricingService,
private readonly db: Database,
) {}
async execute(user: User, cart: Cart, input: CheckoutInput): Promise<Order> {
// ... the body of the old checkout method, unchanged
}
}
One class, one method named execute (or handle, or __invoke), the constructor declares its dependencies, and the controller calls useCase.execute(...). The benefit isn't really philosophical. It's that each operation lives in its own file with its own test, named after the thing it does, and the next engineer can find every step of the checkout flow by opening one file instead of scrolling through a service that's accumulated nine cousins.
Use it when the operations on a domain start to feel only loosely related. Don't use it when there are two of them and they share state. That's when a service is still the right tool.

The Honest Closer
MVC isn't wrong. It just stops being enough.
The pattern was designed to give you a clean separation between what you display, what you store, and what handles the input that connects the two. For most applications, that's three of the layers you need. The fourth layer, the place where domain operations live, was never part of the original deal, and frameworks that ship with thin controllers and active-record models are quietly counting on you to invent that fourth layer the moment you outgrow CRUD.
The mistake isn't using MVC. The mistake is treating MVC as if it were complete, then watching the controller silently eat every concern that doesn't fit anywhere else.
If you're early, write the boring two-line controllers and don't apologize for it. If you're feeling the squeeze, the service layer is a small, mechanical change that pays back fast: extract one operation, give it a name, give it a test, leave the controller five lines long. Then do the next one. Six months later, the codebase is a different shape, and the new engineers you're onboarding can read it in an afternoon, which is the only architectural metric that actually matters.






