BoffMvc - Case Study
A custom PHP MVC framework - PHP, MySQL, Javascript, CSS3, HTML5
How building my own router, database layer, and template system taught me what Laravel and Django abstract away
I’ve shipped apps with Django and Ruby on Rails, but I always had a nagging feeling that I didn’t truly understand what was happening under the hood. When something broke in the routing layer or a query didn’t behave the way I expected, I was guessing. I was using the magic without understanding the trick.
So I decided to build my own MVC framework from scratch in PHP. No third party libraries, no ORM, no training wheels. Just me, PHP, MySQL, and the question: what does it actually take to make one of these things work?
The result is BoffMVC, and the process of building it taught me more about web architecture than any tutorial or course ever could.
Convention Over Configuration: A Router That Figures It Out
One of the first big decisions I had to make was how routing would work. If you’ve used Laravel, you know the pattern: you explicitly define every single route in a file.
php
// Laravel's approach - manually define each route
Route::get('/user', [UserController::class, 'index']);
Route::get('/recipe', [RecipeController::class, 'index']);
Route::post('/recipe', [RecipeController::class, 'store']);This works great in production frameworks, but I wanted to see if I could take a different approach: what if the router could figure out which controller to use just by looking at the URL? Instead of maintaining a growing list of route definitions, BoffMVC dynamically resolves the controller from the request.
php
// Router.php - dynamically resolve the controller from the URL
$page = isset($_GET['page']) ? $_GET['page'] : 'home';
$controller_name = ucfirst($page) . 'Controller';
$controller_path = 'app/controller/' . $controller_name . '.php';
if (file_exists($controller_path)) {
require_once($controller_path);
$controller = new $controller_name();
} else {
// Fall back to the home page if no matching controller exists
require_once('app/controller/HomeController.php');
$controller = new HomeController();
}When a user visits example.com/?page=user, the router parses out user, looks for a UserController, and loads it. If that controller doesn’t exist, it gracefully falls back to the HomeController. No route file to maintain, no manual wiring.
The tradeoff here is flexibility. With Laravel’s explicit routing, you can map any URL pattern to any controller method. My convention-based approach means you’re locked into a specific naming pattern. But for this project, the simplicity was worth it and it forced a consistent structure across the whole application.
Before any of that controller resolution happens though, the router first checks the CSRF token to protect against cross-site request forgery. Every form rendered by BoffMVC includes a hidden token field, and the router validates it before processing any request:
php
// Validate CSRF token before processing the request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
die('Invalid CSRF token');
}
}This was one of those features that I never thought twice about in Django (it just works) but building it myself gave me a real appreciation for how important it is to bake security into the framework layer rather than leaving it to individual developers.
Simulating RESTful Methods Over POST
Here’s a practical constraint I ran into early: HTML forms only support GET and POST. There’s no native way to send a PUT or DELETE request from a form. I’d never had to think about this before because Rails and Django handle it behind the scenes. But when you’re building from scratch, you hit this wall fast.
My solution was to embed a hidden field in every form that tells the controller what the intended method is:
html
<!-- A form that should trigger a DELETE action -->
<form method="POST" action="index.php?page=recipe">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token']; ?>">
<input type="hidden" name="_method" value="DELETE">
<input type="hidden" name="recipe_id" value="42">
<button type="submit">Delete Recipe</button>
</form>Then in the controller, I check for that hidden field and route accordingly:
php
// ApplicationController.php - base controller that all others extend
class ApplicationController {
public function manageRequest() {
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$this->handleGet();
} else if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$method = isset($_POST['_method']) ? $_POST['_method'] : 'CREATE';
switch ($method) {
case 'PUT':
$this->handlePut();
break;
case 'DELETE':
$this->handleDelete();
break;
default:
$this->handleCreate();
break;
}
}
}
}Every controller in BoffMVC extends ApplicationController and calls manageRequest() after instantiation. This pattern means each controller just needs to implement the specific handler methods it cares about (handleGet, handleCreate, handlePut, handleDelete) and the base class takes care of dispatching.
I later learned that Rails does almost the exact same thing with its _method hidden field. It was a fun moment of convergent design, arriving at the same solution independently because the underlying constraint is the same for everyone.
When Textbook MVC Breaks Down: Discovering the Model Manager Pattern
This was probably the biggest “aha” moment of the whole project. The textbook MVC diagram is clean: Controller talks to Model, Model talks to Database, Model returns data to View. Simple.
In practice, it got messy fast. My User model represented a single user and handled things like saving to the database or updating a password. But where does the logic go for “find all users who signed up this week” or “check if this email already exists before creating a new account”? Those operations aren’t really about a single user object. They’re about managing collections of users.
I ended up splitting model responsibilities into two classes: the Model and the Model Manager.
php
// User.php - represents a single user object
class User {
public $id;
public $username;
public $email;
public function __construct($data) {
$this->id = $data['id'];
$this->username = $data['username'];
$this->email = $data['email'];
}
public function save() {
$db = new DBManager();
$db->query(
"UPDATE users SET username = ?, email = ? WHERE id = ?",
[$this->username, $this->email, $this->id]
);
}
}php
// UserManager.php - manages collections and queries about users
class UserManager {
public function getUserById($id) {
$db = new DBManager();
$result = $db->query("SELECT * FROM users WHERE id = ?", [$id]);
return $result ? new User($result) : null;
}
public function getAllUsers() {
$db = new DBManager();
$results = $db->query("SELECT * FROM users");
return array_map(function($row) { return new User($row); }, $results);
}
public function emailExists($email) {
$db = new DBManager();
$result = $db->query("SELECT id FROM users WHERE email = ?", [$email]);
return !empty($result);
}
}The Model handles individual object behavior (save, update, delete itself). The Model Manager handles finding, filtering, and creating those objects. I later learned that this separation is similar to what’s called the Repository pattern in domain-driven design, and it’s essentially what Django’s Manager class does (e.g., User.objects.filter(...)). I arrived at it independently because the single-model approach just couldn’t handle queries that spanned multiple objects cleanly.
This was the moment where building my own framework paid off the most. I went from vaguely knowing that Django has a .objects manager to understanding why it exists and what problem it solves.
The Database Layer: Simplicity Over Abstraction
Established frameworks like Laravel come with full Object-Relational Mapping (ORM) systems like Eloquent, which let you interact with database tables as if they were PHP objects. That’s powerful, but it’s also a huge amount of abstraction to build.
I went the opposite direction. BoffMVC provides a thin DBManager interface that handles connections and parameterized queries, but you write the SQL yourself:
php
// DBManager.php - thin wrapper around PDO
class DBManager {
private $connection;
public function __construct() {
$this->connection = new PDO(
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME,
DB_USER,
DB_PASS
);
$this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
}
public function query($sql, $params = []) {
$stmt = $this->connection->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}Every time a model or manager needs to talk to the database, it creates a new DBManager instance, which opens a connection, runs the query, and returns the result. The pattern ends up looking like this throughout the codebase:
$db = new DBManager();
$recipes = $db->query(
"SELECT * FROM recipes WHERE user_id = ? ORDER BY created_at DESC",
[$user_id]
);Is this the most efficient approach? No. Creating a new connection per query is wasteful compared to connection pooling. A production framework would maintain a single connection (or a pool) across the request lifecycle. But for a learning project, the simplicity of “new instance, run query, get result” meant I could focus on understanding the flow of data through the application without getting bogged down in connection management.
If I were to rebuild this today, I’d implement a singleton pattern for the database connection so that all queries within a single request share one connection.
Views and the Template System
The view layer in BoffMVC collects data from the controller and passes it into .phtml template files. All data that needs to be rendered gets packed into a $RENDER_VARS array:
// RecipeView.php
class RecipeView {
public function showRecipe($recipe, $author) {
$RENDER_VARS = [];
$RENDER_VARS['title'] = $recipe->title;
$RENDER_VARS['author'] = $author->username;
$RENDER_VARS['ingredients'] = $recipe->ingredients;
// Load CSS and JS specific to this page
$RENDER_VARS['css'] = ['app/view/Recipe/css/recipe.css'];
$RENDER_VARS['js'] = ['app/view/Recipe/js/recipe.js'];
// Hand off to the template
require('app/template/recipe_show.phtml');
}
}Templates were broken up into reusable sub-templates: a header, a footer, a navigation bar, and then page-specific content in between. This meant I could change the site-wide navigation in one place instead of updating every page. It’s the same concept as Django’s template inheritance or Rails’ layouts, just implemented more manually.
The Flow of a Request, End to End
To tie it all together, here’s what happens when a user visits example.com/?page=recipe&id=5:
The browser hits
index.php, which immediately hands off toRouter.php.The router checks the CSRF token (for POST requests), parses
page=recipefrom the URL, and loadsRecipeController.php.The controller calls
manageRequest(), which identifies this as a GET request and callshandleGet().handleGet()asksRecipeManagerto find the recipe withid=5. The manager creates aDBManager, runs the query, and returns aRecipeobject.The controller passes the
Recipeobject toRecipeView.The view packs the data into
$RENDER_VARSand loads the appropriate.phtmltemplate.The template renders the final HTML back to the user.
Each layer has a single responsibility, and data flows in one direction. That’s the whole point of MVC, and building it from the ground up made each of those boundaries tangible instead of theoretical.
What I’d Do Differently
The biggest mistake I made was developing the framework while simultaneously building a recipe sharing website on top of it. The framework and the application code became intertwined. Features got added to the framework because the recipe site needed them, not because they were generalized solutions. This made BoffMVC difficult to reuse for any other project.
If I were starting over, I’d develop the framework as a standalone library with its own test suite, and build the recipe site as a completely separate project that imports it. That separation of concerns applies at the project level, not just the code level.
I’d also invest in a few specific improvements. First, a singleton or factory pattern for database connections instead of creating a new connection per query. Second, a more flexible routing system that supports clean URLs (like /recipe/5 instead of ?page=recipe&id=5). Third, better error handling and logging rather than the bare-bones approach I took.
The Point of All This
Building BoffMVC wasn’t about creating something to compete with Laravel. It was about pulling back the curtain on tools I use every day and understanding the decisions that went into them. Every professional framework has made hundreds of architectural choices. Until you’ve faced even a handful of those choices yourself, it’s hard to appreciate why they made the tradeoffs they did.
I now have a much deeper understanding of routing, request lifecycles, database abstraction layers, and the MVC pattern itself. When I work with Laravel or Django now, I’m not just following the documentation. I understand the why behind the patterns, and that makes me a better engineer.


