Simple PHP REST API

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:

  1. /restapi/products/api.php
  2. /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:

The app can be found in GitHub here.