Building a Minimalist MVC Framework in PHP from Scratch

Tomas Tulka
5 min readMay 30, 2020

Frameworks are good stuff, at least for middle to big enterprise applications. One doesn’t want to reinvent the wheel. Aspects like security, caching, monitoring are hard and it’s easy to get them wrong if implemented from scratch.

On the other hand, there are valid use cases where you probably don’t need all this advanced stuff. Examples can be a personal webpage, blog, product catalog etc.

For a lot of simple cases a static web generator is the right way to go, but you do want something interactive on your web like search, form or comments, a static page is just not enough, but a framework could be a bit overkill.

Let’s give it a try! We will build a minimalist MVC framework from scratch. The requirement: clean MVC architecture, declarative routing, extreme simplicity. Can we push it under 16KB? Challenge accepted!

Autour d’un point (cut) by František Kupka
Autour d’un point (cut) by František Kupka

Routing

Routing should be as lucid and declarative as possible. Potentially, we can have a lot of routes and if we had to write too much code the routing would easily become a mess.

All requests must go through a singleton Dispatcher. The instance of the Dispatcher is created in the application bootstrap (index.php) together with the routing.

The dispatcher uses internally a singleton object Router:

// mvc/Dispatcher.phpclass Router {
// 'METHOD path' => action
private $routing = [];
}

The Router holds the routing rules as an array with paths as keys and actions as values. Actions are functions to be executed when the request matches the path.

HTTP method is optional, GET will be used as default.

For dynamically generated pages we need to parse path parameters as well. We can define a parameter in the path string inside curly brackets. The action must then be called with an associative array of the parameter names and values.

Here are some examples of routing rules:

[
'/' => function() { ...},
'POST /abc'=> function() { ... },
'/abc'=> function() { … },
'/abc/{id}'=> function($params) { … },
'GET /abc/{id}/xyz'=> function($params) { … },
'/abc/{id1}/xyz/{id2}'=> function($params) { … }
]

Looks pretty neat, easy to create, easy to read. All what we want.

In the Router API we need a function for adding a route and a function to actually process the route upon a request. The former should be easy:

// mvc/Dispatcher.phpclass Router {
private $routing = [];
function addRouting($pattern, $action) {
$this->routing[$pattern] = $action;
}
}

The latter forms the main functionality of the Router. We iterate through the routes until one doesn’t match the request. Otherwise, we return HTTP code 404 and execute, let’s say, the root route (/):

// mvc/Dispatcher.phpclass Router {
private $routing = [];
function addRouting($pattern, $action) { … } function route($method, $path, $params) {
$path = "{$method} /{$path}";
foreach ($this->routing as $pattern => $handler) {
// route parameters as regex
$patternParams = $this->patternParams($pattern);
if (!empty($patternParams)) {
$pattern = $this->withParams($pattern);
}
// add GET into the pattern if necessary
$pattern = $this->withMethod($pattern);
// if the request matches, $params array is filled
if ($this->requestMatches(
$pattern, $path, $patternParams, $params)) {
$handler($params); // execute action
return;
}
}
http_response_code(404);
$this->route['/']([]);
}
}

Private functions patternParams, withParams, withMethod and requestMatches are straightforward and uninteresting (the full source code is provided in the link below) - the idea is clear.

Now, we can use the Router in the implementation of our Dispatcher. The Dispatcher doesn’t do much more than that it parses the request and delegate it to the Router:

// mvc/Dispatcher.phpclass Dispatcher {
private $router;
function __construct() {
$this->router = new Router();
}
function dispatch() {
$this->router->route(
$_SERVER['REQUEST_METHOD'],
$_SERVER['REQUEST_URI'],
$_REQUEST);
}
}

As we probably don’t want to access the Router directly, we need to expose a delegating function for adding a route:

// mvc/Dispatcher.phpclass Dispatcher {
private $router;
function __construct() { … } function dispatch() { … } function routing($pattern, $action) {
$this->router->addRouting($pattern, $action);
return $this;
}
}

Alright, the routing and dispatching is ready. Let’s give it a try:

// index.php(new Dispatcher())
->routing('/hello/{user}', function($params) {
echo "Hello, {$params['user']}!";
})
->dispatch();
curl http://localhost:8080/hello/user123
Hello, user123!

It works! Now, we will implement the MVC pattern.

View and Model

The very first idea behind PHP was to have a simple language for templating. Nowadays, PHP is used for almost everything, but templating. Until now: we will create a simple HTML template in PHP, just like this:

// views/layouts/html.php<!doctype html>
<html lang="en">
<head>
<title>Minimalist MVC Framework</title>
<meta charset="utf-8">
</head>
<body>
<main>
<?= $this->content() ?>
</main>
<footer>
&copy; <?= date('Y') ?> by PHP
</footer>
</body>
</html>

Similarly, we can think of another template, such as XML:

// views/layouts/xml.php<?php
echo "<?xml version=\"1.0\" encoding=\"utf-8\"?>";
echo $this->content();

Simple enough, nah?

That was the layout page, the content of particular views will be rendered into it. A concrete View could look like following:

// views/hello.php<h1>Hello, <?= $this->user ?>!</h1>
<p>Make yourself at home.</p>

Each View needs a name, type (html, xml, json,etc) and a Model, where the data to render lives:

// mvc/ModelView.phpclass ModelView {
private $name;
private $model;
private $type;
public function __construct($name, $model, $type = 'html') {
$this->name = $name;
$this->model = $model;
$this->type = $type;
}
final function render() {
switch ($this->type) {
case 'xml':
header('Content-type: text/xml; charset=UTF-8');
break;
case 'json':
header("Content-Type: application/json; charset=UTF-8");
break;
default:
header("Content-Type: text/html; charset=UTF-8");
}
require_once "./layout/{$this->type}.php";
}
final function content() {
ob_start();
require_once "./views/{$this->name}.php";
$out = ob_get_contents();
ob_end_clean();
return $out;
}
function __get($key) {
return isset($this->model[$key])
? $this->model[$key]
: "__{$key}__";
}
}

The View is read from a file by the name, model attributes are retrieved by the View code via the getter function. The class ModelView is used by the Controller as we will see next.

Controller

The role of a Controller is to validate user input, call the domain logic, fill the View Model and render the View. The Controller is an abstract class meant to be extended by custom actions controllers:

// mvc/Controller.phpabstract class Controller {
private $model = [];
function render($name, $type = 'html') {
return new ModelView($name, $this->model, $type);
}
function addModelAttribute($key, $value) {
$this->model[$key] = $value;
}
}

A custom controller could look as follows:

// controllers/HelloController.phpclass HelloController extends Controller {  function sayHello($params) {
$this->addModelAttribute('user', $params['user']);
$this->render('hello');
}
}

Put It All Together

As the last, we have use our HelloController in the routing definition:

// index.php(new Dispatcher())
->routing('/hello/{user}', function($params) {
(new HelloController())->sayHello($params);
})
->dispatch();
curl http://localhost:8080/hello/user123
<!doctype html>
<html lang="en">
<head>
<title>Minimalist MVC Framework</title>
<meta charset="utf-8">
</head>
<body>
<main>
<h1>Hello, user123!</h1>
<p>Make yourself at home.</p>
</main>
<footer>
&copy; 2020 by PHP
</footer>
</body>
</html>

We’re done. The final size of the framework source code is 4.1 KB. We did it!

Conclusion

We’ve implemented a minimalist super-performant MVC framework using only vanilla PHP. The focus was simplicity, ease of use, separation of concerns.

In less than 5 KB we have achieved to create a simple request dispatcher with declarative routing and base classes for extending when implementing custom actions. Custom application code, domain logic and the framework are fully separated and being touched only on the boundaries.

The working source code is on my GitHub.

Thanks for reading!

--

--