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 |
|---|---|
|
Maximum requests allowed in the current window |
|
Remaining requests in the current window |
|
Unix timestamp when the rate limit resets |
|
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 |
|---|---|
|
Request successful |
|
Resource created successfully |
|
Resource deleted (no body) |
|
General bad request |
|
Missing, invalid, or expired token; wrong credentials |
|
Authenticated but not permitted (e.g. self-delete, invalid refresh token) |
|
Resource does not exist |
|
HTTP method not allowed for this route |
|
Validation failed (see |
|
Rate limit exceeded |
|
Unexpected server error |
JWT Token Structure
Tokens are signed with HS256 and include the following standard claims:
Claim |
Description |
|---|---|
|
User ID (integer) |
|
Issued-at timestamp (Unix) |
|
Expiry timestamp (Unix) |
|
Issuer (from config) |
|
Audience (from config) |
|
JWT ID (unique identifier) |
Access token additional claims:
Claim |
Description |
|---|---|
|
User role (e.g., |
|
Array of permission scopes (e.g., |
Refresh token additional claims:
Claim |
Description |
|---|---|
|
Always |
|
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()andget_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_secretandrefresh_token_keyto 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;"