Skip to main content
    Courses/PHP/Micro MVC Framework

    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

    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.

    The 'spaghetti' way — logic, data, and HTML all welded together
    <?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.
    Output
    <h1>Alice</h1>
    <p>User #1</p>
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    UserModel — queries the database, returns arrays
    <?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 user
    Output
    Alice <alice@example.com>
    NULL
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    View — renders a template into an HTML string
    <?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";
    Output
    <h1>Hello, Alice</h1>
    <p>Email: alice@example.com</p>
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Front controller + Router + Controller
    <?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";
    }
    Output
    / -> Home Page
    /users/1 -> Profile: Alice (alice@example.com)
    /users/2 -> Profile: Bob (bob@example.com)
    /users/99 -> User #99 not found
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    🎯 Your turn: add a route
    <?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
    Output
    Showing post #7
    Paste this below the Router 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.

    spl_autoload_register — load a class's file on demand
    <?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";
    Output
    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.
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    🎯 Your turn: a safe Model lookup
    <?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
    Output
    Paris
    Fill the two ___ 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_register mapping (or PSR-4 block), confirm the namespace matches the folder, and run composer dump-autoload after 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 to public/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 :placeholder and bind the value in execute(); 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

    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

    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.

    🎯 Mini-Challenge: wire a model, controller, and router
    <?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
    Keep the layers separate: model holds the products, controller coordinates, router maps URLs. Dispatch one URL that exists and one that doesn't, then run it.

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

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service