Lesson 19 • Advanced
Building a Micro MVC Framework 🏛️
By the end of this lesson you'll have built a tiny MVC app from scratch — a front controller, a router, a controller, a PDO-backed model, and a view — and you'll understand exactly what Laravel and Symfony are doing under the hood.
What You'll Learn in This Lesson
- Explain the Model / View / Controller split and why it matters
- Write a front controller plus a simple router from the request URL
- Build a Controller that dispatches to a View
- Query a database safely from a Model using PDO prepared statements
- Autoload classes with spl_autoload_register and Composer PSR-4
- See how Laravel and Symfony build on this exact pattern
php file.php. The Output panel under each example shows exactly what to expect.1️⃣ The Problem: Everything in One File
Before you build the pattern, feel the pain it removes. A beginner PHP page often does everything in one file: it reads the URL, opens a database, runs a query, and prints HTML — all tangled together. It works for one page, but the moment the app grows, every change risks breaking something unrelated. The job of MVC (Model-View-Controller) is to split that one file into three parts, each with a single responsibility.
<?php
// The "spaghetti" way: one file does EVERYTHING.
// Routing, the database query, and the HTML are all tangled together.
$id = $_GET["id"] ?? 1; // read input from the URL
$pdo = new PDO("sqlite::memory:"); // open a database connection
$row = $pdo->query("SELECT 'Alice' AS name")->fetch(); // query
// ...and straight away we print HTML, mixed in with the logic:
echo "<h1>" . $row["name"] . "</h1>"; // output
echo "<p>User #{$id}</p>";
// It works — but logic, data, and HTML are welded together.
// Change the database? You risk breaking the HTML. Re-skin the page?
// You risk breaking the query. That is exactly what MVC untangles.<h1>Alice</h1>
<p>User #1</p>Notice the trap: the SQL, the input handling, and the echo of HTML are all in the same breath. Change the database and you might break the markup; restyle the page and you might break the query. The next three sections pull these apart into a Model, a View, and a Controller.
2️⃣ The Model — Data & Rules (PDO)
The Model owns your data and your business rules. It's the only layer that talks to the database, and it does so through PDO — PHP's built-in database toolkit. Crucially, a model returns plain PHP arrays or objects; it never echoes HTML. Notice the prepared statement below: the value goes in through a :id placeholder, never glued into the SQL string. That single habit is what stops SQL-injection attacks.
<?php
// MODEL — owns the data and the rules. It talks to the database
// via PDO and returns plain PHP arrays. It NEVER echoes HTML.
final class UserModel
{
public function __construct(private PDO $db) {}
// Build a tiny in-memory table so the example runs anywhere.
public function migrate(): void
{
$this->db->exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)");
$this->db->exec("INSERT INTO users (name, email) VALUES
('Alice', 'alice@example.com'),
('Bob', 'bob@example.com')");
}
// Return ONE user, or null if the id is unknown.
public function find(int $id): ?array
{
// A prepared statement keeps user input OUT of the SQL string,
// which is how you stop SQL-injection attacks.
$stmt = $this->db->prepare("SELECT id, name, email FROM users WHERE id = :id");
$stmt->execute([":id" => $id]); // bind :id safely
$user = $stmt->fetch(PDO::FETCH_ASSOC); // one row as an array
return $user ?: null; // false -> null
}
public function all(): array
{
return $this->db->query("SELECT id, name FROM users")->fetchAll(PDO::FETCH_ASSOC);
}
}
$db = new PDO("sqlite::memory:");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // throw on errors
$model = new UserModel($db);
$model->migrate();
$alice = $model->find(1);
echo $alice["name"] . " <" . $alice["email"] . ">\n"; // Alice <alice@example.com>
var_dump($model->find(99)); // NULL — no such userAlice <alice@example.com>
NULLThe model knows nothing about web pages. You could call find() from a web request, a command-line script, or a test — and it behaves identically. That reusability is the whole point of keeping data access in its own layer.
3️⃣ The View — Presentation Only
The View does exactly one thing: turn data into HTML. It receives data the controller has already prepared and renders it — no queries, no business rules, no decisions. The pattern below uses output buffering (ob_start / ob_get_clean) to capture a template's output as a string, and htmlspecialchars() to escape values so a malicious name can't inject markup. Escaping on output is the view's one security responsibility.
<?php
// VIEW — turns data into HTML and NOTHING else. No queries, no rules.
// A view receives data the controller already prepared, and renders it.
final class View
{
public function __construct(private string $template) {}
// $data becomes local variables inside the template file.
public function render(array $data = []): string
{
extract($data); // ["name" => "Alice"] -> $name
ob_start(); // start capturing output
require $this->template; // run the template; its echoes are captured
return ob_get_clean(); // return the captured HTML as a string
}
}
// Normally this is a separate file: app/Views/user.php
// htmlspecialchars() escapes the data so a name like <script> can't
// inject markup — the View's one security job is to escape on output.
file_put_contents("user.php", '<?php ?>'
. '<h1>' . 'Hello, ' . '<?= htmlspecialchars($name) ?>' . '</h1>'
. '<p>Email: <?= htmlspecialchars($email) ?></p>');
$view = new View("user.php");
echo $view->render(["name" => "Alice", "email" => "alice@example.com"]);
echo "\n";<h1>Hello, Alice</h1>
<p>Email: alice@example.com</p>In a real project the template is a separate file like app/Views/user.php, and full frameworks swap this hand-rolled renderer for a template engine such as Blade (Laravel) or Twig (Symfony). The idea is identical: data in, HTML out, nothing else.
4️⃣ The Router & Controller — Wiring It Together
Now the part that makes it an application. A front controller is a single entry point — in production it's public/index.php, and the web server rewrites every URL to it. Inside it, a router reads the request URL (from $_SERVER['REQUEST_URI']) and matches it against registered routes, capturing dynamic parts like :id. It then calls the matching Controller method — the thin coordinator that reads input, asks the Model for data, and hands that data to a View. The controller holds no SQL and no HTML; it just wires the other layers together.
<?php
// FRONT CONTROLLER + ROUTER — one entry point for the whole app.
// In production this is public/index.php and every URL is rewritten to it.
final class Router
{
private array $routes = [];
// Register a GET route. ":id" becomes a captured parameter.
public function get(string $path, callable $handler): void
{
$this->routes[] = ["path" => $path, "handler" => $handler];
}
// Find a matching route and call its handler. Returns the body string.
public function dispatch(string $uri): string
{
// Strip any query string, e.g. "/users/2?sort=asc" -> "/users/2".
$url = parse_url($uri, PHP_URL_PATH);
foreach ($this->routes as $route) {
// Turn "/users/:id" into a regex that captures the id.
$pattern = preg_replace('#:([a-zA-Z]+)#', '([^/]+)', $route["path"]);
if (preg_match('#^' . $pattern . '$#', $url, $m)) {
array_shift($m); // drop the full match
return ($route["handler"])(...$m); // pass captured params
}
}
http_response_code(404);
return "404 Not Found";
}
}
// CONTROLLER — the thin coordinator. It reads input, calls the Model,
// hands data to the View, and returns the result. No SQL, no HTML here.
final class UserController
{
private array $users = [
1 => ["name" => "Alice", "email" => "alice@example.com"],
2 => ["name" => "Bob", "email" => "bob@example.com"],
];
public function show(string $id): string
{
$user = $this->users[(int) $id] ?? null; // ask the "model" for data
if ($user === null) {
return "User #{$id} not found";
}
// Hand the data to a view (here, a simple string) — no logic in the view.
return "Profile: {$user['name']} ({$user['email']})";
}
}
// The front controller wires routes to controller methods, then dispatches.
$controller = new UserController();
$router = new Router();
$router->get("/", fn() => "Home Page");
$router->get("/users/:id", fn($id) => $controller->show($id));
// In production: echo $router->dispatch($_SERVER["REQUEST_URI"]);
// We fake a few requests so the example prints something here:
foreach (["/", "/users/1", "/users/2", "/users/99"] as $uri) {
echo $uri . " -> " . $router->dispatch($uri) . "\n";
}/ -> Home Page
/users/1 -> Profile: Alice (alice@example.com)
/users/2 -> Profile: Bob (bob@example.com)
/users/99 -> User #99 not foundTrace one request: the router turns /users/2 into a regex match, captures 2, and calls UserController::show("2"), which returns the profile string. That four-step flow — URL → router → controller → response — is the heartbeat of every MVC framework.
Now you try. The router and controller are written for you below — you only register one route and fire one request. Fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
<?php
// 🎯 YOUR TURN — register one more route, then run it.
// The router and controller are done; you only add a route and a request.
final class PostController
{
public function show(string $id): string
{
return "Showing post #{$id}";
}
}
$posts = new PostController();
$router = new Router(); // (same Router class from the worked example above)
// 1) Register a GET route for "/posts/:id" that calls $posts->show.
$router->get(___, fn($id) => $posts->show($id)); // 👉 the path is "/posts/:id"
// 2) Dispatch a request for post #7.
echo $router->dispatch(___) . "\n"; // 👉 the URL is "/posts/7"
// ✅ Expected output:
// Showing post #7Showing post #7Router class from the worked example so it's in scope. Fill the two ___ blanks with the path and URL strings, then run it.5️⃣ Autoloading — Loading Classes Automatically
So far the classes lived in one file. In a real project each class gets its own file, and writing require_once for every one is tedious and fragile. Autoloading fixes this: you register a function with spl_autoload_register(), and PHP calls it automatically the first time it meets a class it hasn't loaded yet. The function maps the class name to a file path and requires it — just in time.
<?php
// AUTOLOADING — stop writing require_once for every class.
// spl_autoload_register hands PHP a function it calls the moment you
// use a class it has not seen yet. It loads the file just in time.
spl_autoload_register(function (string $class): void {
// Map the class name to a file path, e.g. App\Models\UserModel
// -> app/Models/UserModel.php (PSR-4 maps a namespace to a folder).
$path = str_replace("\\", "/", $class) . ".php";
echo "Autoloader asked for: {$class} -> {$path}\n";
// if (file_exists($path)) require $path; // the real loader does this
});
// You never call the loader directly. PHP triggers it the first time a
// class is referenced. (These names are illustrative — the files don't
// exist here, so we just print what the loader WOULD load.)
class_exists("App/Models/UserModel", true); // fires the autoloader
class_exists("App/Controllers/UserController", true);
// In real projects Composer writes this loader for you. Declare in composer.json:
// "autoload": { "psr-4": { "App\\": "app/" } }
// then run composer dump-autoload and add require "vendor/autoload.php";
echo "Composer's vendor/autoload.php replaces all hand-written requires.\n";Autoloader asked for: App/Models/UserModel -> App/Models/UserModel.php
Autoloader asked for: App/Controllers/UserController -> App/Controllers/UserController.php
Composer's vendor/autoload.php replaces all hand-written requires.In practice you don't write this by hand. Composer generates it for you: declare a PSR-4 mapping in composer.json (the convention that maps the App\ namespace to the app/ folder), run composer dump-autoload, and add a single require "vendor/autoload.php"; at the top of your front controller. Every class in your project then loads itself.
One more guided exercise — this time on the Model. Finish the safe lookup so the value flows through a prepared statement, never into the SQL string.
<?php
// 🎯 YOUR TURN — finish the Model method so it returns ONE user safely.
// The point: keep input out of the SQL string with a prepared statement.
final class CityModel
{
public function __construct(private PDO $db) {}
public function find(int $id): ?array
{
// 1) Prepare a SELECT with a :id placeholder (don't paste $id into the SQL!)
$stmt = $this->db->prepare(___); // 👉 "SELECT name FROM cities WHERE id = :id"
// 2) Run it, binding the value to the :id placeholder.
$stmt->execute(___); // 👉 [":id" => $id]
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null; // null when nothing matched
}
}
$db = new PDO("sqlite::memory:");
$db->exec("CREATE TABLE cities (id INTEGER PRIMARY KEY, name TEXT)");
$db->exec("INSERT INTO cities (name) VALUES ('London'), ('Paris')");
$model = new CityModel($db);
echo $model->find(2)["name"] . "\n";
// ✅ Expected output:
// ParisParis___ blanks: the SQL string with a :id placeholder, and the array that binds the value. Run it — you should see Paris.6️⃣ How Laravel & Symfony Build On This
You've now built every core piece a framework has — just smaller. Full frameworks keep this exact shape and add the hard parts you don't want to hand-roll: a powerful router, dependency injection (objects are constructed and passed in for you), an ORM like Eloquent or Doctrine (models map to tables without raw SQL), a template engine (Blade, Twig), plus validation, sessions, and security defaults. The mental model you just built — front controller → router → controller → model → view — is exactly how a Laravel or Symfony request flows. Reading their source code will now feel familiar rather than magical.
Common Errors (and the fix)
- Logic or queries inside a View — if your template file runs a database query or decides what to show, it's no longer a view. Views only render data they're handed. Move every query into the Model and every decision into the Controller; the view just loops and echoes.
- Fat controllers — a controller method that's 80 lines of SQL and business rules has stolen the Model's job. Keep controllers thin: read input, call the model, pick a view, return. If a method is doing real work, that work belongs in the Model.
- "Class 'App\UserModel' not found" — autoloading isn't set up, or the file path doesn't match the class name. Check your
spl_autoload_registermapping (or PSR-4 block), confirm the namespace matches the folder, and runcomposer dump-autoloadafter adding classes. - Every URL shows the home page (or a 404) — the web server isn't rewriting requests to your front controller. Configure Apache (
.htaccess) or Nginx to send all paths topublic/index.php, then let the router decide what to do. - "SQLSTATE... syntax error" or odd results from user input — you concatenated input into the SQL string. Switch to a prepared statement with a
:placeholderand bind the value inexecute(); the database then treats it strictly as data.
Pro Tips
- 💡 One responsibility per layer. If you're unsure where code goes, ask: is it data (Model), presentation (View), or coordination (Controller)? That question alone resolves most design decisions.
- 💡 Keep the front controller tiny. It should boot the autoloader, load routes, and dispatch — nothing more. All real logic lives in controllers and models.
- 💡 Build it once, then reach for a framework. Hand-rolling this micro-framework is a learning exercise; ship real apps on Laravel or Symfony, which give you security, an ORM, and a template engine for free.
📋 Quick Reference — MVC in PHP
| Piece | Lives in | Responsibility |
|---|---|---|
| Front controller | public/index.php | Single entry point; boots app, dispatches |
| Router | routes/web.php | Match URL → controller action |
| Controller | app/Controllers/ | Read input, call model, pick view |
| Model | app/Models/ | Data & rules; queries via PDO |
| View | app/Views/ | Turn data into HTML (escape output) |
| spl_autoload_register | bootstrap | Load a class's file on first use |
| Composer PSR-4 | composer.json | Map namespace → folder; auto-generate loader |
Frequently Asked Questions
Q: What does MVC actually stand for, and why use it?
MVC means Model-View-Controller. The Model owns data and business rules (it talks to the database), the View turns data into HTML, and the Controller is the thin coordinator that reads the request, calls the Model, and picks a View. You use it because separating these three concerns makes code easier to test, change, and reuse: you can swap the database without touching a template, or re-skin a page without touching a query.
Q: What is a front controller and why does every request go through one file?
A front controller is a single entry point — usually public/index.php — that every request is routed to by the web server. Instead of having one PHP file per page, the front controller boots the app once (autoloader, config, router) and then dispatches the request to the right controller. This gives you one place to apply routing, authentication, and error handling, and it keeps every other PHP file out of the public web root where it can't be requested directly.
Q: Why use PDO and prepared statements instead of just building the SQL string?
PDO is PHP's database layer; prepared statements let you send the SQL and the values separately, with placeholders like :id. The database treats the values strictly as data, never as SQL, which makes SQL injection essentially impossible. Concatenating user input straight into a query string ("... WHERE id = $id") is the classic vulnerability that lets an attacker rewrite your query. Always prepare and bind.
Q: What is autoloading and how does Composer's PSR-4 fit in?
Autoloading lets you use a class without manually requiring its file first. You register a function with spl_autoload_register(); PHP calls it the moment it meets an unknown class, and the function loads the matching file. PSR-4 is the agreed convention for mapping a namespace prefix to a folder (App\ to app/), so App\Models\UserModel lives at app/Models/UserModel.php. Composer reads the psr-4 block in composer.json and generates the whole autoloader for you — you just add require "vendor/autoload.php"; once.
Q: If I understand this, do I still need Laravel or Symfony?
For anything beyond a toy app, yes. Full frameworks are built on exactly this pattern but add the hard parts you don't want to hand-roll: a powerful router, dependency injection, an ORM (Eloquent/Doctrine), a template engine (Blade/Twig), validation, sessions, CSRF protection, queues, and a security track record. Building your own micro-framework first is the fastest way to truly understand Laravel and Symfony — once you've written the router and dispatcher yourself, their internals stop being magic.
Mini-Challenge: A Two-Route Shop
No code is filled in this time — just a brief and an outline. Build a tiny MVC app with a model, a controller, and the router from the worked example. Run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is the write-run-check loop you'll use on every real feature.
<?php
// 🎯 MINI-CHALLENGE: a two-route mini-app, MVC style.
// No code is filled in — work from the outline, then run it.
//
// 1. MODEL: class ProductModel with an array of products keyed by id, e.g.
// [1 => "Keyboard", 2 => "Mouse"] and a find(int $id): ?string method.
// 2. CONTROLLER: class ProductController with show(string $id) that asks the
// model for the name and returns "Product: <name>" or "Not found".
// 3. ROUTER: reuse the Router from the worked example. Register:
// "/" -> returns "Shop Home"
// "/products/:id"-> calls the controller's show()
// 4. Dispatch "/products/1" and "/products/9" and echo each result.
//
// Keep the layers separate: the model holds data, the controller coordinates,
// the router maps URLs. No SQL or HTML required for this one.
//
// ✅ Expected output:
// Product: Keyboard
// Not found
// your code here🎉 Lesson Complete!
- ✅ MVC splits an app into three single-responsibility layers: Model (data & rules), View (HTML), Controller (coordination)
- ✅ A front controller (
public/index.php) is the single entry point; the router maps$_SERVER['REQUEST_URI']to a controller action - ✅ The Model queries the database with PDO prepared statements — input goes through
:placeholders, never into the SQL string - ✅ The View only renders data into HTML and escapes it with
htmlspecialchars() - ✅
spl_autoload_register(or Composer PSR-4) loads each class's file automatically - ✅ Laravel and Symfony are this exact pattern plus DI, an ORM, a template engine, and security
- ✅ Next lesson: Middleware Architecture — run code before and after a controller, for auth, logging, and CSRF
Sign up for free to track which lessons you've completed and get learning reminders.