Architecture

Understanding MVC Architecture

Mayur Dabhi
Mayur Dabhi
April 20, 2026
14 min read

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.

Why This Matters

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.

Browser HTTP Request Router Dispatches Controller Orchestrates the request Model Business logic Data access View HTML / JSON Template rendering DB SQL route calls queries renders HTTP Response (HTML / JSON)

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:

app/Models/Order.php — Model with business logic
<?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);
    }
}
Fat Model, Thin Controller

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:

resources/views/orders/show.blade.php — Clean View
@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.

app/Http/Controllers/OrderController.php — Thin Controller
<?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:

1

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).

2

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.

3

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.

4

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.

5

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.

6

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).

Anti-pattern vs Correct approach
// ❌ 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.

The N+1 Problem Warning

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.

app/Services/CheckoutService.php
<?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.

Request HTTP Controller Thin Service Use Cases Model Domain Repository Data access DB View Extended MVC with Service + Repository Layers

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)
tests/Unit/OrderTest.php — Model unit test
<?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.

MVC Architecture Patterns Design Patterns Web Development Software Architecture
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.