PHP and MVC Architecture

Course 3 · Ch 3
PHP and MVC Architecture: Building a Simple Framework From Scratch
The pattern behind almost every modern PHP framework, built up piece by piece to see exactly what it's solving

The Intermediate capstone's blog put a fair amount of logic directly inside files like index.php and new_post.php. MVC (Model-View-Controller) separates an application into three distinct responsibilities, routed through a single entry point — the architecture underneath Laravel, Symfony, and most other PHP frameworks (previewed properly in Chapter 7).

Request Controller Model (data/logic) View (HTML output) HTML Response
Controller receives the request, asks the Model for data, hands it to a View to render — never the other way around

The Three Roles

  • Model — data and business logic; in this course, the Post class from the Intermediate capstone is already a Model
  • View — purely the HTML/output template; no business logic, no database calls
  • Controller — receives the request, talks to the Model, picks a View, passes data to it; deliberately "thin"

The Front Controller — One Entry Point for Everything

Instead of one PHP file per page (as the Intermediate blog used), every request is routed through a single file, which decides what to do based on the URL.

mini-framework/ public/index.php — the ONE entry point, the front controller src/ Router.php Controllers/ PostController.php Models/ Post.php views/ post_list.php

A Minimal Router

src/Router.php
<?php namespace App; class Router { private array $routes = []; public function get(string $path, array $handler) { $this->routes[$path] = $handler; // [ControllerClass, 'methodName'] } public function dispatch(string $uri) { if (!isset($this->routes[$uri])) { http_response_code(404); echo "404 Not Found"; return; } [$controllerClass, $method] = $this->routes[$uri]; $controller = new $controllerClass(); $controller->$method(); } } ?>

new $controllerClass() demonstrates that a class name doesn't have to be written literally — it can be stored in a variable and instantiated dynamically, which is exactly what a router needs to do without an enormous hardcoded if/elseif chain for every possible URL.

The Front Controller Itself

public/index.php
<?php require __DIR__ . '/../vendor/autoload.php'; use App\Router; use App\Controllers\PostController; $router = new Router(); $router->get('/posts', [PostController::class, 'index']); $uri = strtok($_SERVER['REQUEST_URI'], '?'); // strip any ?query=string before matching $router->dispatch($uri); ?>
::class — getting a class's fully-qualified name as a string, safely
PostController::class resolves to the string "App\Controllers\PostController" at compile time — safer and more refactor-friendly than writing that string by hand, since renaming the class or its namespace would automatically update everywhere ::class is used too.

A Thin Controller

src/Controllers/PostController.php
<?php namespace App\Controllers; use App\Models\Post; class PostController { public function index() { $postModel = new Post(getDbConnection()); $posts = $postModel->all(); require __DIR__ . '/../../views/post_list.php'; // $posts is now available inside the view } } ?>

The controller's job is deliberately small: get the data, hand it to a view. No SQL, no HTML — both belong elsewhere. This is exactly the "thin controller, fat model" guidance frameworks generally encourage.

A Pure View — No Logic, Just Output

views/post_list.php
<?php foreach ($posts as $post): ?> <h2><?= htmlspecialchars($post['title']) ?></h2> <?php endforeach; ?>
If a view starts containing database queries or business logic, the separation has broken down
The whole benefit of MVC depends on each piece staying within its role — a view that queries the database directly, or a controller that builds raw SQL, has quietly become a single tangled mess again, just split across more files than before.

Coding Challenges

Challenge 1

Add a second route to the Router example: GET /about, mapped to a new AboutController with a show() method that requires a simple views/about.php containing static "About us" text (no model needed). Trace through, in writing, exactly what happens from the request hitting index.php to the final HTML being produced.

📄 View solution
Challenge 2

Identify the MVC violation in this controller method, and rewrite it correctly: public function show() { $stmt = $pdo->query("SELECT * FROM posts"); echo "<ul>"; foreach ($stmt as $row) { echo "<li>{$row['title']}</li>"; } echo "</ul>"; } — explain in a comment what's wrong and how your rewrite fixes it.

📄 View solution
Challenge 3

Extend the Router class to support route parameters, e.g. registering "/posts/{id}" and matching a real URL like "/posts/42" by extracting 42 and passing it as an argument to the controller method. Use a regular expression (Intermediate Chapter 9) to implement the matching.

📄 View solution

Chapter 3 Quick Reference

  • Model — data and business logic; View — pure output template; Controller — thin, coordinates the two
  • Front controller — one entry point (public/index.php) routes every request
  • Router — maps a URL to a [ControllerClass, 'method'] pair, dispatches dynamically
  • new $className() — instantiating a class from a variable, essential for dynamic dispatch
  • ClassName::class — gets the fully-qualified class name as a refactor-safe string
  • "Thin controller, fat model" — keep business logic and SQL out of controllers and views entirely
  • Next chapter: testing with PHPUnit — unit tests, mocking