I build a simple REST API in Object oriented PHP. It can be found on GitHub here. I started off with table structure and tutorial from Medium’s article Rest Api In OOP PHP Step By Step.
The app:
- is built on a MySQL database on localhost using MAMP free version
- uses Object Oriented PHP
- uses PHP’s PDO library
REAST API’s security
The app is not live on the internet, since it is not yet secure enough. It uses two basic security measures:
- Input validation: All inputs are pre-processed to prevent SQL injection and other security vulnerabilities:
$this->name = htmlspecialchars(strip_tags($data->name)); // ...
- Usage of prepared statements: Prepared statements are used when querying the database to prevent SQL injection attacks:
$query = "INSERT INTO {$this->table_name} SET name=:name, description=:description, created=:created"; $stmt = $this->conn->prepare($query); // bind values $stmt->bindParam(":name", $this->name); $stmt->bindParam(":description", $this->description); $stmt->bindParam(":created", $this->created); // execute query return $stmt->execute();
Other security measures are yet to be implemented
- Authentication: Implement an authentication mechanism to ensure that only authorized users can access the API.
- Authorization: Define roles and permissions for users, and restrict access to API endpoints accordingly.
- Use of SSL/TLS: Secure the API by using SSL/TLS to encrypt all data sent between the client and server.
Setting up workspace
To run the program, first a database has to be created and its credentials put into .env file, located in the root of the project, which is NOT included in GIT . In my case .env is:
DB_HOST = localhost DB_PORT = 8888 DB_USER = ... DB_PASSWORD = ... DB_NAME = api_db
The connection to the database is established in the database.php file:
<?php namespace Config; use PDO; require_once realpath(__DIR__ . "/vendor/autoload.php"); use Dotenv\Dotenv; $dotenv = Dotenv::createImmutable(__DIR__); $dotenv->load(); class Database{ private $conn = null; public function __construct(){ try{ $this->conn = new PDO("mysql:host=" . $_ENV['DB_HOST'] . ";dbname=" . $_ENV['DB_NAME'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD']); $this->conn->exec("set names utf8"); } catch(\PDOException $exception){ echo "Connection error: " . $exception->getMessage(); } } public function getConnection(){ return $this->conn; } }
For the environment variables to work – $_ENV[‘DB_HOST’], …, we have to install the appropriate dependency FIRST:
composer require vlucas/phpdotenv
We must not forget to set up the .gitignore file before pushing to a public repository Gitab, or GitHub:
vendor/ .env
To create tables in our database, there is a dbseed.php file. It is run once, to create tables categories and products. If the tables have already been created but we have messed them up and want a fresh start, we have to drop them first. So here is the file dbseed.php (the full file can be found on GitHub):
<?php // drop datbases products and categories and then run: php dbseed.php use Config\Database; require_once "database.php"; $statements = [ "CREATE TABLE IF NOT EXISTS `categories` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(256) NOT NULL, `description` text NOT NULL, `created` datetime NOT NULL, `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=19", "INSERT INTO //... ", "CREATE TABLE IF NOT EXISTS `products` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(32) NOT NULL, `description` text NOT NULL, `price` decimal(10,0) NOT NULL, `category_id` INT(11) NOT NULL, `created` datetime NOT NULL, `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=19", "INSERT INTO ... // ... "]; try { $database = new Database(); $dbConnection = $database->getConnection(); foreach($statements as $statement){ $dbConnection->exec($statement); } echo "Success!\n"; } catch (\PDOException $e) { exit($e->getMessage()); }
The database structure is:
categories

products

With produtcs referencing catogories by the foreign key category_id:
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`)
MVC structure of the application
This API has the MVC (Model-Controller-View) architecture. There is no VIEW layer now. The app has to be tested using e.g. Postman.

The file structure is:

Model
The Model files communicate directly with the database. The manipulate data in the products and categories db tables. This is a part the controller for products table products.php. We use the PDO library (PHP Data Objects). For products we also retrieve the name of the corresponding category.
<?php namespace Models; use Config\Database; use PDO; class Product{ // database connection and table name private $conn; private $table_name = "products"; // object properties public $id; public $name; public $description; public $price; public $category_id; public $category_name; public $created; public function __construct(){ $database = new Database(); $this->conn = $database->getConnection(); } // create product function create($data){ //create product if (!empty($data->name) && !empty($data->price) && !empty($data->description) && !empty($data->category_id)) { $this->name = htmlspecialchars(strip_tags($data->name)); $this->price = htmlspecialchars(strip_tags($data->price)); $this->description = htmlspecialchars(strip_tags($data->description)); $this->category_id = htmlspecialchars(strip_tags($data->category_id)); $this->created = date('Y-m-d H:i:s'); try{ // query to insert record $query = "INSERT INTO {$this->table_name} SET name=:name, price=:price, description=:description, category_id=:category_id, created=:created"; $stmt = $this->conn->prepare($query); // bind values $stmt->bindParam(":name", $this->name); $stmt->bindParam(":price", $this->price); $stmt->bindParam(":description", $this->description); $stmt->bindParam(":category_id", $this->category_id); $stmt->bindParam(":created", $this->created); // execute query return $stmt->execute(); } catch(\PDOException $e) { http_response_code($e->getCode()); echo json_encode(array("message" => "Create failed: {$e->getMessage()}")); return false; // Or log the error instead of displaying it } } //if else{ http_response_code(400); //bad request echo json_encode(array("message" => "Unable to create product. Data is incomplete")); return false; } } function readOne($productId){ $this->id = htmlspecialchars(strip_tags($productId)); try{ $query = "SELECT c.name as category_name, p.id, p.name, p.description, p.price, p.category_id, p.created FROM {$this->table_name} p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id=:p_id LIMIT 0,1"; $stmt = $this->conn->prepare($query); $stmt->bindParam("p_id", $this->id); $stmt->execute(); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row != NULL) { $this->name = $row['name']; $this->price = $row['price']; $this->description = $row['description']; $this->category_id = $row['category_id']; $this->category_name = $row['category_name']; $product_item = array ( "id" => $this->id, "name" => $this->name, "description" => $this->description, "price" => $this->price, "category_id" => $this->category_id, "category_name" => $this->category_name ); http_response_code(200); return json_encode($product_item); } else{// set response code - 404 Not found http_response_code(404); echo json_encode(array("message" => "Product {$productId} not found.")); return false; } } catch(\PDOException $e) { // Handle error http_response_code($e->getCode()); echo json_encode(array("message" => "Query failed: {$e->getMessage()}")); die(); // Or log the error instead of displaying it } } // rest on GitHub ?>
I have a similar category.php file.
Controller
For the Controller layer, there are two endpoints:
- /restapi/products/api.php
- /restapi/categories/api.php
The standard CRUD (Create-Read-Update-Delete) operations are supported.
CRUD operations for products
CREATE a product
POST /restapi/product/api.php
Where body contains a product in JSON format, e.g.
{ "name": "LG P880 4X HD", "description": "My first awesome phone!", "price": "336", "category_id": 3}
All parameters are required.
READ a product
Here we have three pssible routes implemented.
Route one – read all products
GET /restapi/product/api.php
All products are listed.
Route two – read a specific product
GET /restapi/product/api.php/{id}
The product with given id is listed. We are checking if {id} represents a number and also whether the given product exists.
Route three – read a all products from a category
GET /restapi/product/api.php?category={id}
UPDATE a product
PUT /restapi/product/api.php
With body in JSON format. All params are required for simplicity. We could change it by treating the data in the controller layer.
{"id" :3, "name": "LG P880 4X HD", "description": "My first awesome phone!", "price": "336", "category_id": 3}
DELETE a product
DELETE /restapi/product/api.php/{id}
Porduct’s Controller
The productController.php file is:
<?php namespace Controller; use Models\Product; include_once __DIR__.'/../models/product.php'; class productController{ private $requestMethod; private $productId; private $categoryId; private $data; public function __construct ($requestMethod, $productId, $data, $categoryId){ $this->requestMethod = $requestMethod; $this->productId = $productId; $this->data = $data; $this->categoryId = $categoryId; } public function processRequest(){ switch ($this->requestMethod) { case 'GET': if ($this->productId) { $this->handleGetOne(); } else{ if ($this->categoryId){ $this->handleGetByCategory(); } else { $this->handleGet(); } } break; case 'POST': $this->handlePost(); break; case 'DELETE': $this->handleDeleteOne($this->productId); break; case 'PUT': $this->handleUpdate(); break; //we expect /product/{id} to retrieve single product with the corresponding id default: echo json_encode(['message' => 'Invalid request method']); break; } } private function handleGet(){ $product = new Product(); echo $product->read(); } private function handleGetOne(){ $product = new Product(); echo $product->readOne($this->productId); } private function handleGetByCategory(){ $product = new Product(); echo $product->readAllProductsFromCategoryNotStatic($this->categoryId); } private function handlePost(){ $product = new Product(); if ($product->create($this->data)){ http_response_code(201); //created echo json_encode(array('Message' => "Product created")); } } private function handleUpdate(){ $product = new Product(); if ($product->update($this->data)) { http_response_code(200); echo json_encode(array('Message' => "Product updated"));} } private function handleDeleteOne(){ $product = new Product(); if ($product->deleteOne($this->productId)) { http_response_code(200); echo json_encode(array('Message' => "Product deleted")); } else{ echo json_encode(array('Message' => "Something went wrong;")); } } }
it is called by the api.php file located in the /restapi/products/ directory:
<?php use Controller\productController; require_once __DIR__.'/../database.php'; require_once 'productController.php'; header("Access-Control-Allow-Origin: *"); header("Content-Type: application/json; charset=UTF-8"); header("Access-Control-Allow-Methods: OPTIONS,GET,POST,PUT,DELETE"); header("Access-Control-Max-Age: 3600"); header("Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With"); $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $uri = explode( '/', $uri ); $data = json_decode(file_get_contents('php://input')); $requestMethod = $_SERVER["REQUEST_METHOD"]; $categoryId = null; // resolve GET /restapi/product/api.php?category={id} ... list all product from category id */ // bad format, e.g. /restapi/product/api.php?ffddd={id} is ignored if (isset($_GET['category'])) { if (intval($_GET['category'])!== 0){ $categoryId = intval($_GET['category']); } else{ http_response_code(400); //bad request echo json_encode(['message' => 'Bad request expected /restapi/product/api.php?category={id} where id is an integer']); die(); } } $id = null; if (isset($uri[4])) { // if last elment is set, it must be a number = an id if (empty($uri[4]) || intval($uri[4]) !== 0) { if (intval($uri[4]) !== 0) $id = intval($uri[4]); } else{ http_response_code(400); //bad request echo json_encode(['message' => 'Bad request expected /restapi/product/api.php/{id} where id is an integer']); die(); } } try{ $controller = new productController($requestMethod, $id, $data, $categoryId); $controller->processRequest(); } catch (\PDOException $e){ echo json_encode(array("message" => $e->getMessage())); exit(); }
CRUD operations for categories
CRUD operations for categories are similar. Operations READ and DELETE are slightly different.
READ a category
When reading a category, we also list all the products in the given category and the count of the products, like:
{ "id": "1", "name": "Fashion", "description": "Category for anything related to fashion.", "created": "2014-06-01 00:35:07", "product_count": 6, "products_in_category": [ { "id": 6, "name": "Bench Shirt", "description": "The best shirt!", "price": "29" }, { "id": 9, "name": "Spalding Watch", "description": "My sports watch.", "price": "199" }, { "id": 12, "name": "Abercrombie Lake Arnold Shirt", "description": "Perfect as gift!", "price": "60" }, { "id": 13, "name": "Abercrombie Allen Brook Shirt", "description": "Cool red shirt!", "price": "70" }, { "id": 31, "name": "Amanda Waller Shirt", "description": "New awesome shirt!", "price": "333" }, { "id": 60, "name": "Rolex Watch", "description": "Luxury watch.", "price": "25000" } ] }
DELETE a category
Here, only an EMPTY category can be deleted.
Other sources
I have also used articles:
- to alter the program to adhere better to OOP principles using: https://dev.to/arafatweb/building-a-php-crud-application-with-oop-and-mysql-a-best-practice-guide-19p
- to implement the MCV structure: https://developer.okta.com/blog/2019/03/08/simple-rest-api-php#add-a-gateway-class-for-the-person-table
- to use .env file: https://udoyhasan.medium.com/what-is-an-env-file-and-how-to-use-an-env-file-in-php-4e146358cca6
The app can be found in GitHub here.
