Embracing the Challenge: Building My PHP Framework from Scratch

Photo by Nicole Wolf on Unsplash

Embracing the Challenge: Building My PHP Framework from Scratch

Introduction

For the longest time, I harbored a deep-seated desire to create my own PHP framework, a personal venture that always seemed just out of reach due to self-doubt and the lingering effects of imposter syndrome. Despite years of experience, there was always something holding me back. The opportunity finally presented itself when I found myself tasked with revamping an entire application for a project, armed with only SFTP access and no availability of Composer. The previous developers had left behind a chaotic tangle of spaghetti code that needed a complete overhaul.

As I anticipated becoming the head of the dedicated unit for this project in the future, I foresaw the need for others, potentially young and inexperienced, to collaborate on it. Given the lack of stringent time constraints and the complete freedom to innovate, I saw this as the perfect opportunity to build something not only usable by everyone with minimal context but also to guide future colleagues down a path that would make long-term collaboration feasible. This was my chance to craft a framework that struck a balance between accessibility and a structure that would withstand the test of time.

This article is not going to be an easy one, there is a lot of stuff to cover and I am not going into the details of the basics, so if you are a beginner in PHP you may encounter many challenges, you may read it and find inspiration to improve in some arguments tough.

The requirements

Here I will gather what I wanted to come up with at the end of the day.

  1. I was forced not to use composer, but I wanted to use namespacing.

  2. I wanted my framework to be light and very easy to setup and use.

  3. It had to be opinionated and more strict as possible.

  4. I wanted to respect MVC pattern but also have the possibility to have json responses for API based projects.

  5. My project has to be similar to Laravel and Symfony, taking the best of both of them.

With this in mind, I hope I was able to get your attention, and I hope you will follow me in this challenge.

Project's structure and rewrite rules

The most important thing of a new framework is the structure we want to give to the different folders, what they will contain, how to access them, etc.

Another important thing is the security issues we may encounter, and also how much easy is to understand the structure properly. I came out with this:

Screenshot 2024-01-29 120423.png

The server is configured in order to point to the public folder, this is the only accessible one.

The most important thing is that in the public folder we will put only the index.php file, and an .htaccess file, doing something different could lead to potential security issues, because other folders could be accessible from the outside, we don't want that to happen.

This is a simple .htaccess file you can put in the public folder.

 # Enable the rewriting engine
 RewriteEngine on
 # Check if the requested filename is not a regular file
 RewriteCond %{REQUEST_FILENAME} !-f
 # Check if the requested filename is not a directory
 RewriteCond %{REQUEST_FILENAME} !-d
 # Rewrite the URL to pass the path as a query parameter to index.php
 RewriteRule ^(.*)$ /index.php?path=$1 [NC,L,QSA]

you may want to read these very good post to get used to the Rewrite syntax.

From now on, every request will be redirected to the index.php file in the public folder.

We will need some kind of router to give proper response then, we will return on it later, let us first explore the other folders.

The Configuration folder contains the app configuration file, like database credentials, tokens, and other informations.

Core is the store folder for all the basic futures like routing, utility functions, validations, etc. You can see it as the actual kernel of the framework.

Controllers and Views will contain the controllers and the html files if we decide that we want to use this framework in an MVC project, but we could also decide to leave the view folder completely empty and use this project only for APIs.

We will return lately on the other parts.

Namespacing and autoload

Usually, composer would be in charge to take care of autoloading classes and giving you access to namespacing, but as I mentioned before, we will not be allowed to use composer here, only vanilla allowed here.

Luckily, php has a very useful function for that, the infamous spl_autoload_register function, you may want to read the php documentation online, just google it. In order to use that, let us create the index.php and a functions file:

public/index.php
<?php

const BASE_PATH = __DIR__.'/../';

require BASE_PATH."Core/functions.php";

spl_autoload_register(function($class){
    $class = str_replace('\\', DIRECTORY_SEPARATOR, $class);

    require base_path($class.".php");
});
Core/functions.php
<?php

/**
 * get the base path
 */
function base_path($path)
{
    return BASE_PATH.$path;
}

This should be enough, if you try to access to different routes you should always be redirected to the index.php file, without errors or exceptions.

The Router

I wanted a pretty basic but extensible router, this was a functionality in which I did not wanted to lose much time, of course there are better solutions out there, but I am pretty satisfied with mine. Remember that now we are allowed to (and should) use the namespacing.

Core/Router.php
<?php

//set the namespace
namespace Core;

class Router
{
    // Array to store the routes
    protected $routes = array();

    // Add a new route to the routes array
    protected function addRoute(string $uri, string $controller, string $method)
    {
        $this->routes[] = array(
            "uri" => $uri,
            "controller" => $controller,
            "method" => $method,
        );
    }

    // Add a GET route
    public function get(string $uri, string $controller)
    {
        $this->addRoute($uri, $controller, "GET");
    }

    // Add a POST route
    public function post(string $uri, string $controller)
    {
        $this->addRoute($uri, $controller, "POST");
    }

    // Add a PATCH route
    public function patch(string $uri, string $controller)
    {
        $this->addRoute($uri, $controller, "PATCH");
    }

    // Add a PUT route
    public function put(string $uri, string $controller)
    {
        $this->addRoute($uri, $controller, "PUT");
    }

    // Add a DELETE route
    public function delete(string $uri, string $controller)
    {
        $this->addRoute($uri, $controller, "DELETE");
    }

    // Route the request to the appropriate controller
    public function route(string $uri)
    {
        $method = $_SERVER['REQUEST_METHOD'];
        $selectedRoute = null;
        foreach ($this->routes as $route) {
            if($route['uri'] === $uri) {
                // Set the selected route
                $selectedRoute = $route;
            }
        }
        // If no valid route is selected, throw a 404 error
        if($selectedRoute === null) {
            throw new \Exception("Error 404. ", 1);
        }

        // If the method is not allowed, throw an exception
        if($selectedRoute['method'] !== $method) {
            throw new \Exception("Method not allowed", 1);
        }

        // Require and return the selected controller
        return require(base_path($selectedRoute['controller']));
    }

}

that's it, now we can register routes in our index.php file:

public/index.php
<?php
use Core\Router;

const BASE_PATH = __DIR__.'/../';

require BASE_PATH."Core/functions.php";

spl_autoload_register(function($class){
    $class = str_replace('\\', DIRECTORY_SEPARATOR, $class);

    require base_path($class.".php");
});

//defining routes
$router = new Router();
$router->post("/test", "Controllers/TestController.php");
$router->get("/home", "Controllers/HomeController.php");

//route interpreter
$uri = parse_url($_SERVER['REQUEST_URI'])['path'];
//resolve request
$router->route($uri);

registering a new route will be very easy, we just need to add a new line, the only caveat is that no equal path is allowed for different methods, you may want to tweak the code a little bit if you need this feature, to me it was completely fine.

$router->method("/route", "Controllers/path_to_controller");

Controllers

We all know what controllers are used for, they will be in charge for the logic of the application. They usually query data, work on them, do calculations, and present them in a format suitable for the app.

The main choice is to decide if we prefer a controller to be in charge for a single use, or if we want to split them in different methods available for different routes. I went for the first choice, because i think it will generate less confusion and is less bug prone.

As you may see in the Router class, after a controller is registered and coupled to an endpoint, it is available to the route() method, this will require and execute the correct file.

Response

The idea behind a backend is of course to provide a response, be it an HTML file, a csv, an xml, etc. We will firstly focus on every format except the HTML file, that is going to be treated in it's paragraph.

I created the in the Core namespace the Response class, I'll provide the code below.

<?php
namespace Core;

class Response 
{
    // Set the HTTP response code
    public function status($statusCode)
    {
        http_response_code($statusCode);
    }

    // Set the response header as JSON and print the data as JSON
    public function jsonResponse($data)
    {
        header('Content-Type: application/json');
        echo json_encode($data);
    }

    // Set the response header as CSV and print the data as CSV
    public function csvResponse($data, $includeHeaders = false)
    {
        header('Content-Type: text/csv');
        if ($includeHeaders) {
            $headers = array_keys($data[0]);
            $output = fopen('php://output', 'w');
            fputcsv($output, $headers);
            foreach ($data as $row) {
                fputcsv($output, $row);
            }
            fclose($output);
        } else {
            $output = fopen('php://output', 'w');
            foreach ($data as $row) {
                fputcsv($output, $row);
            }
            fclose($output);
        }
    }
}

the Response class is a good way to abstract this important feature of my framework, it provides an elegant way to send a response to the client. Look how easy is to do it:

<?php
use Core\Response;

// create people's array
$data = array(
    array("firstname" => "Mario", "name" => "Rossi", "age" => 30),
    array("firstname" => "Anna", "name" => "Verdi", "age" => 25),
    array("firstname" => "Luca", "name" => "Bianchi", "age" => 28)
);

$response = new Response();
$response->status(200);
$response->jsonResponse($data);
exit();

if we use the csvResponse method we will obtain a csv file.

Views

Often in traditional PHP code, loading a view after executing control logic involves using the include or require statement. However, this practice can become cluttered and less flexible as the application grows in complexity.

To simplify this operation and improve code modularity and maintainability, a utility function like view() can be created.

Core/functions.php
<?php
/**
 * get the base path
 */
function base_path($path)
{
    return BASE_PATH.$path;
}

/**
 * returns the view
 */
function view(string $path, array $params=[])
{
    //extract the params so that they are accessible in the view
    extract($params);
    return require(base_path("Views/".$path.".php"));
}

This function accepts the path of the view to load and, optionally, an array of parameters that will be made available within the view itself.

The extract() function is used within view() to extract variables from the parameters array, making them directly accessible within the view. In practice, extract() transforms each key of the array into a variable, with the key itself serving as the variable name, and assigns the corresponding value to that variable.

Models and Database

The last argument we have to cover, is the database. One of the most clean and reusable approach is the Repository Service Pattern. I won't cover the topic here, I will just cover how i implemented it using also the Models approach.

This lead us to an important subject: the configuration. There are many approaches that we can take, the most important thing is to create a way to access the configurations we need but to never expose the informations outside. One of the best approach is to use environment variables, so I created a Configuration class like this:

Configuration/Configuration.php
<?php

namespace Configuration;

class Configuration
{

    public function get()
    {
        return [
            "database" => [
                "username" => getenv('DB_USERNAME') ?: 'put here a default username if you want',
                "port" => getenv('DB_PORT') ?: 3306,
                "password" => getenv('DB_PASSWORD') ?: '',
                "host" => getenv('DB_HOST') ?: '127.0.0.1',
                "db_name" => getenv("DB_NAME") ?: "db_name",
            ]
        ];
    }
}

Next, the Database class to provide an abstraction layer:

<?php

namespace Core;
use Configuration\Configuration;

class Database
{
    protected static $connection;

    public function __construct()
    {
        if(static::$connection === null) {
            $config = new Configuration();
            $config = $config->get();
            $username = $config['database']['username'];
            $password = $config['database']['password'];
            $host = $config['database']['host'];
            $port = $config['database']['port'];
            $db_name = $config['database']['db_name'];

            $dsn = "mysql:host=$host;port=$port;dbname=$db_name";

            $options = [
                \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
                \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
            ];
            static::$connection = new \PDO($dsn, $username, $password, $options);
        }
    }

    public function query($queryString)
    {
        $statement = static::$connection->query($queryString);
        return $statement;
    }

    public function prepare($queryString)
    {
        $statement = static::$connection->prepare($queryString);
        return $statement;
    }

    public function lastInsertId()
    {
        return static::$connection->lastInsertId();
    }

    public function beginTransaction()
    {
        static::$connection->beginTransaction();
    }

    public function commit()
    {
        static::$connection->commit();
    }

    public function rollBack()
    {
        static::$connection->rollBack();
    }
}

Models

I find that using getters and setters on models is a bit verbose, I prefer the Laravel approach, of course is up to you which one you prefer, in my case I used the same approach of Laravel, so by using magic methods.

Another thing I wanted, was to be sure that if someone tries to get or set a property that was not correspondent to a column on the database would lead to an error.

I came up with this class:

<?php

namespace Models;
use Core\Database;

class Model
{
    //informations needed in every model
    protected $tablename;
    protected $primaryKey;

    public function getClassName()
    {
        return static::class;
    }

    public function __set($name, $value)
    {
        $className = self::getClassName();
        throw new \InvalidArgumentException("Property \"$name\" does not exists in Model $className.");
    }

    public function __get($name)
    {
        throw new \InvalidArgumentException("Property \"$name\" does not exists in Model $className.");
    }
}

Now, everytime we create a table, we can have the correspondent model class, and we are forced to implement the most important informations, like the name of the primary key, the table name, and the column names.

We will work with a User class as an example:

<?php

namespace Models;

class User extends Model
{
    public $id;
    public $email;
    public $first_name;
    public $last_name;
    public $role;
    public $email_verify_token;
    public $email_token_date;
    public $email_verified_at;
    public $last_login;
    public $password;
    public $password_reset_token;
    public $password_reset_token_at;
    public $created_at;
    public $updated_at;
}

Repository Service Pattern and Traits

I decided for my own implementation of the Repository Service Pattern, we will follow the example with the User model used before.

I won't cover the pros and cons of this programming standard, if you are not used to it, I friendly invite you to read some great articles about it that you may find on the web, I also wanted to split my code in different files, in order not to have duplications in every repository.

To do that, I had to use Traits, I find them very useful and I genuinely invite everyone who wants to write clean code to have a read at the documentation of PHP and get used to this feature.

Let's start with the read operations on the database, the first thing we need in the pattern, in an interface for these operations:

<?php

namespace Repositories;
use Models\Model;

interface RepositoryInterface
{
    public function all() : array;
    public function find(string $value) : ?Model;
    public function findMany(array $value) : array;
}

Everytime we want to create another table in the database, now, we will have to create the corresponding model, with all the fields listed in the class, let us for example create another class, the Migration model:

<?php

namespace Models;

class Migration extends Model
{
    public $id;
    public $migration;
    public $batch;
}

then, we need to create an interface that extends the general one, this one is in charge only for the entity on the database, so we will create the UserRepositoryInterface and the MigrationRepositoryInterface classes:

<?php

namespace Repositories;
use Repositories\RepositoryInterface;
use Models\Model;

interface UserRepositoryInterface extends RepositoryInterface
{

}
<?php

namespace Repositories;
use Repositories\RepositoryInterface;
use Models\Model;

interface MigrationRepositoryInterface extends RepositoryInterface
{

}

these interfaces may also contain other methods, that may be useful only for some of our database entities, usually I don't see any reason to provide more methods than just the simple CRUD operations, but never say never.

Next, we have the actual last implementation of the repository.

In my project we had to deal only with an SQL database, but if someday we will have also to deal with Users or Patients scattered in, let's say as an example, an SQL and a MONGO database, the only thing that we have to do, will be to extend the UserRepositoryInterface (or the Migration one), and provide somehow the methods to access the mongo instance in the other classe, let's say the UserMongodbRepository, or the name that you may prefer.

Let us now create the repositories that will be in charge for the mysql database, we will understand after the explanation what the ModelQueriable trait is:

<?php

namespace Repositories;
use Models\User;
use Core\Database;
use Traits\ModelQueriable;

class UserRepository implements UserRepositoryInterface
{
    protected $tablename = "users";
    protected $primaryKey = "id";
    protected $model = "Models\User";
    protected $database;

    public function __construct()
    {
        $this->database = new Database();
    }

    use ModelQueriable;

}
<?php

namespace Repositories;
use Models\User;
use Core\Database;
use Traits\ModelQueriable;

class MigrationRepository implements MigrationRepositoryInterface
{
    protected $tablename = "migrations";
    protected $primaryKey = "id";
    protected $model = "Models\Migration";
    protected $database;

    public function __construct()
    {
        $this->database = new Database();
    }
    use ModelQueriable;
}

Implement services and traits

Using traits in PHP offers several advantages. Firstly, traits allow for code reusability by enabling developers to define methods that can be reused across multiple classes without inheritance limitations. This promotes cleaner and more modular code, as common functionality can be encapsulated within traits and easily included wherever needed.

One good example is the fact that we may want to add the read functions (read all, read many, read by id) to different repositories, but not everyone of them, in a clean way. Thast's why I created a Trait for that.

<?php

namespace Traits;
use Models\Model;

trait ModelQueriable
{
    public function all(): array
    {
        $result = array();
        //execute the query
        $statement = $this->database->query("select * from $this->tablename");
        $statement->execute();
        $data = $statement->fetchAll();

        //instanciate and return a set of models
        foreach ($data as $row) {
            $record = new $this->model();
            foreach ($row as $column => $value) {
                $record->$column = $value;
            }
            $result[] = $record;
        }
        return $result;
    }

    public function find(string $id) : ?Model
    {
        $table = $this->tablename;
        $key = $this->primaryKey;
        $statement = $this->database->prepare("SELECT * FROM $table WHERE $key = :id");
        $statement->bindValue(':id', $id);
        $statement->execute();
        $data = $statement->fetchAll();
        if(empty($data[0])) {
            return null;
        }
        $record = new $this->model();
        foreach ($data[0] as $column => $value) {
            $record->$column = $value;
        }
        return $record;
    }

    public function findMany(array $ids): array
    {
        $table = $this->tablename;
        $key = $this->primaryKey;
        $idsString = implode(",", $ids);
        $sql = "SELECT * FROM $table WHERE $key IN ($idsString)";
        $statement = $this->database->query($sql);
        $statement->execute();
        $data = $statement->fetchAll();

        $result = array();
        foreach ($data as $row) {
            $record = new $this->model();
            foreach ($row as $column => $value) {
                $record->$column = $value;
            }
            $result[] = $record;
        }
        return $result;
    }
}

Everytime we use the trait, it will be like doing a sort of ctrl+c and ctrl+v but without the duplication problems.

Now, we can use the services to interact with the repositories:

<?php

namespace Services;
use Models\User;
use Repositories\UserRepository;

final class UserService
{
    protected static $UserRepository;

    protected static function initialize()
    {
        self::$UserRepository = new UserRepository();
    }

    public static function find(string $id): ?User
    {
        if(self::$UserRepository == null) {
            self::initialize();
        }
        return self::$UserRepository->find($id);
    }

    public static function all(): array
    {
        if(self::$UserRepository == null) {
            self::initialize();
        }
        return self::$UserRepository->all();
    }

    public static function findMany(array $ids) : array
    {
        if(self::$UserRepository == null) {
            self::initialize();
        }
        return self::$UserRepository->findMany($ids);
    }
}
<?php

namespace Services;
use Models\Migration;
use Repositories\MigrationRepository;

final class MigrationService
{
    protected static $MigrationRepository;

    protected static function initialize()
    {
        self::$MigrationRepository = new MigrationRepository();
    }

    public static function find(string $id): ?User
    {
        if(self::$MigrationRepository == null) {
            self::initialize();
        }
        return self::$MigrationRepository->find($id);
    }

    public static function all(): array
    {
        if(self::$MigrationRepository == null) {
            self::initialize();
        }
        return self::$MigrationRepository->all();
    }

    public static function findMany(array $ids) : array
    {
        if(self::$MigrationRepository == null) {
            self::initialize();
        }
        return self::$MigrationRepository->findMany($ids);
    }
}

now we can execute queries in our controllers:

<?php
use Core\Response;
use Services\MigrationService;
use Core\Password;

$migration = MigrationService::all();

$response = new Response();
$response->status(200);
$response->jsonResponse($migration);

Conclusions

I really hope that this article was a nice read for you, if you have any advice, or complaining, please feel free to comment (kindly, I won't tolerate any rudeness, racism or insults).

Cheers.