Advanced
This section will dive deeper into each aspect and feature of the mini-framework, to understand how everything works to get started.
API
Most APIs requires an endpoint for each operation. Let's take a simple case of CRUD methods on a `user` table. Since we have 4 methods, we will require 4 endpoints for example: `/user/create`, `/user/show`, `/user/edit`, `/user/delete`.
For testing and learning purposes you can run the following command:
composer migrate:apply
If you had already configured your database, this will create a `user` table automatically through migration (more on that later).
The API is setup in a way to have a single endpoint, and the method requested is sent with the data.
Any data sent through the API must be in JSON format (and any received data is in the same format).
The data must contain the method requested (and any parameters if they are required).
Here is an exemple below for creating a user:
{
"method": "create",
"params": {
"email": "user@email.com",
"password": "user123"
}
}
Any request to an API endpoint goes through a middleware app/config/Middleware.php
.
The middleware handles the request type, content type, authorization header, sanitizes and validates the data before passing it to a controller.
After multiple steps, the controller assigned to the endpoint will then take the parameters from the data sent and executes the method requested.
Let's take the `create` method for a user as an example:
// User create
public function create()
{
$user_id = Middleware::generateId();
$email = $this->validateParams('email', $this->param['email'], EMAIL);
$password = $this->validateParams('password', $this->param['password'], STRING);
$model = new UserModel;
$model->setUserId($user_id);
$model->setEmail($email);
$model->setPassword(Middleware::hash($password));
$this->result = $model->create('user');
if ($this->result == false) {
$message = 'User already exists.';
} else {
$message = "User created successfully.";
}
Middleware::returnResponse(RESPONSE_MESSAGE, $message, $this->result);
}
After validating the parameters, we create an instance of the model and assigns those parameters to the models properties with the helps of setters before executing the create method of the model.
// User creation
public function create($table)
{
$sql = "SELECT email FROM $table";
$stmt = $this->db_conn->prepare($sql);
$stmt->execute();
$emails = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!in_array($this->email, $emails)) {
$sql = "INSERT INTO $table (user_id, email, password)
VALUES (:user_id, :email, :password)";
$stmt = $this->db_conn->prepare($sql);
$stmt->bindParam(':user_id', $this->user_id);
$stmt->bindParam(':email', $this->email);
$stmt->bindParam(':password', $this->password);
if ($stmt->execute()) {
return true;
} else {
return false;
}
} else {
return false;
}
}
Sending the above mentioned JSON data to the `/user` endpoint will get a response with a code and a message:
{
"response": {
"status": 200,
"message": "User created successfully."
},
"result": true
}
Or if a user already exists:
{
"response": {
"status": 200,
"message": "User already exists."
},
"result": false
}
You can check the other methods (`readAll`, `readUnique`, `update`, `delete`), see what parameters they require and test them to get the hang of it.
While testing the `update` or `delete` method you will get the following error message:
{
"error": {
"status": 412,
"message": "Access Token Not found"
}
}
Since their methods in app/controllers/UserApiController
contains in their first line $this->validateToken();
that verifies the existence and status of the token.
Authentication is handled with the app/controllers/AuthApiController
and app/models/AuthModel
and is token based using JWT (JSON Web Tokens).
After creating a user and trying to login:
{
"method": "login",
"params": {
"email": "user@email.com",
"password": "user123"
}
}
A token will be generated:
{
"response": {
"status": 200,
"message": "Token generated successfully"
},
"result": {
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.
eyJpc3MiOiJsb2NhbGhvc3QiLCJpYXQiOjE2MzcwNzIxNjUsImV4cCI6MTYzNzA3MzA2NSwiYXVkIjoidXNlciIsImRhdGEiOnsidXNlcl9pZCI6ImM2MTM5MTIxNDk2MWIiLCJlbWFpbCI6InRlc3RAZW1haWwuY29tIn19.
O_572ENGXh_JiMiVYdPBFX4lj725W1u5yvvDfivjTvI"
}
}
Any request requiring the validation of the token must be sent with the token in the authorisation header (of type `Bearer Token`).
Tokens have an expiration time $exp = $iat + (60 * $minutes);
that can be defined in app/controllers/AuthApiController
and by default it is configured to last for 30 mins $minutes = 30;
.
The expiration time is calculated from the current time: $iat = time();
plus how many seconds it takes for it to expire.
To make it simple to configure, just replace the value of $minutes
to how many minutes you want the token to last.
If the token expires you will get the following error message:
{
"error": {
"status": 401,
"message": "Expired token"
}
}
And you will need to re-login.
Response messages can be turned off by running the following command in your terminal:
composer responses:off
You will then only get the result.
Controllers & Models
To create a controller, you can run the command:
composer make:controller
This creates both a controller and a model since they rely on each other. They will be generated based on templates, the current state of the table concerned and will contain basic CRUD methods to use or base on to create your own methods.
You will be asked several questions and the generated files will depend on your answers.
Keep in mind that creating a controller/model with this command will require an existing table (either created normally or through migration) since it needs it's name and it's columns (the table must also not have an existing controller/model already created for it).
For every controller method you can add $this->validateToken();
on the first line for token validation. This ensures the execution of the method only if the token sent is valid.
By default on every controller creation, it will be already declared in the `update` and `delete` methods.
Routing
Your routes are defined in public/index.php
:
use Dotenv\Dotenv;
use App\Application;
use App\Controller\WebController;
use App\Controller\AuthApiController;
use App\Controller\UserApiController;
require_once dirname(__DIR__) . '/composer_vendor/autoload.php';
$dotenv = Dotenv::createImmutable('../');
$dotenv->load();
$app = new Application(dirname(__DIR__));
// * WebController manages the Web side of the framework
// Web routes for views : GET
$app->router->get('/', [WebController::class, 'home']);
$app->router->get('/home', [WebController::class, 'home']);
// Web routes for views : POST
// * ApiControllers manages the API side of the framework with the Middleware
// API routes for endpoints : POST
$app->router->post('/auth', [AuthApiController::class, 'processAuth']);
$app->router->post('/user', [UserApiController::class, 'processUser']);
// Run the app and router, resolve paths and request methods and render different layout depending on callback
$app->run();
When the user requests an url and therefore a route. It is processed and checked for it's existence then for its type.
- Web route:
There are two types of routes:
Web routes are the normal basic routes for rendering a view of your application. They are required to go through the WebController
class which executes a defined method that renders a view based on the arguments passed on said method.
namespace App\Controller;
use App\Core\Request;
use App\Controller\ViewsController;
class WebController extends ViewsController
{
// Example view with params
// public function example()
// {
// $params = [
// 'key' => "value"
// ];
// return ViewsController::render('base', 'example', $params);
// }
// Home page
public function home()
{
return ViewsController::render('base', 'home');
}
}
The home
method renders the `home` view in a `base` layout (parameters can be passed optionally if needed on a certain view).
POST routes are supported, but are not recommended to be used for sensitive operations since they will require additional work and configuration. Therefore any POST request is advised to go through the API unless it is some data sent between pages and not to the database.
Web routes are managed with the app/core/Router.php
:
namespace App\Core;
use App\Core\Components;
use App\Core\Request;
use App\Core\Response;
use App\Controller\ViewsController;
class Router extends Components {
public Request $request;
public Response $response;
protected array $routes = [];
public function __construct(Request $request,Response $response)
{
$this->request = $request;
$this->response = $response;
}
public function get($path, $callback) {
$this->routes['get'][$path] = $callback;
}
public function post($path, $callback) {
$this->routes['post'][$path] = $callback;
}
public function resolve() {
$path = $this->request->getPath();
$method = $this->request->getMethod();
$callback = $this->routes[$method][$path] ?? false;
// If the route requested doesn't exist, show a 404 page
if($callback === false) {
$this->response->setStatusCode(404);
// Render the 404 page in the main layout
return ViewsController::render('base', '_404');
}
// Get the route requested from the callback
if(is_array($callback)) {
$callback[0] = new $callback[0]();
}
return call_user_func($callback, $this->request);
}
}
The app/core/Response.php
sets an HTTP status code:
namespace App\Core;
class Response {
public function setStatusCode(int $code) {
http_response_code(($code));
}
}
for example, if a route doesn't exist, an error 404 page will be shown.
Request type (GET or POST) is resolved with the help of the app/core/Request.php
namespace App\Core;
class Request {
public function getPath() {
$path = $_SERVER['REQUEST_URI'] ?? '/';
$position = strpos($path, '?');
if ($position === false) {
return $path;
}
return substr($path, 0, $position);
}
public function getMethod() {
return strtolower($_SERVER['REQUEST_METHOD']);
}
public function getBody() {
$body = [];
if($this->getMethod() === 'get') {
foreach ($_GET as $key => $value) {
$body[$key] = $value;
}
}
if($this->getMethod() === 'post') {
foreach ($_POST as $key => $value) {
$body[$key] = $value;
}
}
return $body;
}
}
The API routes are POST only, and require a specific API Controller (an authentication and user endpoints are already defined since their controller, model and the process method are already included and will be used as examples for explanations).
You can choose to use the mini-framework only for it's API and backend side since it is not tied or required to have an interface. Endpoints can't be accessed through the browser and requires JSON data as mentioned above in the API section.
When this data is sent to the /user
endpoint, after passing through the middleware, the processUser
defined in your route from the app/config/Process.php
will take care of processing the method sent (in this case the `create` method). The method is verified if it exists in the controller and then invoked and executed.
namespace App\Config;
use ReflectionMethod;
use App\Config\Middleware;
use App\Controller\AuthApiController;
use App\Controller\UserApiController;
// The Process class groups the process methods of any Controller.
class Process
{
// Process the method's name sent in the data and check if it exists in your ApiController
public function processMethods($controller)
{
$controllerObj = new $controller();
$controllerMethod = new ReflectionMethod("$controller", $this->method);
if (!method_exists($controllerObj, $this->method)) {
Middleware::throwError(METHOD_DOES_NOT_EXIST, "Method does not exist.");
}
// Then invoke said method on the object created
$controllerMethod->invoke($controllerObj);
}
//* This handles the authentication do not remove or modify
// Process Authentication Methods
public function processAuth()
{
$this->processMethods(AuthApiController::class);
}
//* Create a Process Method for each ApiController you might have
// Process User Methods
public function processUser()
{
$this->processMethods(UserApiController::class);
}
}
Any newly created controller will require the addition of it's own process method in app/config/Process.php
that will be defined in it's endpoint/route in public/index.php
.
Views
A view is basically the page that is requested by a user in the form of a route that is then served to the user to view the contents requested.
Views are handled with a components system that makes it easier to manage each block of a view in a way when a modification has been made to a certain component, it will be reflected in all views/pages where the component is present. This helps avoid repetitiveness and keeping your code DRY and manageable.
Layouts are under the app/views/layouts
folder and can be composed of static components that are present in all views using that layout and must contain a content component.
Static components are wrapped inside double brackets [[ static ]]
and are under the app/views/components/static
.
The content requested is any file under your app/views
folder and must be wrapped inside parentheses (( content ))
.
A layout can be setup like this:
<!DOCTYPE html>
<html>
<head>
[[ head ]]
</head>
<body class="base">
<header class="header">
[[ header ]]
</header>
(( content ))
<footer class="footer">
[[ footer ]]
</footer>
</body>
</html>
Any content/view file can contain plain html or dynamic components that are not meant to be present in all pages since they are not part of the layout but are proper to a certain view itself.
Dynamic components are wrapped inside double curly braces {{ dynamic }}
and are under the app/views/components/dynamic
.
A content/view can look like this:
<main class="main">
<h1>Hello World!</h1>
<section class="slider">
{{ slider }}
</section>
</main>
Let's say this view is the home view which will be app/views/home.php
and is using the above mentioned layout as app/views/layouts/base.php
and will have a route setup in public/index.php
as follow:
$app->router->get('/', [WebController::class, 'home']);
$app->router->get('/home', [WebController::class, 'home']);
And will have a view setup in app/controllers/WebController.php
like this:
public function home()
{
return ViewsController::render('base', 'home');
}
The {{ slider }}
string inside the app/views/home.php
will be automatically replaced by the app/views/components/dynamic/slider.php
file inside this content.
Then the resulted html will be included inside the layout, replacing the (( content ))
string and including any static components in the process.
All of this is done automatically by the app/core/Components.php
class that scans the files and replaces the component strings by including the respective file from their directories.
Migrations
Migrations are the process of creating/modifying tables of a database in a sequenced defined order. It offers a way to have like a version control or a history of past actions made to the database tables.
It is also very useful in the development process to quickly create tables to work with and modify them, even drop them and re-create them in case a mistake occurs when setting up your schema.
All your migration files are under app/migrations
. Two exemples are already included and set up to create a `user` table and to add another column after it is created.
namespace App\Migrations;
use App\Config\Dbh;
use App\Core\Scripts;
class m0001_create_user_table extends Dbh
{
public function migrate()
{
Scripts::log("Creating user table...");
$db_conn = $this->connect();
$stmt = $db_conn->prepare("CREATE TABLE IF NOT EXISTS user (
user_id VARCHAR(16) PRIMARY KEY NOT NULL,
email VARCHAR(32) NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL,
user_created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
user_updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)");
$stmt->execute();
}
public function drop()
{
Scripts::log("Dropping user table...");
$db_conn = $this->connect();
$stmt = $db_conn->prepare("DROP TABLE IF EXISTS user");
$stmt->execute();
}
}
You can test it while running the command:
composer migrate:apply
The above command will run all the migrations in a sequencing order defined by their prefix and id. After checking if the table has been created and modified successfully. You can run the command:
To drop all currently applied migrations. This will only delete tables created through migration. Any tables created manually will not be affected.
composer migrate:drop
To create a migration is very simple by running the following command:
composer make:migration
This will create a migration file based on a defined template, while asking for informations through user input in the terminal to customize it.
- A number (between 1 and 9999) that defines the prefix and id of the migration which also decides it's order in the queue.
- The migration type (create or update).
- Your table name which must be different for already existing tables.
- The type of id (auto-incremented or generated).
- Foreign key reference and constraints.
The informations required are:
All your created migrations will be automatically put into the app/migrations
folder and can be applied while running the composer migrate:apply
command.
Seeding
Seeders are a way of inserting predefined data to existing tables. It can either be essential static data or dummy data which is meant for testing purposes.
They are usually used in conjunction with migrations, but are not tied to them.
All seeders are under the app/seeders
folder. A UserSeeder
class is already included and can be tested right after your create you `user` table.
namespace App\Seeders;
use App\Config\Middleware;
use App\Config\Seeders;
class UserSeeder
{
public static function seedUser()
{
// Data that will be seeded to the table, If you add any column into your table be sure to add its key and value or else it wil be seeded as 'NULL'
Seeders::create('user',
[
'user_id' => Middleware::generateId('admin'),
'email' => 'admin@email.com',
'password' => Middleware::hash('admin'),
'fullname' => '',
]);
Seeders::create('user',
[
'user_id' => Middleware::generateId('user'),
'email' => 'user@email.com',
'password' => Middleware::hash('user'),
'fullname' => '',
]);
}
}
To run the UserSeeder
, you can run the command:
composer seed:user
To create a seeder, you must run the command:
composer make:seeder
- Seeders require the table specified to already exist and to not have an existing seeder.
- You will be asked if the table id is auto-incremented or generated.
- You will be offered an option to import table columns as a key value pair which is recommended.
- For any new seeder created to run individually, a custom command must be created under the `scripts` section in
composer.json
. You can use theUserSeeder
as an example. - Alternatively, you can run all seeders or just selected ones in a defined order under
app/seeders/DatabaseSeeder.php
:
namespace App\Seeders;
use App\Seeders\UserSeeder;
class DatabaseSeeder
{
// To seed all to the database, add any new created seeder below
public static function seedDatabase()
{
UserSeeder::seedUser();
}
}
To run all seeders, run the following command:
composer seed:all
composer migrate:apply:seed