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.

PHP 8.1+ No Composer No Dependencies JWT Auth Multi-DB

Requirements #

  • PHP 8.1 or higher
  • MySQLi extension enabled
  • Apache with mod_rewrite enabled (or Nginx with equivalent rewrite rules)
  • No Composer required

Installation #

  1. Copy all files to your server inside your API folder (e.g. project/api/)
  2. Make sure .htaccess is in the same folder as index.php
  3. Edit config.php and fill in your database credentials and JWT secret
  4. 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:

Deep /api/{version}/{folder}/{file}.php/{method}?param=value
Mid /api/{version}/{file}.php/{method}?param=value
Flat /api/{file}.php/{method}?param=value

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
  • .php in the URL is optional — /login.php/login and /login/login both work
  • Query string params (?page=1&limit=50) always work on any URL shape
  • File name maps to class name: login.phpLoginController, user_profile.phpUserProfileController

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';
The CLI generator calculates the correct path automatically — use it to avoid mistakes.

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');
GET vs POST: GET and DELETE read from the query string. POST, PUT, PATCH read from the request body (JSON or form-encoded). 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
Rules: HTTP status code always goes in the second argument — never as a key inside the array. Never add 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
email 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.

See 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.

See 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
Windows line endings: If you created the file on Windows and uploaded it to Linux, run 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 #

  1. Upload all files to your server's API directory
  2. Set PHP to 8.1 or higher in your server control panel
  3. Edit config.php — set database credentials, JWT secret, mail settings
  4. Enable HTTPS/SSL on your domain (required for secure cookies in production)
  5. Set 'debug' => false and 'secure' => true for production
  6. 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);