API Usage Sample

A complete RESTful CRUD API with JWT authentication built on LavaLust. All endpoints return JSON. Protected endpoints require a Bearer token in the Authorization header.

Project Structure

app/
├── config/
   ├── api.php                        API configuration (JWT, CORS, rate limits)
   └── routes.php                     Route definitions
├── controllers/
   ├── AuthController.php             Register, login, refresh, logout
   └── UsersController.php            CRUD endpoints
└── models/
    └── User_model.php                 Database operations

Setup

1. Run the migrations

Users table:

CREATE TABLE IF NOT EXISTS `users` (
    `id`         INT UNSIGNED    NOT NULL AUTO_INCREMENT,
    `name`       VARCHAR(100)    NOT NULL,
    `email`      VARCHAR(150)    NOT NULL,
    `password`   VARCHAR(255)    NOT NULL,
    `role`       VARCHAR(50)     NOT NULL DEFAULT 'user',
    `created_at` DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP
                                          ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uq_users_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Refresh tokens table (for token rotation):

CREATE TABLE IF NOT EXISTS `refresh_tokens` (
    `id`         INT UNSIGNED    NOT NULL AUTO_INCREMENT,
    `user_id`    INT UNSIGNED    NOT NULL,
    `token`      VARCHAR(255)    NOT NULL,
    `jti`        VARCHAR(64)     NOT NULL,
    `expires_at` DATETIME        NOT NULL,
    `created_at` DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_user_id` (`user_id`),
    KEY `idx_token` (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

2. Create app/config/api.php

<?php
/**
 * API Configuration
 */

// Enable/disable the API library
$config['api_enabled'] = true;

// JWT Secret (strong random string — at least 32 characters)
$config['jwt_secret'] = 'your-super-secret-key-change-me-minimum-32-chars';

// Refresh Token Key (separate from JWT secret)
$config['refresh_token_key'] = 'your-refresh-token-key-minimum-32-chars';

// Token lifetimes (seconds)
$config['payload_token_expiration'] = 900;   // 15 minutes
$config['refresh_token_expiration'] = 604800; // 7 days

// Refresh token table name
$config['refresh_token_table'] = 'refresh_tokens';

// JWT claims
$config['jwt_issuer'] = 'your-api-domain.com';
$config['jwt_audience'] = 'your-api-audience';

// CORS settings
// Use '*' for any origin, or an array of allowed origins
$config['allow_origin'] = '*';
// $config['allow_origin'] = ['https://yourdomain.com', 'https://app.yourdomain.com'];

// Rate limiting
$config['rate_limit_enabled'] = true;
$config['rate_limit_requests'] = 100;   // max requests per window
$config['rate_limit_seconds'] = 60;     // time window in seconds

3. Load the Api library

In your controllers:

<?php
// In any controller method:
$this->call->library('api');
$this->api->method();

Or auto-load it globally in app/config/autoload.php:

<?php
$autoload['libraries'] = array('api');

Then use it directly:

<?php
$this->api->require_jwt();
$body = $this->api->body();

Routes (app/config/routes.php)

LavaLust 4.x uses a router syntax similar to:

<?php
$router->get('/', 'Welcome::index');
$router->post('/submit', 'Form::submit');

Complete API routes:

<?php
// Auth routes
$router->post('/api/auth/register', 'AuthController::register');
$router->post('/api/auth/login',    'AuthController::login');
$router->post('/api/auth/refresh',  'AuthController::refresh');
$router->post('/api/auth/logout',   'AuthController::logout');

// Users CRUD routes
$router->get('/api/users',                'UsersController::index');
$router->get('/api/users/{num}',         'UsersController::show');
$router->post('/api/users',               'UsersController::create');
$router->put('/api/users/{num}',         'UsersController::update');
$router->patch('/api/users/{num}',       'UsersController::update');
$router->delete('/api/users/{num}',      'UsersController::delete');

Authentication

The API uses stateless JWT authentication (HS256) with refresh token rotation. After a successful login or registration, the response includes an access_token and a refresh_token.

  • Access token: Short-lived (default 15 minutes). Send on every protected request.

  • Refresh token: Long-lived (default 7 days). Used to obtain new access tokens.

Request with access token:

Authorization: Bearer <access_token>

Refresh token usage:

Send a request to POST /api/auth/refresh with the refresh token in the request body.

The API library automatically handles CORS, rate limiting, input sanitization, and token validation.

Complete Controller Examples

AuthController.php (Full Authentication)

<?php
defined('PREVENT_DIRECT_ACCESS') OR exit('No direct script access allowed');

class AuthController extends Controller {

    public function __construct() {
        parent::__construct();
        $this->call->library('api');
        $this->call->model('user_model');
    }

    // POST /api/auth/register
    public function register() {
        // Only POST allowed
        $this->api->require_method('POST');

        // Apply rate limiting (prevent abuse)
        $this->api->rate_limit();

        // Get request body
        $data = $this->api->body();

        // Validate input
        $errors = [];

        if (empty($data['name']) || strlen($data['name']) > 100) {
            $errors['name'] = 'Name is required and must be less than 100 characters.';
        }

        if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            $errors['email'] = 'Valid email address is required.';
        }

        if (empty($data['password']) || strlen($data['password']) < 8) {
            $errors['password'] = 'Password must be at least 8 characters.';
        }

        // Check if email exists
        if ($this->user_model->get_user_by_email($data['email'])) {
            $errors['email'] = 'This email address is already registered.';
        }

        if (!empty($errors)) {
            $this->api->respond([
                'error' => 'Validation failed',
                'status' => 422,
                'errors' => $errors
            ], 422);
        }

        // Create user
        $hashed_password = password_hash($data['password'], PASSWORD_BCRYPT);
        $user_id = $this->user_model->create_user([
            'name' => $data['name'],
            'email' => $data['email'],
            'password' => $hashed_password,
            'role' => 'user'
        ]);

        // Get created user
        $user = $this->user_model->get_user($user_id);

        // Issue tokens
        $tokens = $this->api->issue_tokens([
            'id' => $user['id'],
            'role' => $user['role'],
            'scopes' => ['read', 'write']
        ]);

        $this->api->respond([
            'message' => 'Registration successful',
            'tokens' => $tokens
        ], 201);
    }

    // POST /api/auth/login
    public function login() {
        $this->api->require_method('POST');
        $this->api->rate_limit();

        $data = $this->api->body();

        // Validate input
        if (empty($data['email']) || empty($data['password'])) {
            $this->api->respond_error('Email and password are required.', 400);
        }

        // Find user
        $user = $this->user_model->get_user_by_email($data['email']);

        if (!$user || !password_verify($data['password'], $user['password'])) {
            $this->api->respond_error('Invalid email or password.', 401);
        }

        // Issue tokens
        $tokens = $this->api->issue_tokens([
            'id' => $user['id'],
            'role' => $user['role'],
            'scopes' => ['read', 'write']
        ]);

        $this->api->respond([
            'message' => 'Login successful',
            'tokens' => $tokens
        ]);
    }

    // POST /api/auth/refresh
    public function refresh() {
        $this->api->require_method('POST');
        $this->api->rate_limit();

        $data = $this->api->body();

        if (empty($data['refresh_token'])) {
            $this->api->respond_error('Refresh token is required.', 400);
        }

        // This will auto-respond with new tokens or error
        $this->api->refresh_access_token($data['refresh_token']);
    }

    // POST /api/auth/logout
    public function logout() {
        $this->api->require_method('POST');

        // Require valid access token
        $payload = $this->api->require_jwt();

        $data = $this->api->body();

        // Revoke refresh token if provided
        if (!empty($data['refresh_token'])) {
            $this->api->revoke_refresh_token($data['refresh_token']);
        } else {
            // Revoke all refresh tokens for this user
            $this->api->cleanup_expired_refresh_tokens($payload['sub']);
            $this->db->raw(
                "DELETE FROM refresh_tokens WHERE user_id = ?",
                [$payload['sub']]
            );
        }

        $this->api->respond(['message' => 'Logged out successfully']);
    }
}

UsersController.php (Full CRUD)

<?php
defined('PREVENT_DIRECT_ACCESS') OR exit('No direct script access allowed');

class UsersController extends Controller {

    public function __construct() {
        parent::__construct();
        $this->call->library('api');
        $this->call->model('user_model');
    }

    // GET /api/users
    public function index() {
        // Require authentication
        $payload = $this->api->require_jwt();

        // Only GET allowed
        $this->api->require_method('GET');

        // Apply rate limiting (per user)
        $this->api->rate_limit($payload['sub']);

        // Get query parameters with defaults
        $params = $this->api->get_query_params();
        $page = isset($params['page']) ? (int)$params['page'] : 1;
        $limit = isset($params['limit']) ? min((int)$params['limit'], 100) : 15;
        $offset = ($page - 1) * $limit;

        // Get users with pagination
        $users = $this->user_model->get_users($limit, $offset);
        $total = $this->user_model->count_users();

        $this->api->respond([
            'users' => array_map(function($user) {
                unset($user['password']); // Remove sensitive data
                return $user;
            }, $users),
            'pagination' => [
                'total' => $total,
                'per_page' => $limit,
                'current_page' => $page,
                'last_page' => ceil($total / $limit)
            ]
        ]);
    }

    // GET /api/users/{id}
    public function show($id) {
        $payload = $this->api->require_jwt();
        $this->api->require_method('GET');
        $this->api->rate_limit($payload['sub']);

        $user = $this->user_model->get_user($id);

        if (!$user) {
            $this->api->respond_error('User not found.', 404);
        }

        unset($user['password']); // Remove sensitive data

        $this->api->respond($user);
    }

    // POST /api/users
    public function create() {
        $payload = $this->api->require_jwt();
        $this->api->require_method('POST');
        $this->api->rate_limit($payload['sub']);

        // Check if user has admin role
        if (($payload['role'] ?? 'user') !== 'admin') {
            $this->api->respond_error('Forbidden: Admin access required.', 403);
        }

        $data = $this->api->body();

        // Validate input
        $errors = [];

        if (empty($data['name']) || strlen($data['name']) > 100) {
            $errors['name'] = 'Name is required and must be less than 100 characters.';
        }

        if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            $errors['email'] = 'Valid email address is required.';
        }

        if (empty($data['password']) || strlen($data['password']) < 8) {
            $errors['password'] = 'Password must be at least 8 characters.';
        }

        // Check email uniqueness
        if ($this->user_model->get_user_by_email($data['email'])) {
            $errors['email'] = 'Email already exists.';
        }

        if (!empty($errors)) {
            $this->api->respond([
                'error' => 'Validation failed',
                'status' => 422,
                'errors' => $errors
            ], 422);
        }

        // Create user
        $hashed_password = password_hash($data['password'], PASSWORD_BCRYPT);
        $user_id = $this->user_model->create_user([
            'name' => $data['name'],
            'email' => $data['email'],
            'password' => $hashed_password,
            'role' => $data['role'] ?? 'user'
        ]);

        $user = $this->user_model->get_user($user_id);
        unset($user['password']);

        $this->api->respond($user, 201);
    }

    // PUT/PATCH /api/users/{id}
    public function update($id) {
        $payload = $this->api->require_jwt();

        // Allow both PUT and PATCH
        $method = $_SERVER['REQUEST_METHOD'];
        if (!in_array($method, ['PUT', 'PATCH'])) {
            $this->api->respond_error('Method Not Allowed', 405);
        }

        $this->api->rate_limit($payload['sub']);

        // Check if user exists
        $user = $this->user_model->get_user($id);
        if (!$user) {
            $this->api->respond_error('User not found.', 404);
        }

        // Check permissions (admin or self)
        $is_admin = ($payload['role'] ?? 'user') === 'admin';
        $is_self = ($payload['sub'] ?? 0) == $id;

        if (!$is_admin && !$is_self) {
            $this->api->respond_error('Forbidden: You can only update your own account.', 403);
        }

        $data = $this->api->body();
        $update_data = [];
        $errors = [];

        // Validate and prepare update fields
        if (isset($data['name'])) {
            if (strlen($data['name']) > 100) {
                $errors['name'] = 'Name must be less than 100 characters.';
            } else {
                $update_data['name'] = $data['name'];
            }
        }

        if (isset($data['email'])) {
            if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
                $errors['email'] = 'Valid email address is required.';
            } elseif ($this->user_model->get_user_by_email($data['email']) && $data['email'] !== $user['email']) {
                $errors['email'] = 'Email already exists.';
            } else {
                $update_data['email'] = $data['email'];
            }
        }

        if (isset($data['password'])) {
            if (strlen($data['password']) < 8) {
                $errors['password'] = 'Password must be at least 8 characters.';
            } else {
                $update_data['password'] = password_hash($data['password'], PASSWORD_BCRYPT);
            }
        }

        // Only admin can change role
        if (isset($data['role']) && $is_admin) {
            $update_data['role'] = $data['role'];
        } elseif (isset($data['role']) && !$is_admin) {
            $errors['role'] = 'Only administrators can change user roles.';
        }

        if (!empty($errors)) {
            $this->api->respond([
                'error' => 'Validation failed',
                'status' => 422,
                'errors' => $errors
            ], 422);
        }

        if (empty($update_data)) {
            $this->api->respond_error('No valid fields to update.', 422);
        }

        // Update user
        $this->user_model->update_user($id, $update_data);

        // Get updated user
        $updated_user = $this->user_model->get_user($id);
        unset($updated_user['password']);

        $this->api->respond($updated_user);
    }

    // DELETE /api/users/{id}
    public function delete($id) {
        $payload = $this->api->require_jwt();
        $this->api->require_method('DELETE');
        $this->api->rate_limit($payload['sub']);

        // Prevent self-deletion
        if (($payload['sub'] ?? 0) == $id) {
            $this->api->respond_error('You cannot delete your own account.', 403);
        }

        // Check if user exists
        $user = $this->user_model->get_user($id);
        if (!$user) {
            $this->api->respond_error('User not found.', 404);
        }

        // Check admin permission
        if (($payload['role'] ?? 'user') !== 'admin') {
            $this->api->respond_error('Forbidden: Admin access required.', 403);
        }

        // Delete user and their refresh tokens
        $this->db->raw("DELETE FROM refresh_tokens WHERE user_id = ?", [$id]);
        $this->user_model->delete_user($id);

        // 204 No Content - no response body
        http_response_code(204);
        exit;
    }
}

User_model.php

<?php
defined('PREVENT_DIRECT_ACCESS') OR exit('No direct script access allowed');

class User_model extends Model {

    public function get_user($id) {
        $stmt = $this->db->raw(
            "SELECT id, name, email, role, created_at, updated_at FROM users WHERE id = ? LIMIT 1",
            [$id]
        );
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }

    public function get_user_by_email($email) {
        $stmt = $this->db->raw(
            "SELECT * FROM users WHERE email = ? LIMIT 1",
            [$email]
        );
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }

    public function get_users($limit, $offset) {
        $stmt = $this->db->raw(
            "SELECT id, name, email, role, created_at, updated_at FROM users LIMIT ? OFFSET ?",
            [$limit, $offset]
        );
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function count_users() {
        $stmt = $this->db->raw("SELECT COUNT(*) as total FROM users");
        $result = $stmt->fetch(PDO::FETCH_ASSOC);
        return (int)$result['total'];
    }

    public function create_user($data) {
        $this->db->raw(
            "INSERT INTO users (name, email, password, role) VALUES (?, ?, ?, ?)",
            [$data['name'], $data['email'], $data['password'], $data['role']]
        );
        return $this->db->lastInsertId();
    }

    public function update_user($id, $data) {
        $fields = [];
        $values = [];

        foreach ($data as $key => $value) {
            $fields[] = "$key = ?";
            $values[] = $value;
        }

        $values[] = $id;
        $sql = "UPDATE users SET " . implode(', ', $fields) . " WHERE id = ?";

        return $this->db->raw($sql, $values);
    }

    public function delete_user($id) {
        return $this->db->raw("DELETE FROM users WHERE id = ?", [$id]);
    }
}

Testing the API with cURL

Register a user:

curl -X POST https://yourdomain.com/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name":"John Doe","email":"john@example.com","password":"secret123"}'

Login:

curl -X POST https://yourdomain.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"john@example.com","password":"secret123"}'

Get users (protected):

curl -X GET https://yourdomain.com/api/users?page=1&limit=10 \
  -H "Authorization: Bearer <access_token>"

Get single user:

curl -X GET https://yourdomain.com/api/users/1 \
  -H "Authorization: Bearer <access_token>"

Create user (admin only):

curl -X POST https://yourdomain.com/api/users \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"name":"Jane Smith","email":"jane@example.com","password":"password123","role":"user"}'

Update user:

curl -X PUT https://yourdomain.com/api/users/1 \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"name":"John Updated"}'

Delete user (admin only):

curl -X DELETE https://yourdomain.com/api/users/2 \
  -H "Authorization: Bearer <access_token>"

Refresh token:

curl -X POST https://yourdomain.com/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"<refresh_token>"}'

Logout:

curl -X POST https://yourdomain.com/api/auth/logout \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"<refresh_token>"}'

Rate Limiting

All API endpoints are rate-limited by default. Limits are applied per IP address.

Rate limit headers are included in every response:

Header

Description

X-RateLimit-Limit

Maximum requests allowed in the current window

X-RateLimit-Remaining

Remaining requests in the current window

X-RateLimit-Reset

Unix timestamp when the rate limit resets

Retry-After

Seconds to wait before retrying (only on 429 responses)

429 Too Many Requests

{
  "error": "Too many requests. Please try again later.",
  "limit": 100,
  "used": 100,
  "remaining": 0,
  "reset_at": "2025-01-15T10:15:00+00:00",
  "retry_after": 45
}

Error Response Reference

All error responses follow the same JSON envelope:

{
  "error": "Human-readable message.",
  "status": 400
}

Validation errors additionally include an errors object:

{
  "error": "Validation failed",
  "status": 422,
  "errors": {
    "email": "Please provide a valid email address.",
    "password": "Password must be at least 8 characters."
  }
}

Status

When it occurs

200

Request successful

201

Resource created successfully

204

Resource deleted (no body)

400

General bad request

401

Missing, invalid, or expired token; wrong credentials

403

Authenticated but not permitted (e.g. self-delete, invalid refresh token)

404

Resource does not exist

405

HTTP method not allowed for this route

422

Validation failed (see errors object)

429

Rate limit exceeded

500

Unexpected server error

JWT Token Structure

Tokens are signed with HS256 and include the following standard claims:

Claim

Description

sub

User ID (integer)

iat

Issued-at timestamp (Unix)

exp

Expiry timestamp (Unix)

iss

Issuer (from config)

aud

Audience (from config)

jti

JWT ID (unique identifier)

Access token additional claims:

Claim

Description

role

User role (e.g., user, admin)

scopes

Array of permission scopes (e.g., ['read', 'write'])

Refresh token additional claims:

Claim

Description

type

Always refresh

jti

Used for token revocation tracking

Security Notes

  • Passwords: Hashed with password_hash() using BCRYPT. Never stored or logged in plain text.

  • Token Storage: Refresh tokens are hashed before storage using a separate key, preventing token theft from database breaches.

  • Token Rotation: Each refresh operation issues a new refresh token and revokes the old one.

  • Timing Attacks: Signature validation uses hash_equals() for constant-time comparison.

  • User Enumeration: Login returns the same vague message for invalid email or password.

  • Input Sanitization: All inputs are automatically trimmed and HTML-escaped via the body() and get_query_params() methods.

  • CORS: Configurable allowed origins with automatic handling of preflight (OPTIONS) requests.

  • Rate Limiting: Enabled by default with configurable limits per IP address.

  • Environment: Change jwt_secret and refresh_token_key to long, random strings (minimum 32 characters each). Never commit them to version control — use environment variables instead.

Generating secure keys:

# Linux / macOS
openssl rand -base64 32

# Or using PHP
php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"