MVC / MVVM
Model-View-Controller separates data, presentation, and orchestration. Models own state and rules; views render; controllers route requests and orchestrate. Rails canonised it; Spring, ASP.NET, Laravel, and Django all run with it. Done well, you can swap views or models in isolation; done poorly, the controller becomes a god-class.
A worked MVC slice
EXAMPLE
<?php
// ============================================================
// Model — owns persistence + business rules
// ============================================================
final class Order {
public function __construct(
public string $id,
public string $customer,
public int $totalCents,
public string $status = 'new',
public ?DateTimeImmutable $paidAt = null,
) {}
// Business rule lives in the model, not the controller
public function pay(DateTimeImmutable $at): void {
if ($this->status !== 'new') {
throw new DomainException('cannot pay from ' . $this->status);
}
$this->status = 'paid';
$this->paidAt = $at;
}
public function totalAud(): float {
return $this->totalCents / 100;
}
}
interface OrderRepository {
public function findById(string $id): ?Order;
public function save(Order $o): void;
}
// ============================================================
// Controller — orchestrates the request, returns a view
// ============================================================
final class OrdersController {
public function __construct(
private OrderRepository $repo,
private Clock $clock,
) {}
// GET /orders/{id}
public function show(Request $req): Response {
$order = $this->repo->findById($req->route('id'));
if (!$order) return Response::notFound();
return view('orders.show', ['order' => $order]);
}
// POST /orders/{id}/pay
public function pay(Request $req): Response {
$order = $this->repo->findById($req->route('id'));
if (!$order) return Response::notFound();
try {
$order->pay($this->clock->now());
$this->repo->save($order);
} catch (DomainException $e) {
return Response::back()->withError($e->getMessage());
}
return Response::redirect('/orders/' . $order->id)->with('flash', 'paid');
}
}
// ============================================================
// View — rendering only, no business logic
// ============================================================
// resources/views/orders/show.blade.php
?>
<h1>Order {{ $order->id }}</h1>
<dl>
<dt>Customer</dt><dd>{{ $order->customer }}</dd>
<dt>Total</dt> <dd>{{ number_format($order->totalAud(), 2) }} AUD</dd>
<dt>Status</dt> <dd>{{ $order->status }}</dd>
</dl>
@if ($order->status === 'new')
<form method='POST' action='/orders/{{ $order->id }}/pay'>
@csrf
<button>Mark paid</button>
</form>
@endif
<?php
// ============================================================
// Anti-patterns to avoid
// ============================================================
// 1) Business logic in the view
// if ($order->totalCents > 50000 && $user->tier === 'gold') ...
// -> push into the model or a domain service
// 2) Business logic in the controller
// if ($order->status === 'paid') { $invoice->status = 'void'; ... }
// -> push into the model ($order->cancel()) or a service object
// 3) Active Record god model
// Order class with 200 methods including sending email and posting to Slack
// -> extract a service (PayOrder, ShipOrder) when responsibilities grow
// 4) Skinny model, fat controller
// Controller with 200 lines of orchestration logic
// -> push behaviour back into the model OR a service object
// ============================================================
// MVC vs the cousins
// ============================================================
// MVP (Model-View-Presenter): the View is dumb; the Presenter handles UI logic
// MVVM (Model-View-ViewModel): the ViewModel exposes observable state; popular
// in WPF, Vue (computed/refs), SwiftUI (Observable)
// Hexagonal / Clean: MVC + explicit ports for storage, queues, services
//
// Pick MVC for server-rendered web apps; MVVM for desktop / declarative UI;
// Clean Architecture when the team is large enough to enforce the layering.
interface Clock { public function now(): DateTimeImmutable; }
class Request {
public function route(string $k): string { return 'o1'; }
public function user(): mixed { return null; }
}
class Response {
public static function notFound(): self { return new self(); }
public static function redirect(string $u): self { return new self(); }
public static function back(): self { return new self(); }
public function withError(string $m): self { return $this; }
public function with(string $k, mixed $v): self { return $this; }
}
function view(string $name, array $data): Response { return new Response(); }
Why it matters
When a controller grows past about 10 lines per action, pull the work out into a model method or a service object. Skinny controllers + meaningful models + thin views is the shape that scales: tests stay focused, code review stays cheap, and refactors stay local.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Model — state + rules. // View — display + input. // Controller — orchestrates: turns input into model updates, picks the next view. // MVVM swaps Controller for a ViewModel that the view binds to.Try it Yourself »
Discussion
Loading…