Skip to content

Lesson 15 of 17 · Living With Code You Didn't Write

Model-View-Controller - The Baseline We All Know

Beginner•
lesson•
October 5, 2026•
~10 min read

The pattern almost every PHP developer has met

If you've written PHP in a framework, you've written MVC, whether or not anyone called it that out loud. Laravel, CodeIgniter, CakePHP, Yii and Phalcon ship with the same starter shape: a folder for controllers, one for models, and one for views or templates. Symfony ships controllers and templates and leaves the model to you; Fabien Potencier, who created it, calls it "a Request/Response framework" rather than an MVC one. Tutorials and new projects start with that shape.

That's the part of MVC most of us have actually used. It isn't quite what MVC was supposed to be, and the gap between the two is a big part of why long-lived MVC codebases age the way they do. Web MVC is also the shape you'll most likely find when you inherit a PHP codebase, so it helps to know where it puts things before Lesson 16 shows you how to read one.

Where MVC came from

MVC was invented by Trygve Reenskaug at Xerox PARC, in two technical notes written in 1979. The version most people inherited, the one that shipped in the Smalltalk-80 class library, was reworked by Jim Althoff after Reenskaug had left.

In the Smalltalk-80 version, the three roles were:

  • Model. The thing being represented. Business state and behavior. Independent of the screen.

  • View. A presentation of the model. Subscribes to the model and updates itself when the model changes.

  • Controller. Translates user input (mouse, keyboard) into operations on the model.

The whole point was that the model didn't know about views or controllers. This is a desktop GUI pattern. It assumes a long-running application, with multiple views looking at the same in-memory state, talking to each other through the model. None of those assumptions are true on the web.

How MVC landed in PHP frameworks

When the web rediscovered MVC in the 2000s, the pattern got reinterpreted to fit a request-response world:

  • Model became "the database table and the things you do with it". Often an ActiveRecord class, like Laravel's Eloquent or CodeIgniter's models, where the model is mostly a wrapper around a row.

  • View became "the template you render at the end of a request". A Blade file, a Twig file, a plain PHP template. No live subscription to the model. The view is generated once per request and shipped to the browser.

  • Controller became "the function that runs for one route". It receives a request, calls some models, picks a view, hands the view some data, returns a response.

It works, but it's a different pattern with the same name. The original MVC was a long-lived UI pattern. Web MVC is a request-response routing pattern with three folders.

Naming the rebrand matters because an argument about "MVC is dead" versus "MVC is fine" is often two people describing two different patterns.

What "Model" usually means in practice

The biggest source of confusion in web MVC is the word "Model". In a typical Laravel codebase, "the model" means three different things, and the team uses the word interchangeably:

  • The database row. users table, User::find(1), $user->email.

  • The persistence object. Eloquent User class, with relationships, scopes, casts, and accessors.

  • The business concept. The idea of a user in your domain, with rules about what they can and can't do.

Small applications can pretend these are the same thing and get away with it. Long-lived products, the contexts from Lesson 2, can't. The User model accumulates billing logic, auth logic, export logic, marketing logic, audit logic, every piece of business that happens to involve a user, until it's the file from Lesson 5: low cohesion, high coupling, the most expensive class in the codebase to change.

Here is the start of that drift on the Reservation model of a hotel reservation app, with all three meanings in one class:

PHP
<?php declare(strict_types=1); namespace App\Models; use DateTimeImmutable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; final class Reservation extends Model { // Persistence: how the row maps to PHP. protected function casts(): array { return [ 'check_in' => 'immutable_date', 'check_out' => 'immutable_date', 'non_refundable' => 'boolean', 'marketing_opt_in' => 'boolean', ]; } public function guest(): BelongsTo { return $this->belongsTo(Guest::class); } public function scopeUpcoming(Builder $query): Builder { return $query->where('check_in', '>=', now()->toDateString()); } // Business rules: what the hotel allows. public function canBeCancelled(DateTimeImmutable $now): bool { if ($this->non_refundable) { return false; } return $now <= $this->check_in->modify('-48 hours'); } public function totalCents(): int { $nights = $this->check_in->diff($this->check_out)->days; $totalCents = $this->nightly_rate_cents * $nights; if ($this->guest->is_loyalty_member) { return (int) round($totalCents * 0.9); } return $totalCents; } // Finance and marketing: code that only happens to involve a reservation. /** * @return list<int|string> */ public function toExportRow(): array { return [$this->id, $this->guest->email, $this->check_in->format('Y-m-d'), $this->totalCents()]; } public function shouldReceiveUpsellEmail(DateTimeImmutable $now): bool { return $this->marketing_opt_in && $now < $this->check_in && $now->diff($this->check_in)->days <= 7; } }

The casts, the relationship and the scope are the persistence object. canBeCancelled() and totalCents() are the business concept: the cancellation policy, and the same pricing rule Lesson 14 pulled out into StayPrice. toExportRow() belongs to finance's CSV, and shouldReceiveUpsellEmail() belongs to marketing. Ask Lesson 5's question, do these things change for the same reasons, and the answer is no: the cancellation policy, the finance export and the upsell window each change for a different team, and every one of those changes opens this file.

The framework didn't force this; it made it the easiest path. There was no separate place for "the business concept of a user", so it ended up on the persistence class, where the team could find it.

What "Controller" usually becomes

The controller in web MVC is supposed to be thin. Receive request, call something, return response. In practice, controllers grow.

The reservation controller from Lesson 6 is a stylized version of what happens to controllers in a long-lived MVC codebase. In real projects, it's often named something like BookingController: database writes, price calculation, payment, and email confirmation, all in one method that grows with every feature. (The name BookingController itself is part of the problem: it picks a word the business doesn't use - we say Reservation - and stamps a framework role onto it. The Architectures course drains this controller one reservation operation at a time.)

Two reasons this happens:

  • Where else would you put it? The framework gives you Controllers, Models, and Views. The model looks like a database table and the view is HTML, so whatever logic doesn't already sit on the model goes in the controller, by elimination.

  • Nobody told you not to. MVC tutorials show short controllers with no business rules. New developers learn that controllers are where the action goes, and they generalize from there.

What "View" usually becomes

Views are usually the part of MVC that ages best in the web version. A Blade file rendering some data is a Blade file rendering some data. It doesn't accumulate cross-cutting business logic the way models and controllers do.

The places views go wrong are smaller and more local:

  • Templates that contain business logic in if statements. The rule has just escaped the model into the template.

  • Partials that fetch their own data, bypassing the controller. Now the template has its own opinions about what queries to run.

  • View helpers that turn into business helpers, slowly, without anyone noticing.

The first one looks like this on the reservation page:

BLADE
{{-- resources/views/reservations/show.blade.php --}} @if (! $reservation->non_refundable && now()->lte($reservation->check_in->subHours(48))) <form method="POST" action="{{ route('reservations.cancel', $reservation) }}"> @csrf <button type="submit">Cancel for free</button> </form> @endif

That @if is canBeCancelled() written a second time. When the policy moves to 72 hours, someone updates the model, the template still says 48, and the page offers a free cancellation that the model then refuses.

These are paper cuts until there are hundreds of them and nobody can change a rule without finding all the places it leaked into.

What MVC is actually good at

  • It's familiar. Almost every developer who has touched PHP knows the layout. New hires can navigate the folder structure on their first day.

  • It's enough for small things. Brand sites, marketing pages, simple admin tools, internal CRUDs. The three folders are roughly the three concerns, and the project never gets big enough to feel the cost.

  • Frameworks are highly optimized for it. Routing, request handling, validation, authentication, view rendering: the whole framework is built around making controller-to-view-via-model work fast.

  • It's a good starting point. A product that outgrows MVC can evolve toward something else when it needs to. Over-engineering up front (Lesson 7) is a worse trap than starting with MVC and migrating later.

Where MVC stops being enough

MVC starts feeling thin in the situations from Lesson 2: products built to last, domains that are genuinely complex, teams big enough to collide, and a change rate that doesn't quit. Specifically:

  • When models become responsibility magnets. Once User has thirty methods spanning five different concerns, the M in MVC is doing the work of three layers. Lesson 11 (business processes) and Lesson 12 (intent naming) both have something to say about this.

  • When controllers become orchestration. Once a controller is doing validation, business logic, persistence, side effects, and response shaping in one method, you don't have a controller anymore. You have an application layer (the code that runs one use case from start to finish), badly named.

  • When the domain has rules MVC has no place for. "A reservation can only be confirmed when the guest has a valid payment method, the room is free for every night of the stay, and the discount code applies to this hotel." That rule has nowhere natural to live in MVC. It ends up in a controller, in a service class somebody invented to escape the controller, or split across three files in a way that makes nobody happy.

  • When the team grows past the size MVC quietly assumes. Three folders, no module boundaries, no use case names. Every file is a candidate target for every change. The cost-of-change curve from Lesson 3 climbs steeply.

You don't have to abandon MVC to fix any of that. The framework's MVC scaffolding still routes the request and renders the response. What changes is what happens between the controller and the database. That space, which MVC leaves blank, is where the rest of the series' ideas live.

A grown-up MVC, in practice

The pattern I've seen long-lived PHP codebases land on is MVC at the edges, with a real application and domain layer behind it.

Roughly:

PHP
Request -> Controller (thin: reads the request, hands it off) -> Application layer (one use case, e.g. a command handler) -> Domain classes (the business concepts and their rules) -> Persistence interface (e.g. Reservations: load and save) -> Eloquent model (database access only) <- result <- Controller shapes the response (JSON, or a rendered view)

The controller is still a controller. The view is still a view. The "model" has been split into two: a domain class that holds the business meaning, and a persistence class (Eloquent) that handles the database. The work that used to pile up in the controller has been pushed inward, into named use cases.

Each architecture the Architectures course covers is a version of this. Vertical slices put each use case in its own folder. Hexagonal calls the persistence interface a port and keeps the framework outside it. Clean Architecture draws the same layers as rings. The underlying move is the same: give business logic a place that isn't the controller and isn't the database. The lesson "The Request Lifecycle Through the Layers" in the Architectures course walks one reservation request through this path in code.

The one thing to remember

MVC is the baseline. The three folders that frameworks ship with are a starting point for separating concerns; they aren't a complete architecture.

When someone says "we're using MVC", they're describing the framework's defaults. When someone says "we've outgrown MVC", they usually mean "we still use MVC at the edges, but the interesting work happens behind it now". Both can be true at the same time.

Exercise

  1. Open the biggest controller in a codebase you work in. List what it does, one line per job: validation, business rules, saving data, side effects, shaping the response.

  2. For each business rule on that list, find where else the same rule appears: on the model, in a template, in another controller.

  3. Pick one rule and name the use case it belongs to, using the business's word for it instead of a framework role like "controller".

Course Content

Introduction - Why Most Codebases Rot
Lesson
It All Depends on the Context You Are In
Lesson
The Cost of Change Curve
Lesson
Reading Code vs Writing Code
Lesson
Coupling and Cohesion
Lesson
Accidental vs Essential Complexity
Lesson
Under-Engineering vs Over-Engineering
Lesson
Premature Abstraction
Lesson
Technical Debt and the Boy Scout Rule
Lesson
Framework Coupling vs Framework Decoupling
Lesson
Thinking Data vs Thinking Business Processes
Lesson
Naming Things by Intent
Lesson
Planning Before You Code
Lesson
Strategic vs Tactical Programming
Lesson
Model-View-Controller - The Baseline We All Know
Lesson
Sign in to track your progress
Reading a Codebase You Didn't Write
Lesson
Recording the Why
Lesson