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:
| Method | Used for | Example |
|---|---|---|
| GET | reading data | products.php?q=watch |
| POST | creating / 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)
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
// ------------------------------------------------------------------
// 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');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
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;
}| Option | What it does |
|---|---|
| ERRMODE_EXCEPTION | SQL errors throw exceptions instead of silently returning false |
| FETCH_ASSOC | rows come back as array('name' => 'Smart Watch Pro', …) |
| EMULATE_PREPARES false | real 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
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);
}| Helper | Job |
|---|---|
| json_out / ok / fail | send 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
// 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
| Status | Meaning in KartEasy |
|---|---|
| 200 | OK |
| 401 | not logged in / token expired |
| 403 | not allowed (e.g. review before delivery) |
| 404 | not found |
| 405 | wrong method (GET instead of POST) |
| 409 | conflict: out of stock, email already used |
| 422 | validation error: bad phone, missing field |
| 500 | server bug (details only in the error log) |
Step 4: Protect private files
Three small .htaccess files (Apache / LiteSpeed — used by Hostinger and cPanel):
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># 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>
# 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 -IndexesOn 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
// 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):
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/
"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
