Building a Micro MVC Framework

Reviewed & published by Brayan K

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.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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>\n";   // output
echo "<p>User #{$id}</p>\n";

// 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.

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', '[email protected]'),
            ('Bob',   '[email protected]')");
    }

    // 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 <[email protected]>
var_dump($model->find(99));                              // NULL — no such user

The 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>' . "\n"
    . '<p>Email: <?= htmlspecialchars($email) ?></p>');

$view = new View("user.php");
echo $view->render(["name" => "Alice", "email" => "[email protected]"]);
echo "\n";

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" => "[email protected]"],
        2 => ["name" => "Bob",   "email" => "[email protected]"],
    ];

    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";
}

Trace 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 #7

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.)
// Note the BACKSLASHES: a name with forward slashes is not a legal class
// name, so PHP rejects it without ever calling the autoloader.
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";

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:
//    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)

Pro Tips

📋 Quick Reference — MVC in PHP

PieceLives inResponsibility
Front controllerpublic/index.phpSingle entry point; boots app, dispatches
Routerroutes/web.phpMatch URL → controller action
Controllerapp/Controllers/Read input, call model, pick view
Modelapp/Models/Data & rules; queries via PDO
Viewapp/Views/Turn data into HTML (escape output)
spl_autoload_registerbootstrapLoad a class's file on first use
Composer PSR-4composer.jsonMap namespace → folder; auto-generate loader

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.

🎉 Lesson Complete!

Practice quiz

What do the three letters in MVC stand for?

  • Module-Variable-Class
  • Method-Value-Callback
  • Model-View-Controller
  • Map-Validate-Cache

Answer: Model-View-Controller. MVC means Model (data & rules), View (HTML), and Controller (the thin coordinator).

Which MVC layer is the ONLY one that talks to the database?

  • The Model
  • The View
  • The Controller
  • The Router

Answer: The Model. The Model owns data and business rules, querying the database via PDO and returning plain arrays — it never echoes HTML.

What is the single responsibility of the View?

  • Run database queries
  • Decide which route matched
  • Hash passwords
  • Turn data the controller prepared into HTML, and nothing else

Answer: Turn data the controller prepared into HTML, and nothing else. The View only renders data into HTML (escaping it on output); it does no queries and makes no decisions.

What is a 'front controller' in this MVC design?

  • The first controller class alphabetically
  • A single entry point (public/index.php) that every request is routed to, which boots the app and dispatches
  • A controller that only handles the home page
  • The View that renders the front page

Answer: A single entry point (public/index.php) that every request is routed to, which boots the app and dispatches. The front controller is the one file every URL is rewritten to; it boots the app once and dispatches to the right controller.

How does the Model stop SQL-injection attacks?

  • By using a prepared statement with a :id placeholder, so user input never goes into the SQL string
  • By escaping HTML with htmlspecialchars()
  • By validating that input is lowercase
  • By hashing the input first

Answer: By using a prepared statement with a :id placeholder, so user input never goes into the SQL string. The value goes in through a bound :id placeholder, never glued into the SQL — that single habit stops injection.

What does the View use to escape data so a name like <script> can't inject markup?

  • strip_tags()
  • addslashes()
  • htmlspecialchars()
  • urlencode()

Answer: htmlspecialchars(). htmlspecialchars() escapes output — the View's one security job — so injected markup is shown as text, not executed.

What is the role of the Controller in this architecture?

  • Hold all the SQL queries
  • Be the thin coordinator: read input, call the Model, hand data to a View, and return the result
  • Render the final HTML directly
  • Store the database credentials

Answer: Be the thin coordinator: read input, call the Model, hand data to a View, and return the result. The Controller wires the layers together; it holds no SQL and no HTML — a fat controller has stolen the Model's job.

What does spl_autoload_register() do?

  • Automatically connects to the database
  • Generates HTML for every page
  • Caches query results
  • Registers a function PHP calls to load a class's file the first time the class is referenced

Answer: Registers a function PHP calls to load a class's file the first time the class is referenced. It hands PHP a function that maps a class name to a file path and loads it just in time, replacing manual require_once calls.

What does Composer's PSR-4 convention map?

  • A URL to a controller method
  • A namespace prefix to a folder, e.g. App\ to app/, so App\Models\UserModel lives at app/Models/UserModel.php
  • A database table to a class
  • A locale to a translation file

Answer: A namespace prefix to a folder, e.g. App\ to app/, so App\Models\UserModel lives at app/Models/UserModel.php. PSR-4 maps a namespace to a directory; Composer reads the psr-4 block and generates the whole autoloader for you.

How do full frameworks like Laravel and Symfony relate to this micro-framework?

  • They use a completely different, unrelated architecture
  • They replace MVC with procedural code
  • They keep this exact shape (front controller → router → controller → model → view) and add DI, an ORM, a template engine, and security
  • They avoid routers entirely

Answer: They keep this exact shape (front controller → router → controller → model → view) and add DI, an ORM, a template engine, and security. Laravel and Symfony build on this same pattern, adding the hard parts you don't want to hand-roll.

Continue this course

Frequently asked questions

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.

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.

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.

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.

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.

Related lessons