A Simple MVC Router
Small PHP sites often start with one file per page: index.php, students.php, student.php?id=7, contact.php. Each file connects to the database, runs its queries, prints its own header and footer, and mixes SQL with HTML. As the site grows, the same code is copied into dozens of files, and a change to the menu means editing all of them.
Every modern PHP framework solves this the same way: one front controller receives every request, a router decides which code should handle it, and the code is split into models, views and controllers (MVC). In this lesson you will build all of these yourself in about 150 lines, with clean URLs such as /students/7. Once you understand this little app, Laravel and Symfony will feel familiar.
What MVC means
MVC splits an application into three kinds of code, each with one job. This idea is called separation of concerns:
| Part | Job | In our app |
|---|---|---|
| Model | Data and rules: queries, validation, calculations | Student class with all() and find() |
| View | Presentation: the HTML template | students/show.php |
| Controller | Glue: reads the request, asks the model, chooses a view | StudentController with index() and show() |
A request travels like this: browser, front controller (public/index.php), router, controller method, model, then back through a view to the browser as HTML. The model never prints HTML, and the view never runs SQL. That makes each part easier to read, test and change: a designer can edit views without touching queries, and you can reuse the same model in an API.
The project layout
school/
composer.json
app/
Router.php
helpers.php e() and view()
db.php db() from the PDO lessons
Controllers/StudentController.php
Models/Student.php
Views/layout.php
Views/home.php
Views/students/index.php
Views/students/show.php
Views/errors/message.php
public/
index.php the front controller, the only public PHP fileOnly the public folder is visible on the web, so code, configuration and templates cannot be downloaded directly. Classes in app/ use the App\ namespace and are loaded automatically by Composer's PSR-4 autoloader, which you met in Namespaces & Composer. Add this to composer.json and run composer dump-autoload once:
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}Sending every request to one file
The web server must pass every URL that is not a real file (such as a CSS file or an image) to public/index.php. With PHP's built-in server, name it as the router script: php -S localhost:8000 public/index.php. On Apache, put this .htaccess file in public/:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]The two conditions say "the requested path is not an existing file or folder", and the rule sends everything else to index.php. QSA keeps the query string. On Nginx, the equivalent is try_files $uri /index.php?$query_string;. The original URL is still available to PHP in $_SERVER['REQUEST_URI'].
A tiny router
A router keeps a list of routes: an HTTP method, a URL pattern and a handler to call. Patterns can contain placeholders such as {id}, which the router turns into a regular expression and passes to the handler as an argument:
<?php
declare(strict_types=1);
namespace App;
final class Router
{
/** @var list<array{method: string, regex: string, handler: callable|array}> */
private array $routes = [];
public function get(string $pattern, callable|array $handler): void
{
$this->add('GET', $pattern, $handler);
}
public function post(string $pattern, callable|array $handler): void
{
$this->add('POST', $pattern, $handler);
}
private function add(string $method, string $pattern, callable|array $handler): void
{
// '/students/{id}' becomes the regex '#^/students/(?P<id>\d+)$#'
$regex = preg_replace('#\{(\w+)\}#', '(?P<$1>\d+)', $pattern);
$this->routes[] = ['method' => $method, 'regex' => "#^$regex$#", 'handler' => $handler];
}
public function dispatch(string $method, string $uri): string
{
$path = rtrim(parse_url($uri, PHP_URL_PATH), '/') ?: '/';
$allowed = [];
foreach ($this->routes as $route) {
if (!preg_match($route['regex'], $path, $m)) {
continue;
}
if ($route['method'] !== $method) {
$allowed[] = $route['method']; // right path, wrong method
continue;
}
// keep only the named groups, converted to int: ['id' => 7]
$params = array_map('intval', array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY));
$handler = $route['handler'];
if (is_array($handler)) { // [StudentController::class, 'show']
[$class, $action] = $handler;
$handler = [new $class(), $action];
}
return $handler(...$params);
}
if ($allowed) {
http_response_code(405);
header('Allow: ' . implode(', ', $allowed));
return view('errors/message', ['title' => 'Method not allowed']);
}
http_response_code(404);
return view('errors/message', ['title' => 'Page not found']);
}
}A few lines deserve a closer look:
(?P<id>\d+)is a named group: it matches one or more digits and remembers them under the nameid. So/students/abcdoes not match and gets a 404 without any extra code. Regular expressions are explained in Regular Expressions.rtrim(..., '/')makes/students/and/studentsthe same route.$handler(...$params)spreads['id' => 7]as a named argument, so the value arrives in the controller's$idparameter.- A handler is either any callable, such as a closure, or a
[ClassName::class, 'method']pair, from which the router creates the controller object. - If the path matched but the method did not, the answer is
405 Method Not Allowedwith anAllowheader; if nothing matched, it is404 Not Found.
Views and a layout
Views are plain PHP templates. The view() helper renders one template into a string with output buffering (ob_start() collects everything the template prints, ob_get_clean() returns it), then places it inside a shared layout:
<?php
declare(strict_types=1);
function e(string|int|float $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
/** Render a view inside the layout and return the HTML. */
function view(string $name, array $data = []): string
{
$render = function (string $file, array $data): string {
extract($data); // ['students' => ...] becomes $students
ob_start();
require $file;
return ob_get_clean();
};
$views = __DIR__ . '/Views';
$content = $render("$views/$name.php", $data);
return $render("$views/layout.php", ['title' => $data['title'] ?? 'School', 'content' => $content]);
}extract() turns array keys into local variables, so a controller that passes ['student' => $row] lets the template use $student. Running it inside a closure keeps those variables private to the template. The layout holds the parts every page shares:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= e($title) ?> | Rohtak Public School</title>
</head>
<body>
<nav><a href="/">Home</a> <a href="/students">Students</a></nav>
<main>
<?= $content ?>
</main>
</body>
</html>Now the menu lives in exactly one file. $content is printed without e() because it is HTML that our own templates already escaped. The page view, Views/students/show.php, contains only what is special to that page:
<h1><?= e($student['name']) ?></h1>
<p>City: <?= e($student['city']) ?>. Marks: <?= $student['marks'] ?></p>
<p><a href="/students">Back to all students</a></p>The model and the controller
The model talks to the database and knows nothing about HTTP or HTML. It receives the PDO connection in its constructor, which makes it easy to test with a different database:
<?php
declare(strict_types=1);
namespace App\Models;
use PDO;
final class Student
{
public function __construct(private PDO $db) {}
public function all(): array
{
return $this->db->query('SELECT id, name, city FROM students ORDER BY name LIMIT 5')->fetchAll();
}
public function find(int $id): ?array
{
$stmt = $this->db->prepare('SELECT id, name, city, marks FROM students WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
}The controller has one public method, called an action, per page. Each action asks the model for data and returns a rendered view:
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\Student;
final class StudentController
{
private Student $students;
public function __construct()
{
$this->students = new Student(db());
}
public function index(): string
{
return view('students/index', [
'title' => 'All students',
'students' => $this->students->all(),
]);
}
public function show(int $id): string
{
$student = $this->students->find($id);
if ($student === null) {
http_response_code(404);
return view('errors/message', ['title' => 'Student not found']);
}
return view('students/show', ['title' => $student['name'], 'student' => $student]);
}
}Notice how short each action is. There is no SQL, no HTML and no $_SERVER in sight; the router already turned the URL into a clean int $id.
The front controller
Finally, public/index.php loads the autoloader and helpers, lists the routes in one place and dispatches the current request:
<?php
declare(strict_types=1);
use App\Controllers\StudentController;
use App\Router;
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../app/helpers.php';
require __DIR__ . '/../app/db.php';
$router = new Router();
$router->get('/', fn(): string => view('home', ['title' => 'Welcome']));
$router->get('/students', [StudentController::class, 'index']);
$router->get('/students/{id}', [StudentController::class, 'show']);
echo $router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);Start the server with php -S localhost:8000 public/index.php and request a student page:
curl http://localhost:8000/students/2<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Meera Nair | Rohtak Public School</title> </head> <body> <nav><a href="/">Home</a> <a href="/students">Students</a></nav> <main> <h1>Meera Nair</h1> <p>City: Kochi. Marks: 93</p> <p><a href="/students">Back to all students</a></p> </main> </body> </html>
/students/99 and /teachers return 404 Not Found, /students/abc does not match the digits-only placeholder and also gets a 404, and curl -X POST http://localhost:8000/students returns 405 Method Not Allowed with Allow: GET. The routes in index.php now act as a table of contents for the whole site.
Where frameworks go further
This router covers the core idea. Frameworks add features you will want in real projects: string placeholders and constraints ({slug}), named routes that generate URLs for you, middleware (code that runs before or after controllers, for logins or CSRF checks), automatic dependency injection (the framework builds the controller and passes it a model, instead of new Student(db())), form validation and a template engine with automatic escaping. Introduction to Laravel shows the same student pages written with Laravel.
Common mistakes
- Putting SQL in views or HTML in models. Keep each part to its job, or MVC brings no benefit.
- Making the whole project folder public. Point the web server at
public/only, soapp/andconfig/cannot be downloaded. - Forgetting to escape in views. Every value from the database or the user goes through
e(). - Matching routes without anchors. Without
^and$in the regex,/students/7/deletecould match/students/{id}. - Returning 200 for unknown pages. Send 404 (and 405 for a wrong method) so browsers and search engines understand.
Key points
- A front controller (
public/index.php) receives every request;.htaccessor the built-in server's router script sends URLs to it. - A router maps a method and URL pattern to a handler; placeholders like
{id}become regex named groups and controller arguments. - Models handle data, views handle HTML, controllers connect them: separation of concerns.
view()uses output buffering to render a template inside a shared layout.- Unknown paths get 404 and wrong methods get 405 with an
Allowheader. - Frameworks build on the same pattern with middleware, dependency injection and template engines.
Exercise
Add a result page at /students/{id}/result. Register the route in index.php, add a result(int $id) action to StudentController that returns a 404 page for an unknown id, and create the view Views/students/result.php. The page heading should read Result: <name> and the text Passed with 91 marks. or Not passed with 25 marks., using 33 as the pass mark. Decide in the controller, not the view, whether the student passed.
Show solution
// public/index.php: one new route
$router->get('/students/{id}/result', [StudentController::class, 'result']);
// app/Controllers/StudentController.php: a new action
public function result(int $id): string
{
$student = $this->students->find($id);
if ($student === null) {
http_response_code(404);
return view('errors/message', ['title' => 'Student not found']);
}
return view('students/result', [
'title' => 'Result of ' . $student['name'],
'student' => $student,
'passed' => $student['marks'] >= 33,
]);
}<h1>Result: <?= e($student['name']) ?></h1>
<p><?= $passed ? 'Passed' : 'Not passed' ?> with <?= $student['marks'] ?> marks.</p>For /students/6/result the main part of the page is:
<h1>Result: Ananya Iyer</h1> <p>Passed with 91 marks.</p>
The router needed no changes, because {id} already works anywhere in a pattern. The pass rule lives in the controller, so the view only displays the answer it is given.
Quick Quiz
1. What is the job of a front controller?
All URLs are sent to one file, which loads the app and dispatches the request.
2. In MVC, where should the SQL query for a student belong?
Models handle data; views only display what the controller gives them.
3. The path /students exists for GET only. What should a POST to it return?
The resource exists, but not for that method, which is exactly what 405 means.
4. Why does the .htaccess file check !-f before rewriting?
Only paths that are not existing files or folders are rewritten to the front controller.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
