Free tutorials & notes in Hindi & English · Clean code examples 🧰 Free PDF & Image Tools →
🛠️ PHP + MySQL REST API  ·  Lesson 03

API Setup: Folders, PDO & JSON

What our API looks like

A REST API is a set of URLs that return data instead of web pages. The app sends a request, PHP runs, and we reply with JSON:

Android appapi/product.phpMySQLGET /api/product.php?id=7SELECT … WHERE id = ?row200 {"success":true,"data":{…}}
One request, one JSON answer
MethodUsed forExample
GETreading dataproducts.php?q=watch
POSTcreating / changing data (body is JSON)cart/add.php {"product_id":7,"qty":1}

We keep it simple: only GET and POST, one PHP file per endpoint. This works on every hosting without special server rules.

Folder structure

📁 backend/
  .htaccess
  database.sql
  📁 includes/            ← shared code, blocked from the browser
    .htaccess
    config.php         DB password, BASE_URL, delivery fee
    db.php             one PDO connection
    helpers.php        json_out(), input(), require_auth() …
    cart.php           cart totals + coupons (later lesson)
  📁 api/                 ← one PHP file = one endpoint
    index.php          health check
    home.php  products.php  product.php  categories.php  banners.php
    📁 auth/  cart/  wishlist/  address/  coupons/  orders/  reviews/
  📁 admin/               ← admin panel (later lesson)
  📁 uploads/             ← images (PHP disabled here)
backend/ — upload this folder to public_html/karteasy on your hosting

Step 1: config.php

All settings in one file. Each value can also come from an environment variable (getenv), which is handy for local testing — on hosting you simply edit the default values.

PHP – backend/includes/config.php
<?php
// ------------------------------------------------------------------
// KartEasy configuration. Edit these values after uploading.
// On Hostinger: hPanel -> Databases -> MySQL Databases shows the
// database name, user and host (usually "localhost").
// ------------------------------------------------------------------

define('DB_HOST', getenv('KE_DB_HOST') ?: 'localhost');
define('DB_PORT', getenv('KE_DB_PORT') ?: '3306');
define('DB_NAME', getenv('KE_DB_NAME') ?: 'karteasy');
define('DB_USER', getenv('KE_DB_USER') ?: 'karteasy_user');
define('DB_PASS', getenv('KE_DB_PASS') ?: 'change-this-password');

// Full URL of the backend folder, used to turn "uploads/x.png" into a full image URL.
// Example: https://yourdomain.com/karteasy
define('BASE_URL', rtrim(getenv('KE_BASE_URL') ?: 'https://yourdomain.com/karteasy', '/'));

define('TOKEN_DAYS', 30);          // login token validity
define('DELIVERY_FEE', 40);        // charged when subtotal is below FREE_DELIVERY_ABOVE
define('FREE_DELIVERY_ABOVE', 499);
define('DEBUG', getenv('KE_DEBUG') === '1'); // never true on a live site

date_default_timezone_set('Asia/Kolkata');
⚠️ Keep DEBUG off on the live site

With DEBUG on, real error messages (table names, SQL) are sent to the app. Useful on your computer, dangerous online.

Step 2: db.php (PDO)

PDO is PHP's database layer. We create the connection only once per request (static $pdo) and turn on three important options:

PHP – backend/includes/db.php
<?php
require_once __DIR__ . '/config.php';

/**
 * One shared PDO connection per request.
 * Prepared statements are real (not emulated) and errors throw exceptions.
 */
function db()
{
    static $pdo = null;
    if ($pdo === null) {
        $dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4';
        $pdo = new PDO($dsn, DB_USER, DB_PASS, array(
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false,
        ));
    }
    return $pdo;
}
OptionWhat it does
ERRMODE_EXCEPTIONSQL errors throw exceptions instead of silently returning false
FETCH_ASSOCrows come back as array('name' => 'Smart Watch Pro', …)
EMULATE_PREPARES falsereal prepared statements — the main protection against SQL injection

Step 3: helpers.php

Every endpoint starts with require_once __DIR__ . '/../includes/helpers.php';. This file sets JSON headers, catches all errors, and gives us small helper functions. Read it once carefully — the rest of the API is built from these pieces.

PHP – backend/includes/helpers.php
<?php
require_once __DIR__ . '/db.php';

// Every API response is JSON, and the app may call us from any origin.
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

// Any uncaught error becomes a clean JSON 500 instead of an HTML page.
set_exception_handler(function ($e) {
    error_log('[KartEasy] ' . $e->getMessage());
    $msg = DEBUG ? $e->getMessage() : 'Something went wrong. Please try again.';
    json_out(array('success' => false, 'message' => $msg), 500);
});

/** Send a JSON response and stop. */
function json_out($data, $code = 200)
{
    http_response_code($code);
    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

function ok($data = null, $message = 'OK')
{
    json_out(array('success' => true, 'message' => $message, 'data' => $data));
}

function fail($message, $code = 400)
{
    json_out(array('success' => false, 'message' => $message, 'data' => null), $code);
}

/** Only allow the given HTTP method. */
function require_method($method)
{
    if ($_SERVER['REQUEST_METHOD'] !== $method) {
        fail('Method not allowed. Use ' . $method . '.', 405);
    }
}

/**
 * Read the request body. Works for JSON bodies (Retrofit @Body)
 * and for normal form posts (@FormUrlEncoded).
 */
function input()
{
    static $data = null;
    if ($data === null) {
        $raw = file_get_contents('php://input');
        $json = json_decode($raw, true);
        $data = is_array($json) ? $json : $_POST;
    }
    return $data;
}

function in_str($key, $default = '')
{
    $d = input();
    return isset($d[$key]) ? trim((string) $d[$key]) : $default;
}

function in_int($key, $default = 0)
{
    $d = input();
    return isset($d[$key]) ? (int) $d[$key] : $default;
}

function q_int($key, $default = 0)
{
    return isset($_GET[$key]) ? (int) $_GET[$key] : $default;
}

function q_str($key, $default = '')
{
    return isset($_GET[$key]) ? trim((string) $_GET[$key]) : $default;
}

/** Return "Name is required" style errors for missing fields. */
function require_fields($fields)
{
    foreach ($fields as $key => $label) {
        if (in_str($key) === '') {
            fail($label . ' is required.', 422);
        }
    }
}

/** "uploads/p1.png" -> "https://site.com/karteasy/uploads/p1.png" */
function img_url($path)
{
    if ($path === null || $path === '') {
        return null;
    }
    if (preg_match('#^https?://#', $path)) {
        return $path;
    }
    return BASE_URL . '/' . ltrim($path, '/');
}

/** Read the Bearer token from the Authorization header. */
function bearer_token()
{
    $h = '';
    if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
        $h = $_SERVER['HTTP_AUTHORIZATION'];
    } elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
        $h = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
    } elseif (function_exists('apache_request_headers')) {
        $all = apache_request_headers();
        foreach ($all as $k => $v) {
            if (strtolower($k) === 'authorization') {
                $h = $v;
            }
        }
    }
    if (preg_match('/Bearer\s+([a-f0-9]{64})/i', $h, $m)) {
        return strtolower($m[1]);
    }
    return null;
}

/** Create a new login token for a user. Only its sha256 hash is stored. */
function create_token($userId)
{
    $token = bin2hex(random_bytes(32));
    $stmt = db()->prepare('INSERT INTO user_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)');
    $stmt->execute(array($userId, hash('sha256', $token), date('Y-m-d H:i:s', time() + TOKEN_DAYS * 86400)));
    return $token;
}

/** Returns the logged-in user row, or stops with 401. */
function require_auth()
{
    $token = bearer_token();
    if ($token === null) {
        fail('Please login to continue.', 401);
    }
    $stmt = db()->prepare(
        'SELECT u.id, u.name, u.email, u.phone, u.created_at FROM user_tokens t
         JOIN users u ON u.id = t.user_id
         WHERE t.token_hash = ? AND t.expires_at > NOW()'
    );
    $stmt->execute(array(hash('sha256', $token)));
    $user = $stmt->fetch();
    if (!$user) {
        fail('Session expired. Please login again.', 401);
    }
    $user['id'] = (int) $user['id'];
    return $user;
}

/** Shape one product row for the app (numbers as numbers, full image URL). */
function product_out($p)
{
    $price = (float) $p['price'];
    $mrp = (float) $p['mrp'];
    return array(
        'id'           => (int) $p['id'],
        'category_id'  => (int) $p['category_id'],
        'name'         => $p['name'],
        'short_desc'   => $p['short_desc'],
        'price'        => $price,
        'mrp'          => $mrp,
        'discount_pct' => $mrp > $price ? (int) round(($mrp - $price) * 100 / $mrp) : 0,
        'stock'        => (int) $p['stock'],
        'image'        => img_url($p['image']),
        'rating'       => (float) $p['rating_avg'],
        'rating_count' => (int) $p['rating_count'],
    );
}

/** Product ids in the user's wishlist, as a lookup array [id => true]. */
function wishlist_ids($userId)
{
    $stmt = db()->prepare('SELECT product_id FROM wishlist WHERE user_id = ?');
    $stmt->execute(array($userId));
    $ids = array();
    foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
        $ids[(int) $id] = true;
    }
    return $ids;
}

/** Optional login: returns the user id if a valid token was sent, else 0. */
function optional_user_id()
{
    $token = bearer_token();
    if ($token === null) {
        return 0;
    }
    $stmt = db()->prepare('SELECT user_id FROM user_tokens WHERE token_hash = ? AND expires_at > NOW()');
    $stmt->execute(array(hash('sha256', $token)));
    $id = $stmt->fetchColumn();
    return $id ? (int) $id : 0;
}

function money($n)
{
    return round((float) $n, 2);
}

/** Money for messages: ₹1,899 or ₹224.80 */
function rs($n)
{
    $n = round((float) $n, 2);
    return '₹' . number_format($n, floor($n) == $n ? 0 : 2);
}
HelperJob
json_out / ok / failsend JSON with the right HTTP status and stop
require_method('POST')reject the wrong method with 405
input(), in_str(), in_int()read the JSON body (or a normal form post)
q_str(), q_int()read ?query parameters
require_fields()"Email is required." style 422 errors
img_url()turn uploads/p7.png into a full https URL for Glide
require_auth(), create_token()login tokens (next lesson)
product_out()one consistent product JSON shape everywhere
PHP – why helpers save time
<?php
// Without helpers – every endpoint would repeat this:
header('Content-Type: application/json; charset=utf-8');
http_response_code(404);
echo json_encode(array('success' => false, 'message' => 'Product not found.', 'data' => null));
exit;

// With helpers – one line:
fail('Product not found.', 404);

One response format for everything

Every endpoint — success or error — returns the same three keys. The Android app can then use one Kotlin class (ApiResponse<T>) for all calls.

Success

  • HTTP 200
  • "success": true
  • "message": "Added to cart."
  • "data": { … }

Error

  • HTTP 401 / 404 / 409 / 422
  • "success": false
  • "message": "Only 12 left in stock."
  • "data": null
The message is always human readable: the app shows it directly in a Snackbar
StatusMeaning in KartEasy
200OK
401not logged in / token expired
403not allowed (e.g. review before delivery)
404not found
405wrong method (GET instead of POST)
409conflict: out of stock, email already used
422validation error: bad phone, missing field
500server bug (details only in the error log)

Step 4: Protect private files

Three small .htaccess files (Apache / LiteSpeed — used by Hostinger and cPanel):

Apache – backend/.htaccess
Options -Indexes

# Pass the "Authorization: Bearer ..." header to PHP (some Apache/LiteSpeed setups drop it).
RewriteEngine On
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

# The SQL file must never be downloadable.
<Files "database.sql">
  <IfModule mod_authz_core.c>
    Require all denied
  </IfModule>
  <IfModule !mod_authz_core.c>
    Deny from all
  </IfModule>
</Files>
Apache – backend/includes/.htaccess
# Nobody may open these files from a browser (they contain the DB password).
<IfModule mod_authz_core.c>
  Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
  Deny from all
</IfModule>
Apache – backend/uploads/.htaccess
# Uploaded files are images only: never run PHP from this folder.
<FilesMatch "\.(php|phtml|php3|php4|php5|php7|phar)$">
  <IfModule mod_authz_core.c>
    Require all denied
  </IfModule>
  <IfModule !mod_authz_core.c>
    Deny from all
  </IfModule>
</FilesMatch>
Options -Indexes
🔑 Why the Authorization rule?

On some shared hosts PHP never sees the Authorization: Bearer … header. The RewriteRule copies it into $_SERVER['HTTP_AUTHORIZATION'], and bearer_token() checks all three places it can appear.

Step 5: First endpoint + run it

A "health check" endpoint tells you in one look that PHP, config and the database work:

PHP – backend/api/index.php
<?php
// Open https://yourdomain.com/karteasy/api/ in a browser to check that the API and database work.
require_once __DIR__ . '/../includes/helpers.php';

$products = (int) db()->query('SELECT COUNT(*) FROM products')->fetchColumn();
ok(array(
    'name' => 'KartEasy API',
    'version' => '1.0',
    'database' => 'connected',
    'products' => $products,
    'php' => PHP_VERSION,
    'endpoints' => array(
        'GET  home.php', 'GET  categories.php', 'GET  banners.php', 'GET  products.php', 'GET  product.php?id=',
        'POST auth/register.php', 'POST auth/login.php', 'POST auth/logout.php', 'GET  auth/me.php', 'POST auth/update.php',
        'GET  cart/list.php', 'POST cart/add.php', 'POST cart/update.php', 'POST cart/remove.php',
        'GET  wishlist/list.php', 'POST wishlist/toggle.php',
        'GET  address/list.php', 'POST address/save.php', 'POST address/delete.php',
        'GET  coupons/list.php', 'POST coupons/apply.php',
        'POST orders/place.php', 'GET  orders/list.php', 'GET  orders/detail.php?id=', 'POST orders/cancel.php',
        'GET  reviews/list.php?product_id=', 'POST reviews/add.php',
    ),
), 'API is running');

Run the backend with PHP's built-in server (no XAMPP Apache needed):

Terminal
cd backend

# Use your own local DB user. The env vars override config.php (no file edit needed)
KE_DB_USER=root KE_DB_PASS= KE_BASE_URL=http://10.0.2.2:8000 KE_DEBUG=1 php -S 0.0.0.0:8000

# New terminal window:
curl http://localhost:8000/api/
Real response
GET /karteasy/api/ HTTP 200 { "success": true, "message": "API is running", "data": { "name": "KartEasy API", "version": "1.0", "database": "connected", "products": 16, "php": "8.3.6", "endpoints": [ "GET home.php", "GET categories.php", "GET banners.php", "GET products.php", "GET product.php?id=", "POST auth/register.php", "… 21 more" ] } }
🔒 localhost:8000/api/{ "success": true, "message": "API is running", "data": { "name": "KartEasy API", "database": "connected", "products": 16, "php": "8.3.6", "endpoints": [ … ] } }
❌ Seeing an error?

"Something went wrong" with HTTP 500 usually means wrong DB_USER / DB_PASS / DB_NAME. Start with KE_DEBUG=1 to see the real message. On Hostinger the host is localhost and the names have a prefix like u123456_.

PHP 7.2 friendly code

Many shared hosts still run older PHP. The whole backend was checked with PHPCompatibility for PHP 7.2+, so it runs on 7.2, 7.4, 8.0 … 8.3. We avoid newer syntax:

Avoided (needs PHP 7.4 / 8)

  • fn($x) => $x * 2
  • private int $count;
  • match ($status) { … }
  • $user?->name
  • str_contains()

Used instead (PHP 7.2 OK)

  • function ($x) { return $x * 2; }
  • private $count;
  • if / elseif or an array map
  • isset($user) ? $user->name : null
  • strpos($a, $b) !== false
Same result, wider hosting support

Frequently Asked Questions

Why plain PHP files instead of Laravel?
Plain PHP runs on every cheap shared hosting with no Composer or terminal, and you can see exactly what each line does. The same ideas (routes, validation, JSON, tokens) map directly to Laravel later.
Why ATTR_EMULATE_PREPARES = false?
It makes MySQL do real prepared statements, so values are always sent separately from the SQL. It also returns integers as integers.
What does Access-Control-Allow-Origin do for an Android app?
Nothing — Android ignores CORS. It is there so you can also call the same API from a website or a React/Next.js app later.
← E-commerce App Tutorial
🔗

Share this topic with a friend

यह topic किसी दोस्त को भेजें

Found it useful? Send it to a classmate learning the same thing.

अच्छा लगा? जो दोस्त यही सीख रहा है, उसे भेज दीजिए।

Next topicRegister & Login API (Token) Your progress: 3 / 28 (11%) · Lesson 4 / 28 Continue →

How was this lesson?

Your feedback helps us make the content better.

Write a review or suggestion (optional)

After approval, your name and city may appear publicly with your review. Please do not write your email or phone number.

▶ CodingEasily on YouTube

Learn with video lessons in easy Hindi

Featured video: RDBMS Part 3 – Class XI Commerce IT

▶ Subscribe – it’s free