PHP MVC API Documentation

You,php

In this post, I will be going through the step by step process of creating a MVC architecture for your vanilla PHP backend. We will cover topics such as concern seperation, data sanitization, OOP, singletons, and dependency injection. For this example, we will be creating a backend to handle messages users send through a contact form of some sort. This closely aligns with the code utilized in my personal professional portfolio for handling messages. The topic of how to safely and efficiently notify yourself of these messages on a Linux server will be discussed in a later post.

The first class we will start out with shall handle MySQL/MariaDB database configuration settings, including loading environment variables and initializing a database connection. It follows the singleton pattern to ensure a single instance of the configuration throughout the application.

<?php
class Config
{
    /** @var Config|null $instance The singleton instance of the Config class. */
    private static $instance;
 
    /** @var mysqli|null $dbConnection The database connection object. */
    private $dbConnection;

This constructor loads environment variables and initializes the database connection.
It follows the singleton pattern to ensure a single instance. A getter method allows
you to get a singleton instance of the Config class.

public function __construct()
{
    $this->loadEnv();
    $this->initializeDatabaseConnection();
}
 
public static function getInstance()
{
    if (self::$instance === null) {
        self::$instance = new self();
    }
    return self::$instance;
}

This method initialized the database connection based on credentials in
your .env file. It handles connection errors with exceptions gracefully. A get method allows you to get the connection object, which is of type mysqli.

private function initializeDatabaseConnection()
{
    try {
        $this->dbConnection = new mysqli(DB_HOST, DB_USERNAME, DB_PASSWORD, DB_NAME);
 
        // Check for database connection errors
        if ($this->dbConnection->connect_errno) {
            throw new Exception("Could not connect to the database: " . $this->dbConnection->connect_error);
        }
    } catch (Exception $e) {
        die("ERROR: " . $e->getMessage());
    }
}
 
public function getDatabaseConnection()
{
    return $this->dbConnection;
}

Finally, these methods are responsible for loading the contents of a .env file and properly loading the variables from the array of parsed variables.

private function loadEnv()
{
    $envFilePath = '../.env';
    if (file_exists($envFilePath)) {
        $envVariables = $this->parseEnvFile($envFilePath);
 
        // Set environment variables as constants or variables
        define('DB_HOST', $envVariables['DB_HOST']);
        define('DB_USERNAME', $envVariables['DB_USERNAME']);
        define('DB_PASSWORD', $envVariables['DB_PASSWORD']);
        define('DB_NAME', $envVariables['DB_NAME']);
    }
}
 
private function parseEnvFile($filePath)
{
    $envVariables = [];
 
    $lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    foreach ($lines as $line) {
        // Skip lines starting with '#' (comments) or empty lines
        if (empty($line) || strpos(trim($line), '#') === 0) {
            continue;
        }
 
        list($key, $value) = explode('=', $line, 2);
        $key = trim($key);
        $value = trim($value);
 
        // Remove surrounding quotes from the value if present
        if (in_array($value[0], ['"', "'"]) && $value[0] === substr($value, -1)) {
            $value = substr($value, 1, -1);
        }
 
        $envVariables[$key] = $value;
    }
    return $envVariables;
}
?>

Next, we can go ahead and start building a model. The following model is of a message that should contain a valid name, email address, and message.

We start off by defining member variables for these necessary fields as well as a database link that we set in the constructor.

<?php
class MessageModel
{
    private $name;
    private $email;
    private $message;
    private $link;

For our constructor, we will use dependency injection of the singleton class we defined above to set the member variable $link with the database connection object.

public function __construct(Config $config)
{
    $this->link = $config->getDatabaseConnection();
}

Next, we go on to define sanitization methods for all of the member variables of our model. We do a preliminary check like this before making any database queries in order to filter out bad requests ahead of time whilst performing a bit of initial sanitization of the data.

/**
 * Check if the message data is valid.
 *
 * @param mixed $data The data received from the request.
 * @return bool True if the message data is valid, false otherwise.
 */
public function isValidMessageData($data)
{
    $this->name = isset($data->name) ? $data->name : '';
    $this->email = isset($data->email) ? $data->email : '';
    $this->message = isset($data->message) ? $data->message : '';
 
    return !empty($this->name) && !empty($this->email) && $this->isValidEmail() && !empty($this->message);
}
 
/**
 * Sanitize and filter the message data.
 *
 * @param mixed $data The message data.
 * @return mixed The sanitized and filtered message data.
 */
public function sanitizeMessageData($data)
{
    $sanitizedData = new stdClass();
    $sanitizedData->name = $this->sanitizeAndFilterInput($data->name);
    $sanitizedData->email = $this->sanitizeAndFilterInput($data->email);
    $sanitizedData->message = $this->sanitizeAndFilterInput($data->message);
 
    return $sanitizedData;
}
 
/**
 * Check if the email address is valid.
 *
 * @return bool True if the email address is valid, false otherwise.
 */
private function isValidEmail()
{
    $sanitizedEmail = filter_var($this->email, FILTER_SANITIZE_EMAIL);
    return filter_var($sanitizedEmail, FILTER_VALIDATE_EMAIL) !== false;
}
 
/**
 * Sanitize and filter the input data.
 *
 * @param string $input The input string to sanitize and filter.
 * @return string The sanitized and filtered input string.
 */
private function sanitizeAndFilterInput($input)
{
    $input = trim($input);
    $input = stripslashes($input);
    $input = htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
    $input = filter_var($input, FILTER_SANITIZE_STRING);
    $input = strip_tags($input);
    return $input;
}

Next, we implement some logic to update and retreive the last time a certain IP address attempted to send a message in order to prevent abuse. We also implement a function that counts how many messages a user has sent for some extra rate limiting logic we will utilize in the controller.

/**
 * Get the last request time from the database for the specified IP address.
 *
 * @param string $ip The IP address.
 * @return int|null The last request time, or null if not found.
 */
public function getLastRequestTime($ip)
{
	$sql = "SELECT last_request_time FROM rate_limit WHERE ip = ?";
	$stmt = $this->link->prepare($sql);
	$stmt->bind_param("s", $ip);
	$stmt->execute();
	$stmt->bind_result($lastRequestTime);
 
	if ($stmt->fetch()) {
		return $lastRequestTime;
	}
 
	return null;
}
 
/**
* Update the last request time for the specified IP address in the database.
*
* @param string $ip The IP address.
* @param int $currentTime The current time.
* @return bool True if the update was successful, false otherwise.
*/
public function updateLastRequestTime($ip, $currentTime)
{
	$existingTime = $this->getLastRequestTime($ip);
 
	if ($existingTime !== null) {
		// Update the existing record
		$sql = "UPDATE rate_limit SET last_request_time = ? WHERE ip = ?";
		$stmt = $this->link->prepare($sql);
		$stmt->bind_param("is", $currentTime, $ip);
		$success = $stmt->execute();
	} else {
		// Create a new record
		$sql = "INSERT INTO rate_limit (ip, last_request_time) VALUES (?, ?)";
		$stmt = $this->link->prepare($sql);
		$stmt->bind_param("si", $ip, $currentTime);
		$success = $stmt->execute();
	}
 
	$stmt->close();
	return $success;
}
 
/**
 * Get the count of spam messages from the same IP.
 *
 * @return int The count of spam messages from the same IP.
 */
public function getSpamCount()
{
    $sql = "SELECT COUNT(*) as countIP FROM messages WHERE ip = ? ;";
    $stmt = $this->link->prepare($sql);
    $stmt->bind_param("s", $_SERVER["REMOTE_ADDR"]);
    $stmt->execute();
    $result = $stmt->get_result();
    $row = $result->fetch_assoc();
    return $row["countIP"];
}

To finish off the model, we add a function to safely save the message in the database. We keep SQL injection protection in mind by utilizing prepare statements.

/**
 * Save the message to the database.
 *
 * @param string $name The name.
 * @param string $email The email.
 * @param string $message The message.
 * @return int The ID of the saved message.
 * @throws Exception If there is an error while saving the message.
 */
public function saveMessage($name, $email, $message)
{
    $sql = "INSERT INTO messages (name, email, message, ip) VALUES (?, ?, ?, ?)";
    $stmt = $this->link->prepare($sql);
    $stmt->bind_param("ssss", $name, $email, $message, $_SERVER["REMOTE_ADDR"]);
 
    if ($stmt->execute()) {
        return $stmt->insert_id;
    } else {
        throw new Exception("Not Sent");
    }
}

Next, we will implement a controller as well as some simple routing logic. To start the controller off, we can declare a member variable for the model and use dependency injection again for the singleton MessageModel class this time.

private $model;
 
/**
 * MessageController constructor.
 *
 * @param MessageModel $model The message model.
 */
public function __construct(MessageModel $model)
{
    $this->model = $model;
}

Next, we wrap the main controller logic in a handler function. We set some CORS headers and use php://input to consume the JSON body that is sent to our controller. Since this controller is meant to only work with post requests, we set our CORS headers and throw an exception if someone tries sending another type of request.

/**
 * Handle the incoming request.
 *
 * @return void
 */
public function handleRequest()
{
    header("Access-Control-Allow-Origin: *");
    header("Content-Type: application/json");
    header("Access-Control-Allow-Methods: POST");
    header("Access-Control-Allow-Headers: Access-Control-Allow-Headers,Content-Type,Access-Control-Allow-Methods, Authorization, X-Requested-With");
 
    $response = [];
    $data = json_decode(file_get_contents("php://input"));
 
    try {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            throw new Exception("Invalid request method. Only POST method is allowed.");
        }

To continue our controller, we use the previously mentioned functions from the model to check the last request time for each IP. If someone attempts to send a message more than once every 3 seconds, we respond with an error. After this check is complete, we update the last request time with the current time for that IP.

// Check for invalid message spam.
$ip = $_SERVER['REMOTE_ADDR'];
$lastRequestTime = $this->model->getLastRequestTime($ip);
 
if ($lastRequestTime !== null) {
	$timeDiff = time() - $lastRequestTime;
	if ($timeDiff < 3) {
		// Less than 3 seconds since the last request, rate limit exceeded
		throw new Exception("Try Again");
	}
}
 
$this->model->updateLastRequestTime($ip, time());

We can now proceed to check that the message data is valid and sanitize it. We check one last time against abuse by ensuring that a single IP address hasn't sent more than 10 messages. If they havent, we go ahead and store the sanitized message in the database and respond with a 201 success code. If the message data is invalid or malformed, we respond with a 400 error code. If any other issues are present with the request, we respond with an Internal Server Error (code 501).

if ($this->model->isValidMessageData($data)) {
        $sanitizedData = $this->model->sanitizeMessageData($data);
 
        // Check for valid message spam.
        $count = $this->model->getSpamCount();
        if ($count >= 10) {
            http_response_code(403);
            $response = [
                "status" => "error",
                "message" => "Too Many Messages",
            ];
        } else {
            // If less than 10 messages sent from IP, save their message.
            $messageId = $this->model->saveMessage($sanitizedData->name, $sanitizedData->email, $sanitizedData->message);
            http_response_code(201);
            $response = [
                "status" => "success",
                "message" => "Message Sent Successfully",
                "message_id" => $messageId,
            ];
        }
    } else {
        http_response_code(400);
        throw new Exception("Not Sent");
    }
 
    echo json_encode($response);
    exit();
 
} catch (Exception $e) {
    http_response_code(500);
    $response = [
        "status" => "error",
        "message" => $e->getMessage(),
    ];
    echo json_encode($response);
    exit();
}

This wraps up the database connection, model, and controller. We can now define a simple route somewhere on our server that uses this controller. Here, we tie the controller and model together step by step. First, we pass a $Config instance to a $MessageModel instance. Then, we assign that model to our $MessageController instance. Finally, we call the request handler in our controller to wrap things up.

<?php
require_once "../controllers/MessageController.php";
require_once "../db/Config.php";
require_once "../models/MessageModel.php";
 
$config = new Config();
 
// Create an instance of the MessageModel
$messageModel = new MessageModel($config);
 
// Create an instance of the MessageController
$messageController = new MessageController($messageModel);
 
// Handle the incoming request
$messageController->handleRequest();
?>

You can setup more advanced routing if you wish, but this simple PHP file works as a starting point.

To summarize, we created a MVC architecture using vanilla PHP whilst adhering to good programming practices:

I hope that this post was insightful in terms of creating REST APIs and backends using the MVC approach from scratch!

© Kevin Siraki.RSS