PHP API Micro-Framework
A lightweight, zero-dependency REST API framework. No Composer, no external libraries — pure PHP 8.1+. Works on any shared hosting, VPS, or local environment.
Requirements #
- PHP 8.1 or higher
- MySQLi extension enabled
- Apache with
mod_rewriteenabled (or Nginx with equivalent rewrite rules) - No Composer required
Installation #
- Copy all files to your server inside your API folder (e.g.
project/api/) - Make sure
.htaccessis in the same folder asindex.php - Edit
config.phpand fill in your database credentials and JWT secret - Done — no build step, no dependency install
project/
└── api/
├── .htaccess
├── index.php
├── config.php
├── console.php
├── core/
├── controllers/
├── services/
└── cron/
Directory Structure #
api/
├── .htaccess — URL rewriting (do not modify)
├── index.php — Entry point
├── config.php — All configuration
├── console.php — CLI code generator
│
├── core/
│ ├── Request.php — Input parsing and validation
│ ├── Response.php — JSON output helpers
│ ├── Router.php — URL to controller dispatch
│ ├── BaseController.php — Shared DB, config, token access
│ ├── Middleware.php — Auth, role, method guards
│ ├── TokenService.php — JWT generate and verify (HS256)
│ └── ValidationException.php
│
├── controllers/
│ ├── sample.php — example flat controller
│ └── v1/
│ ├── auth/
│ │ └── login.php → LoginController
│ └── orders/
│ └── orders.php → OrdersController
│
├── services/
│ ├── SampleService.php — example service (start here)
│ ├── LoginService.php — login attempts, IP, device
│ ├── LogService.php — user activity logging
│ ├── MailService.php — raw SMTP email sender
│ └── PasswordResetService.php — password reset and activation
│
└── cron/
├── sample_job.php — example cron job (start here)
└── expire_cylinders.php — example scheduled task
Configuration #
All settings live in config.php. This is the only file you need to edit before
deploying.
return [
'timezone' => 'Asia/Manila',
'app' => [
'name' => 'My API',
'debug' => false, // true shows full errors — dev only
],
'jwt' => [
// Generate: php -r "echo bin2hex(random_bytes(32));"
'secret' => 'your-long-random-secret-here',
'access_ttl' => 900, // 15 minutes
'refresh_ttl' => 604800, // 7 days
'cookie_prefix' => 'app',
'secure' => true, // false for local HTTP
'same_site' => 'Strict',
],
'db_default' => 'primary',
'databases' => [
'primary' => [
'driver' => 'mysqli', // mysqli | pgsql | sqlsrv | sqlite
'host' => 'localhost',
'port' => 3306,
'name' => 'my_database',
'user' => 'db_user',
'password' => 'db_password',
'charset' => 'utf8mb4',
],
],
'mail' => [
'driver' => 'smtp',
'host' => 'smtp.yourmailserver.com',
'port' => 587,
'encryption' => 'tls',
'username' => 'noreply@yourdomain.com',
'password' => 'your_email_password',
'from_email' => 'noreply@yourdomain.com',
'from_name' => 'My App',
],
'cors' => [
'allowed_origins' => '*',
'allowed_methods' => 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'allowed_headers' => 'Content-Type, X-Auth-Token, Origin, Authorization',
'allow_credentials' => true,
],
];
Routing #
The .htaccess routes all requests to index.php. The Router maps the URL to
a
controller file and calls the matching method.
URL Shapes
All three formats are supported and can be mixed freely:
Examples
| HTTP | URL | File | Method |
|---|---|---|---|
| POST | /api/v1/auth/login.php/login | controllers/v1/auth/login.php | login() |
| GET | /api/v1/orders/orders.php/list | controllers/v1/orders/orders.php | list() |
| GET | /api/v1/cylinders.php/list | controllers/v1/cylinders.php | list() |
| POST | /api/auth.php/login | controllers/auth.php | login() |
| GET | /api/users.php/list | controllers/users.php | list() |
| DELETE | /api/orders.php/delete | controllers/orders.php | delete() |
Rules
- The version segment (
v1,v2,version1) is just a folder — name it anything .phpin the URL is optional —/login.php/loginand/login/loginboth work- Query string params (
?page=1&limit=50) always work on any URL shape - File name maps to class name:
login.php→LoginController,user_profile.php→UserProfileController
Folder vs URL
controllers/
├── users.php → /api/users.php/list
├── orders.php → /api/orders.php/list
└── v1/
├── auth.php → /api/v1/auth.php/login
├── cylinders.php → /api/v1/cylinders.php/list
└── finance/
└── invoices.php → /api/v1/finance/invoices.php/create
Controllers #
Every controller extends BaseController and lives in the controllers/
folder.
Minimal Controller
require_once __DIR__ . '/../core/BaseController.php';
class UsersController extends BaseController
{
public function list(Request $request, Response $response): void
{
Middleware::method('GET', $request, $response);
$user = Middleware::auth($request, $response);
try {
$conn = $this->db();
$stmt = $conn->prepare("SELECT id, name, email FROM users WHERE deleted = 0");
$stmt->execute();
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
$response($rows, 200);
} catch (Throwable $e) {
$response(['error' => true, 'message' => $e->getMessage()], 500);
}
}
}
require_once Depth
// controllers/orders.php (1 level deep)
require_once __DIR__ . '/../core/BaseController.php';
// controllers/v1/orders.php (2 levels deep)
require_once __DIR__ . '/../../core/BaseController.php';
// controllers/v1/finance/invoices.php (3 levels deep)
require_once __DIR__ . '/../../core/BaseController.php';
Request #
// GET query string: ?page=1&limit=50
$page = $request->query('page', 1);
$limit = $request->query('limit', 50);
// POST / JSON body
$name = $request->input('name');
$email = $request->input('email', '');
// All body data
$all = $request->all();
// HTTP method
$method = $request->method(); // 'GET', 'POST', 'PUT'…
// Request header
$auth = $request->header('Authorization');
// URL segment param e.g. /orders/123 → param0 = 123
$id = $request->param('param0');
validate() and all()
automatically read the correct source.
Response #
The response object is callable — invoke it like a function:
$response($data, 200);
$response(['success' => true, 'id' => $newId], 201);
// Shorthand helpers
$response->ok($data); // 200
$response->created($data); // 201
$response->noContent(); // 204
$response->badRequest($data); // 400
$response->unauthorized($data); // 401
$response->forbidden($data); // 403
$response->notFound($data); // 404
$response->error($data); // 500
status as a key inside the response. $response()
always exits — no code runs after it.
Validation #
Call $request->validate() with a rules array. Throws ValidationException on
failure (auto-caught → 422). Returns all input merged with validated fields on success.
$data = $request->validate([
'name' => 'required|min:2|max:100',
'email' => 'required|email',
'age' => 'required|integer|min:1',
'price' => 'required|float',
'status' => 'required|in:active,pending,disabled',
'role' => 'not_in:superadmin',
'website' => 'url',
'dob' => 'required|date', // Y-m-d
'scheduled' => 'required|datetime', // Y-m-d H:i:s
'opens_at' => 'required|time', // H:i or H:i:s
'start' => 'after:2024-01-01',
'end' => 'before:2030-12-31',
'notes' => '', // optional, no rules
]);
All Rules
| Rule | Description |
|---|---|
| required | Must be present and not empty |
| string | Must be a string |
| numeric | Must be a number |
| integer | Must be a whole number |
| float | Must be a decimal number |
| boolean | Must be true / false / 1 / 0 |
| Valid email format | |
| url | Valid URL format |
| alpha | Letters only |
| alpha_num | Letters and numbers only |
| alpha_dash | Letters, numbers, dashes, underscores |
| min:{n} | Length / value / array count >= n |
| max:{n} | Length / value / array count <= n |
| in:{a},{b} | Value must be one of the listed options |
| not_in:{a},{b} | Value must NOT be one of the listed options |
| date | Valid date Y-m-d |
| datetime | Valid datetime Y-m-d H:i:s |
| time | Valid time H:i or H:i:s |
| before:{date} | Must be before the given date |
| after:{date} | Must be after the given date |
| regex:{pattern} | Must match the given regex pattern |
| array | Must be an array |
Optional Fields
Fields without required are fully skipped when not present in the payload:
$data = $request->validate([
'id' => 'required|numeric|min:1', // always validated
'notes' => 'string', // only validated IF sent
'date' => 'date', // only validated IF sent
]);
Array & Wildcard Validation
$data = $request->validate([
'items' => 'required|array|min:1',
'items.*.id' => 'required|integer|min:1',
'items.*.qty' => 'required|numeric|min:1',
'items.*.status' => 'required|in:pending,approved',
]);
Authentication & JWT #
Pure PHP HS256 JWT — no external library needed.
Issue Tokens on Login
$tokens = $this->tokens()->issue([
'id' => $user['id'],
'username' => $user['username'],
'role' => $user['role'],
]);
// Written to HttpOnly cookies automatically.
// Also returned in response body for mobile clients.
$response(['message' => 'Login successful', 'tokens' => $tokens], 200);
Token via Authorization Header (Mobile)
Authorization: Bearer <access_token>
The framework checks the Authorization header first, then falls back to the cookie.
Browser and mobile clients both work from the same API.
Refresh & Revoke
$tokens = $this->tokens()->refresh(); // new pair from refresh token
$this->tokens()->revoke(); // clear both cookies
$payload = $this->tokens()->verifyFromCookie(); // verify without reissuing
Middleware #
All guards are static — call them at the top of any controller method.
// Enforce HTTP method — sends 405 if wrong
Middleware::method('POST', $request, $response);
// Require valid token — sends 401 if missing/invalid
// Returns decoded token payload
$user = Middleware::auth($request, $response);
// $user['id'], $user['username'], $user['role']
// Require specific role — sends 403 if no match
$user = Middleware::role(['admin', 'superadmin'], $request, $response);
// Block logged-in users — sends 403 if token present
Middleware::guest($request, $response);
Database #
Connect
$conn = $this->db(); // default (db_default in config)
$conn = $this->db('secondary'); // named connection
$conn = $this->db($request->input('db')); // dynamic from request
Prepared Statements
// SELECT one row
$stmt = $conn->prepare("SELECT * FROM users WHERE id = ? LIMIT 1");
$stmt->bind_param('i', $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
// SELECT multiple rows
$stmt = $conn->prepare("SELECT * FROM orders WHERE status = ?");
$stmt->bind_param('s', $status);
$stmt->execute();
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// INSERT
$stmt = $conn->prepare("INSERT INTO users (name, email) VALUES (?, ?)");
$stmt->bind_param('ss', $name, $email);
$stmt->execute();
$newId = $stmt->insert_id;
$stmt->close();
// UPDATE
$stmt = $conn->prepare("UPDATE users SET name = ? WHERE id = ?");
$stmt->bind_param('si', $name, $id);
$stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
bind_param Types
| Char | Type |
|---|---|
| i | integer |
| s | string |
| d | double / float |
| b | blob |
Dynamic Filters (WHERE builder)
$where = ['deleted = 0'];
$params = [];
$types_str = '';
if ($status !== 'all') {
$where[] = 'status = ?';
$params[] = $status;
$types_str .= 's';
}
if ($customerId) {
$where[] = 'customer_id = ?';
$params[] = $customerId;
$types_str .= 'i';
}
$params[] = $limit;
$params[] = $offset;
$types_str .= 'ii';
$whereClause = implode(' AND ', $where);
$stmt = $conn->prepare("SELECT * FROM orders WHERE {$whereClause} LIMIT ? OFFSET ?");
if (!empty($params)) { $stmt->bind_param($types_str, ...$params); }
$stmt->execute();
Supported Drivers
| Driver | Config value |
|---|---|
| MySQL / MariaDB | mysqli |
| PostgreSQL | pgsql |
| SQL Server | sqlsrv |
| SQLite | sqlite |
Services #
Services are static classes that handle business logic. No Request, no Response — just DB and logic. Controllers call services to stay thin.
services/SampleService.php for a fully documented
example with 7 common patterns.LogService
require_once __DIR__ . '/../../services/LogService.php';
LogService::login($conn, $user['id'], $ip, $device);
LogService::logout($conn, $user['id']);
LogService::create($conn, $user['id'], 'Created delivery DLV-001');
LogService::update($conn, $user['id'], 'Updated cylinder ID 42');
LogService::delete($conn, $user['id'], 'Deleted customer ID 7');
LogService::statusChange($conn, $user['id'], 'Changed order to completed');
LogService::import($conn, $user['id'], 'Imported 150 records');
LogService::purgeOlderThan($conn, 180); // run from cron
LoginService
LoginService::loginAttempt($conn, $userId); // increment (locks at 5)
LoginService::resetAttempt($conn, $userId); // reset to 0 on success
LoginService::isDisabled($user); // true if locked
LoginService::getIp(); // detect real IP
LoginService::getDevice(); // parse User-Agent
LoginService::saveLoginInfo($conn, $userId); // save IP + device to DB
MailService
MailService::send(
to: 'user@example.com',
subject: 'Welcome!',
body: '<h1>Hello!</h1>',
toName: 'John Doe',
);
PasswordResetService
// Send 6-digit code to email
PasswordResetService::sendCode($conn, $usernameOrEmail);
// Verify code
$valid = PasswordResetService::verifyCode($conn, $usernameOrEmail, $code);
// Reset password
PasswordResetService::resetPassword($conn, $usernameOrEmail, $code, $newPassword);
// Account activation
PasswordResetService::sendActivation($conn, $userId);
PasswordResetService::activateAccount($conn, $token);
PasswordResetService::resendActivation($conn, $usernameOrEmail);
CLI Generator #
Generate controller files from the terminal — no manual file creation needed.
# Flat controller
php console.php gen:controller Orders
# Versioned + nested
php console.php gen:controller v1/auth/Login
# With specific methods
php console.php gen:controller v1/orders/Items --methods=list,create,update,delete
# Public endpoint — no auth middleware
php console.php gen:controller v1/auth/Forgot --no-auth
# Public with methods
php console.php gen:controller v1/auth/Forgot --no-auth --methods=forgot,reset,verify
# Overwrite existing
php console.php gen:controller v1/auth/Login --force
# Show help
php console.php list
HTTP Verb Auto-Detection
| Method name | HTTP verb |
|---|---|
| list, index, show, find, get | GET |
| create, store, login, register, forgot, reset, verify | POST |
| update | PUT |
| delete, destroy | DELETE |
| anything else | GET |
Cron Jobs #
Standalone PHP files in the cron/ folder that connect directly to the DB and run
scheduled tasks.
cron/sample_job.php for a fully documented starting
point.Basic Structure
$config = require __DIR__ . '/../config.php';
$db = $config['databases'][$config['db_default']];
$conn = new mysqli($db['host'], $db['user'], $db['password'], $db['name'], $db['port']);
// your job logic here
$conn->close();
Schedule Format
* * * * *
| | | | └── Day of week (0-7)
| | | └──── Month (1-12)
| | └────── Day of month (1-31)
| └──────── Hour (0-23)
└────────── Minute (0-59)
0 0 * * * daily at midnight
0 8 * * * daily at 8am
0 0 * * 0 every Sunday
0 0 1 * * first day of month
*/30 * * * * every 30 minutes
Running on a Server
# Run manually
php /path/to/api/cron/my_job.php
# Run with log output
php /path/to/api/cron/my_job.php >> /path/to/logs/my_job.log 2>&1
dos2unix /path/to/cron/my_job.php before
executing.Email #
Raw PHP SMTP sockets — no Composer, no PHPMailer needed. Configure in config.php under
the mail key.
Common SMTP Providers
| Provider | Host | Port |
|---|---|---|
| Gmail | smtp.gmail.com | 587 |
| Outlook | smtp.office365.com | 587 |
| SendGrid | smtp.sendgrid.net | 587 |
| Mailgun | smtp.mailgun.org | 587 |
| Any host | smtp.yourdomain.com | 587 |
Deployment #
- Upload all files to your server's API directory
- Set PHP to 8.1 or higher in your server control panel
- Edit
config.php— set database credentials, JWT secret, mail settings - Enable HTTPS/SSL on your domain (required for secure cookies in production)
- Set
'debug' => falseand'secure' => truefor production - Test the API:
curl -X POST https://yourdomain.com/api/v1/auth/login.php/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"yourpassword"}'
Apache
The .htaccess is already configured — just make sure mod_rewrite is
enabled.
Nginx
location /api/ {
try_files $uri $uri/ /api/index.php?x=$uri&$args;
}
Environment Variables (optional)
'databases' => [
'primary' => [
'host' => getenv('DB_HOST') ?: 'localhost',
'name' => getenv('DB_NAME') ?: 'my_database',
'user' => getenv('DB_USER') ?: 'root',
'password' => getenv('DB_PASS') ?: '',
],
],
'jwt' => [
'secret' => getenv('JWT_SECRET') ?: 'fallback-dev-secret',
],
Response Conventions #
// Success — list / data
$response($rows, 200);
// Success — with message
$response(['success' => true, 'message' => 'Done.'], 200);
// Success — created
$response(['success' => true, 'message' => 'Created.', 'id' => $newId], 201);
// Pagination
$response(['data' => $rows, 'total' => $total, 'page' => $page, 'limit' => $limit], 200);
// Validation error (422)
$response(['error' => true, 'message' => $e->getErrors()], 422);
// Not found (404)
$response(['error' => true, 'message' => 'Record not found.'], 404);
// Conflict (409)
$response(['error' => true, 'message' => 'Email already exists.'], 409);
// Server error (500)
$response(['error' => true, 'message' => $e->getMessage()], 500);