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.





