Write a PHP MVC Framework
This is a work in progress
Today I am going to show how to create a simple PHP blog application using the MVC pattern (Model-View-Controller). It is inspired by Ruby on Rails and uses a similar structure. This blog is based on a simple MVC framework written in PHP. This will give you a basic understanding of MVC before using a real framework.
MVC frameworks are widely used in the industry because they offer a lot of advantages for rapid and structured development. There are MVC frameworks for most of the programming languages you may know, from DotNet to PHP to Ruby on Rails. Unfortunately, those frameworks might have a steep learning curve. This is due to the fact that people need to learn how to write code in the framework ecosystem.
What does MVC mean?
MVC (Model, View, Controller) is a design pattern used to decouple data (Models), the user-interfaces (Views), and application logic (Controllers). To be able to follow this “How to”, you need to have a good knowledge of PHP and OOP (Object Oriented Programming)but I will also provide the code for a basic blog that you and continue with..
Build a PHP MVC framework
Independently you are using Docker, XAMPP, or whatever for your development environment, let’s create a simple structure for the simple PHP MVC framework. I use the web root of my server to create the folder structure, in this case the folder is /var/www/html/. All folders I create are inside this folder.
Let’s create the basis folders for your MVC:
app
config
public
views
routes
Starting small, let’s create the two most important files of our simple PHP MVC: index.php and htaccess.
The htaccess configuration file
Enter the public folder, and let’s create a file called index.php
Now, at the root level of your project, let’s create a new file called .htaccess Then open it, and put this code inside the htaccess:
RewriteEngine On
# Stop processing if already in the /public directory
RewriteRule ^public/ - [L]
# Static resources if they exist
RewriteCond %DOCUMENT_ROOT%/public/$1 -f
RewriteRule (.+) public/$1 [L]
# Route all other requests
RewriteRule (.*) public/index.php?route=$1 [L,QSA]
# This is needed to use HTTP Authentication with (f)CGI versions of PHP
SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
Htaccess is a configuration file for the Apache web server, and the mod_rewrite directive tells to Apache that every request will end up to the index.php located in the folder named public.
What does it mean? It means that if you browse https://myblog/page1, https://myblog/page2 or https://myblog/page3, all of them will end up in the index.php under public, that is the entry point of your PHP MVC framework.
This is a big advantage because you can now handle your request in one place, understand what resource is requested and provide the right response.
Another thing: using htaccess and drive the traffic under the public folder, the rest of your project’s structure will be hidden to anyone. This is how your project looks like right now:
Folder structure:
app
config
public
img
js
css
index.php
views
routes
.htaccess
Bootstrap it. Now you need a way to bootstrap your app and load the code you need. We already said that index.php under the public folder is the entry point, for that reason we include the necessary files from there. First of all, we load the config file, here is the content of index.php:
// Load Config
require_once '../config/config.php';
Now we can create a config.php file under the config folder. Inside the config file, we can store the settings of the framework, for example, we can store the name of our app, the path of the root, and of course, the database connection parameters: We want to be able to load the future classes without any pain (see: dozen of include or require), then we’ll use the PSR-4 autoloading with Composer. It was reported that some hosting needs
The classmap directive: classmap autoloading will recursively go through .php and .inc files in specified directories and files and sniff for classes in them.
Composer is a dependency manager for PHP, it allows you to declare the libraries your project depends on, and it will manage them for you. Really helpful! First, at the root level, you must create a file called composer.json and add the following content:
{ "name": "plorimer/simple-php-blog", "description": "Simple PHP Blog", "autoload": { "psr-4": { "App": "app/" }, "classmap": [ "app/" ] }, "require": { "symfony/routing": "^4.4", "symfony/http-foundation": "^4.4" } }
Now ensure composer is installed and execute the following command at the root level of your project, depending on where you are working and where your composer was installed: composer install If you check your root folder now, you can see a new folder called vendor that contains the autoload.php file and the composer folder.
Open the index.php under the public folder, and simply add the following code at the beginning of the file:
require_once '../vendor/autoload.php';
From now on, you can use App as a starting point of your namespaces, like this: use AppControllersMyController;
Model: A model is an object that represents your data. The model will be modeled on your database table structure and it will interact with the database operations (create, read, update and delete). For instance, if you have a Posts table:
CREATE TABLE Posts (
PostID int NOT NULL AUTO_INCREMENT,
UserID int NOT NULL DEFAULT '1',
CategoryID int NOT NULL DEFAULT '1',
PostTitle varchar(128) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
PostSummary varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL, utf8mb3
PostContent text utf8mb3 CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
IsPostPunlished tinyint NOT NULL DEFAULT '1',
PostCreationDate date NOT NULL DEFAULT '2023-01-23',
PRIMARY KEY (PostID), KEY UserID (UserID), KEY CategoryID (CategoryID),
CONSTRAINT Posts_ibfk_1 FOREIGN KEY (CategoryID)
REFERENCES Categories (CategoryID) ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT Posts_ibfk_2 FOREIGN KEY (UserID)
REFERENCES Users (UserID) ON DELETE RESTRICT ON UPDATE RESTRICT ) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8mb3
First of all, let’s create a new folder called Models under app folder. Then let’s create a new file called Product under Models. Your model Product will be:
namespace AppModels;
class Posts {
protected $post_id;
protected $category_id;
protected $post_title;
protected $post_summary;
protected $post_content;
protected $post_date;
protected $top3_title;
protected $top3_summary;
protected $category_name;
protected $user_name;
protected $conn;
// Class Constructor and Destructor
public function __construct() { $this->conn = mysqli_connect(DB_HOST, DB_USER, DB_PASS, DB_NAME); }
public function __destruct() { $this->conn->close(); unset($posts); unset($admin); }
// GET METHODS
public function getPostId() { return $this->post_id; }
public function getCatId(string $catname) { return $this->category_id; }
public function getUserName() { return $this->user_name; }
public function getCategoryName() { return $this->category_name; }
public function getAllCategories()
{
$userList = array();
$sql = "SELECT CategoryName FROM Categories ORDER BY 1";
$result = $this->conn->query($sql);
if ($result->num_rows > 0)
{
// output data of each row
while ($row = $result->fetch_assoc()) {
$catList[] = $row['CategoryName'];
}
}
return $catList;
}
public function getAllUsers() {
$userList = array();
$sql = "SELECT username FROM Users ORDER BY username";
$result = $this->conn->query($sql);
if ($result->num_rows > 0) {
// output data of each row
while ($row = $result->fetch_assoc()) {
$userList[] = $row['username']; }
}
return $userList;
}
public function WriteLog($message,$trace,$details)
{
$sql = " INSERT INTO Log (LogMessage, LogTrace, LogDetails, LogDate) VALUES ('$message','$trace', '$details', NOW())"; $this->conn->query($sql);
}
public function isUserSelected(int $id) {
$selected = "none";
$sql = "SELECT Posts.UserID FROM Posts, Users WHERE Users.UserID = Posts.UserID AND Posts.PostID=$id"; $result = $this->conn->query($sql);
// output data of each row
$row = $result->fetch_assoc();
if (!empty($row['UserID']) ) $selected = "selected";
//$this->WriteLog("$row[UserID]",'');
return $selected; }
public function getTitle() { return $this->post_title; }
public function getSummary() { return $this->post_summary; }
public function getContent() { return $this->post_content; }
public function getDate() { return $this->post_date; }
// SET METHODS
public function setPostId(int $post_id) { $this->post_id = $post_id; }
public function getUserId(string $username) {
$sql = "SELECT UserID FROM Users WHERE UserName='$username'"; $result = $this->conn->query($sql);
if ($result->num_rows > 0) {
// output data of each row
$row = $result->fetch_assoc(); }
$this->user_id = $row['UserID'];
return $this->user_id; }
public function setUserName(int $id)
{ $sql = "SELECT username FROM Users,Posts WHERE Posts.UserID = Users.UserID AND Posts.PostID=$id";
$result = $this->conn->query($sql);
if ($result->num_rows > 0) {
// output data of each row
$row = $result->fetch_assoc(); }
$this->user_name = $row['username'];
}
public function setTitle(int $id) {
// Check connection
if (!$this->conn) {
die("Connection failed: " . mysqli_connect_error());
}
$sql = "SELECT postTitle FROM Posts where PostID = $id";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
$title = htmlspecialchars_decode($row['postTitle']);
$this->post_title = $title;
}
public function setSummary(int $id)
{
// Check connection
if (!$this->conn)
{
die("Connection failed: " . mysqli_connect_error());
}
$sql = "SELECT postSummary FROM Posts where PostID = $id";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
$summary = htmlspecialchars_decode($row['postSummary']);
$this->post_summary = $summary;
}
public function setContent(int $id) {
// Check connection
if (!$this->conn) { die("Connection failed: " . mysqli_connect_error()); }
$sql = "SELECT postContent FROM Posts where PostID = $id";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
$content = htmlspecialchars_decode($row['postContent']);
$this->post_content = $content;
}
public function setDate(int $id) {
// Check connection
if (!$this->conn) { die("Connection failed: " . mysqli_connect_error()); }
$sql = "SELECT postCreationDate FROM Posts where PostID = $id";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
$date = $row['postCreationDate'];
$this->post_date = $date;
}
public function setCategoryName(int $id)
{
$sql = "SELECT Categories.CategoryName FROM Categories, Posts WHERE Categories.CategoryID = Posts.CategoryID AND Posts.PostID=$id";
// die ($sql); $result = $this->conn->query($sql);
if ($result->num_rows > 0)
{
// output data of each row $row = $result->fetch_assoc();
}
$this->category_name = $row['CategoryName'];
}
public function setCategoryId(int $id)
{
$sql = "SELECT Categories.CategoryID FROM Categories, Posts WHERE Categories.CategoryID = Posts.CategoryID AND Posts.PostID=$id";
// die ($sql);
$result = $this->conn->query($sql);
if ($result->num_rows > 0) {
// output data of each row
$row = $result->fetch_assoc(); }
$this->category_id = $row['CategoryID'];
}
// CRUD OPERATIONS
public function create()
{
try {
$sql = "INSERT INTO Posts (postTitle,postSummary,postContent,postCreationDate,CategoryID) VALUES ( '" . htmlspecialchars($_POST['postTitle'], ENT_QUOTES) . "', '" . htmlspecialchars($_POST['postSummary'], ENT_QUOTES) . "', '" . htmlspecialchars($_POST['postContent'], ENT_QUOTES) . "', CURDATE()," . 3 . ")" ;
$this->conn->query($sql);
} catch(mysqli_sql_exception $e) {
$this->WriteLog("Database Error",htmlspecialchars(str_replace("'"," ",$e->getMessage()),ENT_QUOTES),str_replace("'"," ","Method: create() Models/Posts.php:313 - $sql")); }
}
public function readAll() {
try { $sql = "SELECT * FROM Posts";
$result = $this->conn->query($sql);
if ($result->num_rows > 0) {
// output data of each row
$this->ret = array();
while($row = $result->fetch_assoc())
{
$this->ret['id'][] = $row['PostID'];
$this->ret['title'][] = $row["PostTitle"];
$this->ret['summary'][] = htmlspecialchars_decode($row["PostSummary"]);
}
}
} catch(mysqli_sql_exception $e)
{ $this->WriteLog("Database Error",htmlspecialchars(str_replace("'"," ",$e->getMessage()),ENT_QUOTES),str_replace("'"," ","Method: readALL() Models/Posts.php:327 - $sql"));
}
return $this;
}
public function readAllPosts()
{
$sql = " SELECT * FROM Posts ORDER BY RAND() ";
$result = $this->conn->query($sql);
if ($result->num_rows > 0) {
// output data of each row
$this->ret = array();
while($row = $result->fetch_assoc())
{
$this->ret['id'][] = $row['PostID'];
$this->ret['title'][] = htmlspecialchars_decode($row["PostTitle"]);
$this->ret['summary'][] = htmlspecialchars_decode($row["PostSummary"]);
$this->ret['date'][] = $row["PostCreationDate"];
}
}
return $this;
}
public function top_three()
{
$sql = " SELECT * FROM Posts ORDER BY RAND() LIMIT 3 ";
$result = $this->conn->query($sql);
if ($result->num_rows > 0)
{
// output data of each row
$this->ret = array();
while($row = $result->fetch_assoc())
{
$this->ret['id'][] = $row['PostID'];
$this->ret['title'][] = htmlspecialchars_decode($row["PostTitle"]);
$this->ret['summary'][] = htmlspecialchars_decode($row["PostSummary"]); $this->ret['date'][] = $row["PostCreationDate"];
}
}
return $this;
}
public function update(int $id)
{
try {
$sql = "UPDATE Posts SET postTitle = '" . htmlspecialchars($_POST['postTitle'], ENT_QUOTES) . "', postSummary = '" . htmlspecialchars($_POST['postSummary'], ENT_QUOTES) . "', postContent = '" . htmlspecialchars($_POST['postContent'], ENT_QUOTES) . "', UserID = '" . $_POST['postUserID'] . "' WHERE PostID = '$id'";
$this->conn->query($sql); }
catch(mysqli_sql_exception $e) {
$this->WriteLog("Database Error",htmlspecialchars(str_replace("'"," ",$e->getMessage()),ENT_QUOTES),str_replace("'"," ","Method: update() Models/Posts.php:420 - $sql")); }
}
public function delete(int $id)
{
try {
$sql = "DELETE FROM Posts WHERE PostID = '$id'"; $this->conn->query($sql);
} catch(mysqli_sql_exception $e) {
$this->WriteLog("Database Error",htmlspecialchars(str_replace("'"," ",$e->getMessage()),ENT_QUOTES),str_replace("'"," ","Method: delete() Models/Posts.php:435 - $sql")); }
}
// End Class
}
And that’s it. With the methods, you’ll create the objects you need to be filled with the real values based on the model.
View: The view is responsible to take in the data from the controller and display those values.
There are a lot of template engines for PHP, from Twig to Blade. For this MVC tutorial for PHP, we’ll use only plain HTML to make things simple. In order to create a new view, we must create a new file called product.php under the views folder.
Based on the product attributes, we can write a simple HTML like this:
Simple PHP MVC My Product: getTitle(); ?> getDescription(); ?> getPrice(); ?> getSku(); ?> getImage(); ?> Back to homepage
The view is now ready to get the product object ($product) and display its values.
Controller: The controller is the heart of the application logic. Is responsible for accepting the input and converting it to commands for the model or view. Let’s create a new folder called Controllers under the app folder, and create a new controller file called ProductController.php.
Here is the content:
read($id);
require_once APP_ROOT . '/views/product.php';
Very simple, isn’t it? Obviously, things might be more complex, we can create a parent Controller class, a view method, and other helper functions. But it’s enough for now.