MVC: Model, View, Controller
Understand the three responsibilities of the Model-View-Controller pattern in PHP applications, how they collaborate on a request, and the common anti-patterns that break the pattern down (fat controllers, anemic models, views with business logic).
MVC (Model-View-Controller) is the pattern most PHP frameworks (Laravel, Symfony, CodeIgniter) are built around, and it is also the pattern most PHP codebases quietly violate. The idea is simple — separate data and business rules (Model) from what the user sees (View) from the traffic-cop that connects a request to the two (Controller) — but knowing the idea is not the same as applying it. Most "MVC" PHP code you will encounter in the wild is actually a Controller that does everything, a Model that is just a database row, and a View sprinkled with if statements and SQL. This lesson shows the pattern done correctly, then names the anti-patterns so you can recognize them in real code.
The three responsibilities
MVC splits an application into three collaborating parts, each with exactly one job. The Model owns data and business rules — it knows how to validate an order, calculate a total, or persist a user, and it knows nothing about HTTP or HTML. The View owns presentation — it knows how to turn data it is handed into HTML, and it knows nothing about how that data was fetched or validated. The Controller owns coordination — it reads the incoming request, asks the Model to do work, and hands the Model's result to a View to render.
The key discipline is that each part stays ignorant of the others' internals. A Model should be usable from a CLI script, a test, or a web request without modification, because it never touches $_GET, $_POST, or echo. A View should be swappable (HTML today, JSON tomorrow) without touching business logic. A Controller should be thin: it translates HTTP into method calls and translates the result into a response — nothing more.
• Model — data, validation, business rules, persistence logic; no knowledge of HTTP or rendering • View — presentation only; receives already-prepared data and renders it, no queries or business decisions • Controller — reads the request, delegates to the Model, selects and hands data to a View; stays thin
A request walked through MVC
Consider a request to view a single product page. The router matches the URL to a Controller action. The Controller extracts the product id from the request and asks a Model (or a repository the Model layer exposes) to fetch that product. The Model returns a plain data structure or domain object — it does not know or care that this data is headed for a web page. The Controller then passes that object to a View, and the View's only job is to turn it into HTML.
Nothing in this flow requires the Model to know about $_GET, and nothing requires the View to know how the product was fetched — that separation is the entire point. When any of the three starts reaching into another's job (a View running a query, a Model reading $_POST, a Controller building HTML by hand), the architecture has broken down even if the folders are still named Models, Views, and Controllers.
Anti-pattern: the fat controller
The most common MVC failure in PHP is the "fat controller" — a controller action that contains validation, SQL queries, business calculations, email sending, and response formatting all in one method, often 100+ lines long. It happens gradually: each new requirement gets added directly to the controller because that is the file that is already open. The result is logic that cannot be reused (you cannot call this behavior from a queue job or a console command without duplicating it) and cannot be tested without booting an HTTP request.
The fix is always the same: move anything that is not "read the request / call something / build the response" out of the controller and into a Model, service, or use case that the controller merely calls.
Anti-pattern: the anemic model
An anemic domain model is a Model class that is really just a bag of public properties or getters/setters with no real behavior — it holds data but none of the rules about that data. When a Model is anemic, all of the actual business logic ("an order cannot be shipped until it is paid", "a discount cannot exceed 50%") ends up living in controllers or, worse, scattered across multiple controllers that each reimplement the rule slightly differently.
A healthy Model has behavior, not just data: methods like $order->markAsPaid() or $order->canBeShipped() that enforce the rule in one place. If you can only ever get and set properties on your "Model" and every decision about that data lives elsewhere, it is not really a Model in the MVC sense — it is a data structure wearing a Model's name.
Anti-pattern: business logic in the view
The third common failure is a View template that does more than render — it runs queries, applies discount math, or makes authorization decisions with if statements buried in HTML. This happens because templates make it easy: the data is "right there" so it is tempting to just compute one more thing inline. The cost is that the same logic silently duplicates across every template that needs it, and it becomes untestable because it only runs inside a rendering pass.
A View should receive fully-prepared data from the Controller (or a view model) and limit its own logic to display concerns — loops for lists, conditionals for showing/hiding a prepared flag — never business rules or data access.
A minimal MVC shape in plain PHP
The Model (Product) has behavior (formattedPrice), not just data. The Controller has no SQL and no HTML — it only wires the Model to the View. The View receives an already-built Product and never queries anything itself.
// Model — data + behavior, no HTTP knowledge
class Product
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly float $priceCents
) {}
public function formattedPrice(): string
{
return number_format($this->priceCents / 100, 2);
}
}
// Controller — coordinates, stays thin
class ProductController
{
public function __construct(private ProductRepository $products) {}
public function show(int $id): string
{
$product = $this->products->find($id);
return (new ProductView())->render($product);
}
}
// View — presentation only
class ProductView
{
public function render(Product $product): string
{
return "<h1>{$product->name}</h1><p>\${$product->formattedPrice()}</p>";
}
}Code Examples
The Order Model owns the paid/shippable rule, the Controller only orchestrates (find, act, save, render), and the View only turns the result into text — each class can be tested and reused on its own. Expected result: Order #1 is now ready to ship.
$order->canBeShipped() puts the business rule inside the Model, so no Controller or View has to reimplement it.
<?php
// --- Model: business rules live here ---
final class Order
{
private bool $paid = false;
public function __construct(
public readonly int $id,
public readonly float $totalCents
) {}
public function markAsPaid(): void
{
$this->paid = true;
}
public function canBeShipped(): bool
{
// The rule lives in the Model, not in a controller or a view.
return $this->paid;
}
}
interface OrderRepository
{
public function find(int $id): Order;
public function save(Order $order): void;
}
// --- Controller: thin coordination only ---
final class OrderController
{
public function __construct(private OrderRepository $orders) {}
public function pay(int $orderId): string
{
$order = $this->orders->find($orderId);
$order->markAsPaid();
$this->orders->save($order);
return (new OrderView())->renderPaid($order);
}
}
// --- View: presentation only ---
final class OrderView
{
public function renderPaid(Order $order): string
{
$status = $order->canBeShipped() ? 'ready to ship' : 'awaiting payment';
return "Order #{$order->id} is now {$statu
…MVC request flow
The Controller never renders HTML itself and the Model never touches the request — the Controller is the only part that talks to both.
Request
│
▼
┌────────────┐ calls ┌───────────┐
│ Controller │ ───────────────▶ │ Model │
│ (thin) │ ◀─────────────── │ (rules, │
└─────┬──────┘ returns data │ data) │
│ └───────────┘
│ hands data to
▼
┌────────────┐
│ View │ ──▶ HTML/JSON response
│ (render) │
└────────────┘Important Rules
The Model contains business rules and data; it must never reference $_GET, $_POST, or the session directly.
The Controller stays thin: read request, call Model/service, choose a View — no SQL, no HTML building, no business math inline.
The View renders already-prepared data; it never queries a database or enforces a business rule.
If logic must be reused outside a web request (CLI, queue, test), it belongs in the Model layer, never in the Controller.
Why frameworks enforce this
Laravel, Symfony, and every serious PHP framework structure folders around Models, Views (Blade/Twig templates), and Controllers precisely so teams have a shared place to put each kind of code. The folder structure alone does not prevent fat controllers or anemic models — that discipline is on the developer — but it makes violations easier to spot in code review: a 200-line controller action or a Model with only getters is an immediate signal something has drifted from the pattern.
class ProductController { public function show($id) { $pdo = new PDO(...); $row = $pdo->query("SELECT * FROM products WHERE id=$id")->fetch(); echo "<h1>{$row['name']}</h1>"; } } class ProductController { public function show($id) { $product = $this->products->find($id); return $this->view->render($product); } }
The wrong version is a fat controller: it opens its own database connection, runs raw SQL (vulnerable to injection), and builds HTML by hand — none of which is a controller's job and none of which can be reused or tested in isolation.
Which of the following best describes an anemic domain model? A. A Model class that only stores data (getters/setters) with no business behavior, forcing rules to live elsewhere B. A Model class with too many methods C. A View that has no data to render D. A Controller with a single action
A. A Model class that only stores data (getters/setters) with no business behavior, forcing rules to live elsewhere — An anemic model holds data without behavior, so business rules end up duplicated in controllers or views instead of living in the Model where they belong.
In a correct MVC design, where should the rule 'an order can only ship once it is paid' live? A. In the View template, as an if-statement before rendering the ship button B. In the Controller, checked right before calling the shipping service C. In the Model (e.g., an Order::canBeShipped() method), so every caller shares the same rule D. In the router configuration
C. In the Model (e.g., an Order::canBeShipped() method), so every caller shares the same rule — Business rules belong in the Model so they are enforced consistently everywhere the rule is needed, not reimplemented separately in each Controller or View that touches an order.
Take a controller action that fetches a user from the database, checks if their subscription is still active by comparing dates inline, and echoes HTML directly. Refactor it into a proper Model (with an isActive(): bool method), a thin Controller, and a separate View class.
Move the date comparison into a User::isActive(): bool method on the Model. The Controller becomes: $user = $this->users->find($id); return $this->view->render($user);. The View class takes the User and builds the HTML, calling $user->isActive() to decide what to show.
Ask: which of these three things is business logic (the date comparison), which is orchestration (fetch, then render), and which is presentation (the HTML string)?