Understanding MVC Architecture
Every large, maintainable web application is built on a solid architectural foundation — and for decades that foundation has been the Model-View-Controller (MVC) pattern. Whether you're writing Laravel, Ruby on Rails, ASP.NET Core, Django, or Spring MVC, you are already working inside this pattern. But knowing the name and truly understanding what each layer is responsible for — and why those responsibilities must stay separate — are two very different things. This guide goes beyond the surface, unpacking MVC from first principles so you can apply it with confidence and recognize where violations cause real-world pain.
Misunderstanding MVC is one of the most common causes of "fat controllers", untestable code, and applications that become impossible to maintain after six months. Getting this right from the start saves enormous refactoring effort later.
The Origin of MVC
MVC was conceived by Trygve Reenskaug in 1979 while he was working on Smalltalk at Xerox PARC. The original motivation was clean: a graphical user interface needed a way to separate the data it displayed from the widgets used to display it, while keeping a mediating layer that translated user gestures into meaningful actions on data.
The insight was deceptively simple — data and presentation change for entirely different reasons. A database schema evolves when business rules change. A UI evolves when user-experience requirements change. Coupling them together means every UI revision risks breaking business logic, and every data schema change forces UI rewrites. Separation solves this.
When the web era arrived, the pattern was adapted for the request-response cycle. Instead of a persistent GUI, the "View" became an HTTP response; instead of continuous mouse events, the "Controller" processed a single incoming HTTP request. The adaptation worked so well that MVC became the dominant web framework pattern for the next 40 years.
The MVC request-response cycle in a web application
The Three Layers in Depth
Let's examine each component with the precision needed to apply it correctly, not just recognize the acronym.
The Model: Your Application's Brain
The Model is not just a database table wrapper. It is the authoritative representation of your application's domain — the rules, calculations, and state transitions that make your system meaningful. A well-designed Model layer has zero awareness of HTTP, HTML, or how data will eventually be presented.
The Model layer typically includes:
- Domain entities: Objects that represent the core concepts (User, Order, Product, Invoice)
- Business rules: Validation, state machines, calculations (e.g., an Order cannot be shipped if payment is pending)
- Data access: Queries, persistence logic (often via an ORM like Eloquent or ActiveRecord)
- Relationships: How entities relate to one another (a User has many Orders; an Order belongs to many Products)
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $fillable = ['user_id', 'status', 'total'];
const STATUS_PENDING = 'pending';
const STATUS_PAID = 'paid';
const STATUS_SHIPPED = 'shipped';
const STATUS_CANCELLED = 'cancelled';
// --- Relationships ---
public function user()
{
return $this->belongsTo(User::class);
}
public function items()
{
return $this->hasMany(OrderItem::class);
}
// --- Business Rules (belong in Model, NOT Controller) ---
public function canBeShipped(): bool
{
return $this->status === self::STATUS_PAID;
}
public function canBeCancelled(): bool
{
return in_array($this->status, [self::STATUS_PENDING, self::STATUS_PAID]);
}
public function markAsShipped(): void
{
if (! $this->canBeShipped()) {
throw new \DomainException("Order #{$this->id} cannot be shipped in status: {$this->status}");
}
$this->update(['status' => self::STATUS_SHIPPED]);
}
// --- Computed attribute (presentation hint, but still domain knowledge) ---
public function getFormattedTotalAttribute(): string
{
return '$' . number_format($this->total / 100, 2);
}
}
A widely accepted MVC best practice is to keep business logic inside Models (or dedicated service classes), not Controllers. If your controller is doing if/else business decision-making, that logic belongs in the Model instead. Controllers should only orchestrate — call the right model methods, then hand data to a view.
The View: Pure Presentation
The View's only job is to take data handed to it and present it — as HTML, JSON, XML, CSV, or any other format. A View should contain zero business logic. It should not calculate discounts, decide whether a user is authorized to see content, or query the database. It receives prepared data and renders it.
Signs your View layer is doing too much:
- Database queries inside templates (
User::find()in a Blade file) - Business calculations in templates (
if ($order->total * 0.1 > 50)) - Authorization decisions made inside templates rather than passed as booleans
@extends('layouts.app')
@section('content')
<div class="order-detail">
<h1>Order #{{ $order->id }}</h1>
<p class="status status--{{ $order->status }}">{{ ucfirst($order->status) }}</p>
<p>Total: {{ $order->formatted_total }}</p>
{{-- $canShip is a boolean passed from Controller, not decided here --}}
@if($canShip)
<form action="{{ route('orders.ship', $order) }}" method="POST">
@csrf
<button type="submit">Mark as Shipped</button>
</form>
@endif
<ul>
@foreach($order->items as $item)
<li>{{ $item->product->name }} × {{ $item->quantity }}</li>
@endforeach
</ul>
</div>
@endsection
The Controller: The Traffic Cop
The Controller receives an HTTP request, figures out what the application needs to do, delegates that work to Models (or service classes), and hands the result to a View. It is a coordinator, not an executor. The moment your controller contains business logic (price calculation, email formatting, complex conditional workflows), it has taken on responsibility that belongs elsewhere.
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function show(Order $order)
{
// Controller only orchestrates: prepare data for the view
return view('orders.show', [
'order' => $order,
'canShip' => $order->canBeShipped(), // Business rule lives in Model
]);
}
public function ship(Order $order)
{
// Delegate the actual work to the Model
$order->markAsShipped();
return redirect()
->route('orders.show', $order)
->with('success', 'Order has been marked as shipped.');
}
public function cancel(Order $order)
{
if (! $order->canBeCancelled()) {
abort(422, 'This order cannot be cancelled.');
}
$order->update(['status' => Order::STATUS_CANCELLED]);
return redirect()->route('orders.index')
->with('success', 'Order cancelled.');
}
}
MVC Request Flow: Step by Step
To make the cycle concrete, here is exactly what happens when a user visits https://example.com/orders/42:
HTTP Request Arrives
The web server (Nginx/Apache) receives a GET request for /orders/42 and forwards it to the PHP application's entry point (public/index.php).
Router Dispatches
The framework's router matches the URL and method against registered route definitions and resolves which Controller method should handle the request — OrderController@show with id=42.
Middleware Pipeline
Before reaching the controller, the request passes through middleware (authentication checks, CSRF verification, rate limiting, logging). If any middleware rejects the request, the controller is never invoked.
Controller Runs
The controller retrieves the Order model (via route model binding or manual query), calls any needed business methods on it, and prepares a data array for the view.
Model Queries the Database
The ORM translates model calls into SQL queries, executes them, and returns hydrated model objects or collections back to the controller.
View Renders the Response
The template engine (Blade, Twig, ERB) merges the data from the controller with the HTML template. The rendered HTML string is sent back as the HTTP response body.
MVC vs Other Patterns
MVC is not the only option, and understanding when alternatives shine helps you make better architectural decisions.
| Pattern | Key Difference from MVC | Best For |
|---|---|---|
| MVC | Classic server-side, Controller returns a View directly | Traditional server-rendered web apps (Laravel, Rails) |
| MVP (Model-View-Presenter) | Presenter mediates all interaction; View is fully passive | Desktop GUIs, Android apps, highly testable UIs |
| MVVM (Model-View-ViewModel) | ViewModel exposes observable state; View binds to it | React, Vue, Angular, WPF — reactive UI frameworks |
| ADR (Action-Domain-Responder) | One class per action; Responder separate from Domain | REST APIs where controllers become bloated |
| Hexagonal (Ports & Adapters) | Core domain isolated from all infrastructure via ports | Large enterprise systems, DDD-driven codebases |
Common MVC Anti-Patterns
Recognizing what violates MVC is as valuable as knowing what upholds it. These are the most frequent mistakes developers make:
Fat Controllers
A controller with hundreds of lines doing database queries, business calculations, email sending, file processing, and then returning a view. This is the single most common MVC violation. Every line of business logic in a controller is logic that cannot be independently tested and cannot be reused from another entry point (e.g., a CLI command or queue job).
// ❌ Fat Controller — business logic crammed in controller
public function checkout(Request $request)
{
$cart = Cart::find($request->cart_id);
$total = 0;
foreach ($cart->items as $item) {
$total += $item->price * $item->quantity;
}
if ($cart->user->hasPromoCode()) {
$total -= $total * 0.1; // 10% discount
}
if ($total > 10000) {
$shippingFee = 0;
} else {
$shippingFee = 500;
}
$order = Order::create([...]);
Mail::to($cart->user)->send(new OrderConfirmation($order));
return view('checkout.success', compact('order'));
}
// ✅ Thin Controller — delegates to a service/model
public function checkout(Request $request, CheckoutService $checkout)
{
$order = $checkout->processCart(
Cart::find($request->cart_id),
$request->validated()
);
return view('checkout.success', compact('order'));
}
Anemic Models
The opposite problem: Models that are nothing but plain data bags with getters and setters, with all the business logic living in service classes or controllers. This leads to a procedural style disguised as object-orientation and makes it impossible to reason about an entity's invariants from looking at the Model class.
Views That Query the Database
Placing User::where(...)->get() inside a Blade template defeats the purpose of the pattern entirely. It merges the presentation and data layers, making it impossible to cache responses or change data sources without touching template files.
Views that trigger lazy-loaded relationships inside loops cause N+1 query problems. Always eager-load relationships in the controller (e.g., Order::with('items.product')->find($id)) before passing data to the view, so the view only renders already-fetched data.
Extending MVC with Service and Repository Layers
For applications beyond moderate complexity, vanilla MVC with three layers starts showing seams. Two additional layers are widely used to address this:
The Service Layer
A Service class (sometimes called an Application Service or Use Case) encapsulates a single business operation that involves multiple models, external API calls, event dispatching, or queue scheduling. The controller delegates to the service; the service orchestrates models. This keeps controllers thin without moving complex logic into models that shouldn't know about infrastructure concerns.
<?php
namespace App\Services;
use App\Models\Cart;
use App\Models\Order;
use App\Mail\OrderConfirmation;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\DB;
class CheckoutService
{
public function processCart(Cart $cart, array $paymentData): Order
{
return DB::transaction(function () use ($cart, $paymentData) {
// 1. Calculate total (domain logic stays in models)
$total = $cart->calculateTotal();
// 2. Create the order
$order = Order::create([
'user_id' => $cart->user_id,
'total' => $total,
'status' => Order::STATUS_PENDING,
]);
// 3. Copy cart items to order items
foreach ($cart->items as $item) {
$order->items()->create([
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'price' => $item->price,
]);
}
// 4. Clear cart
$cart->clear();
// 5. Send confirmation email
Mail::to($cart->user)->send(new OrderConfirmation($order));
return $order;
});
}
}
The Repository Layer
A Repository sits between the Model and the database, providing a collection-like interface for persistence. Repositories are most valuable in large systems where: you want to swap the underlying data source (e.g., switch from MySQL to Elasticsearch for certain queries), or you want to unit-test data-access logic without hitting a real database.
A production-grade MVC stack with Service and Repository layers
MVC Across Different Frameworks
The pattern manifests differently across frameworks, but the underlying separation remains the same. Here is a quick reference:
Laravel is one of the most complete MVC frameworks. Routing in routes/web.php, Controllers in app/Http/Controllers/, Models in app/Models/, Views in resources/views/.
// Route
Route::get('/orders/{order}', [OrderController::class, 'show']);
// Controller
public function show(Order $order) {
return view('orders.show', ['order' => $order]);
}
// Model (Eloquent)
class Order extends Model {
public function items() { return $this->hasMany(OrderItem::class); }
}
// View (Blade)
// resources/views/orders/show.blade.php
Express.js doesn't enforce MVC, but the pattern is commonly applied by organizing code into routes, controllers, and models (Mongoose/Sequelize).
// routes/orders.js (Router)
router.get('/:id', orderController.show);
// controllers/orderController.js (Controller)
exports.show = async (req, res) => {
const order = await Order.findById(req.params.id)
.populate('items');
res.render('orders/show', { order });
};
// models/Order.js (Model)
const orderSchema = new mongoose.Schema({
userId: { type: ObjectId, ref: 'User' },
status: String,
total: Number,
});
module.exports = mongoose.model('Order', orderSchema);
Django calls itself MTV (Model-Template-View) but it maps directly to MVC: Model = Model, Template = View, View = Controller.
# models.py (Model)
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
status = models.CharField(max_length=20)
total = models.IntegerField()
# views.py (Controller in MVC terms)
def order_detail(request, pk):
order = get_object_or_404(Order, pk=pk)
return render(request, 'orders/show.html', {'order': order})
# urls.py (Router)
path('orders/<int:pk>/', views.order_detail, name='order-detail'),
# templates/orders/show.html (View in MVC terms)
<h1>Order #{{ order.id }}</h1>
Ruby on Rails is the archetypal MVC web framework. Convention over configuration means the file structure IS the MVC structure.
# config/routes.rb (Router)
resources :orders, only: [:show, :update]
# app/controllers/orders_controller.rb (Controller)
class OrdersController < ApplicationController
def show
@order = Order.find(params[:id])
end
end
# app/models/order.rb (Model)
class Order < ApplicationRecord
belongs_to :user
has_many :order_items
def can_be_shipped?
status == 'paid'
end
end
# app/views/orders/show.html.erb (View)
<h1>Order #<%= @order.id %></h1>
Testing in an MVC Application
One of the hidden payoffs of properly separated MVC is dramatically improved testability. Each layer can be tested in isolation:
| Layer | Test Type | What to Assert | Needs DB? |
|---|---|---|---|
| Model | Unit Test | Business rules, state transitions, calculations | Yes (or mocked) |
| Controller | Feature / Integration Test | HTTP status codes, redirects, view data, session | Usually yes |
| View | Browser / E2E Test | Rendered HTML, UI interactions | Yes (seeded) |
| Service | Unit Test | Correct method calls, return values, exceptions | No (mock models) |
<?php
namespace Tests\Unit;
use App\Models\Order;
use PHPUnit\Framework\TestCase;
class OrderTest extends TestCase
{
public function test_paid_order_can_be_shipped(): void
{
$order = new Order(['status' => Order::STATUS_PAID]);
$this->assertTrue($order->canBeShipped());
}
public function test_pending_order_cannot_be_shipped(): void
{
$order = new Order(['status' => Order::STATUS_PENDING]);
$this->assertFalse($order->canBeShipped());
}
public function test_shipping_a_non_paid_order_throws_exception(): void
{
$this->expectException(\DomainException::class);
$order = new Order(['status' => Order::STATUS_PENDING, 'id' => 1]);
$order->markAsShipped();
}
}
MVC Best Practices Checklist
- Controllers: No business logic. No database queries beyond simple model lookups. No email sending. Just orchestrate and pass data to a view.
- Models: Encode domain rules as methods, not just data properties. Validate state transitions. Use relationships to express domain connections.
- Views: No database queries. No business calculations. Receive prepared, pre-calculated data from the controller.
- Services: Extract complex multi-step operations that span multiple models or require external calls.
- Repositories: Abstract data access if you have complex query logic or need to test without a real DB.
- Routing: Keep route files clean — group by feature, apply shared middleware via groups.
- Testing: Unit-test model business rules. Feature-test controller endpoints. Avoid testing the same logic at multiple layers.
Conclusion: MVC as a Thinking Tool
MVC is much more than a folder structure imposed by a framework. It is a principle: separate what changes for different reasons. Data rules and presentation rules evolve on completely different timescales driven by completely different teams — business analysts versus UI designers. Keeping them in separate layers means changes stay local, tests stay fast, and the codebase stays navigable as it grows.
Key Takeaways
- Model = your domain knowledge. If it is a business rule, it belongs here.
- View = pure presentation. Data comes in, HTML (or JSON) goes out. No logic.
- Controller = traffic cop. Receives request, calls the right model methods, hands data to a view.
- Fat controllers are the #1 MVC anti-pattern. Business logic in a controller cannot be reused or tested independently.
- Services and Repositories are natural extensions of MVC for larger applications — they don't contradict MVC, they refine it.
- Proper separation pays dividends at testing time: each layer is independently testable.
"Good architecture is not about being clever. It is about making the easy things obvious and the hard things possible."
— Adapted from Martin Fowler
The frameworks do a lot of heavy lifting, but the discipline of keeping concerns separated is yours. Read your controllers — if they feel like they know too much, extract that knowledge into models and services. Your future self, debugging a production issue at 2 AM, will thank you.
