Lesson 15 of 17 · Living With Code You Didn't Write
Model-View-Controller - The Baseline We All Know
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.
userstable,User::find(1),$user->email.The persistence object. Eloquent
Userclass, 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
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
ifstatements. 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:
{{-- 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>
@endifThat @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
Userhas 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:
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
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.
For each business rule on that list, find where else the same rule appears: on the model, in a template, in another controller.
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".
Related Links
- MVC technical notesTrygve Reenskaug's original 1979 notes from Xerox PARC.
- What is Symfony2?Fabien Potencier on why he calls Symfony a Request/Response framework rather than an MVC one.
- GUI ArchitecturesMartin Fowler on how MVC gets renamed and misunderstood across UI frameworks.
- "Fat model, skinny controller" is a load of rubbishJon Cairns on why pushing logic into the model just relocates the mess.
- The Art Of Keeping Business Logic HonestKeeping business rules explicit and out of controllers, models, and middleware.