| name | ci3-rest-api |
| description | CodeIgniter 3 REST API patterns and conventions: MVC structure (controllers/api/, models/, views/), RESTful endpoints with chriskacerguis/RestServer library, JWT authentication using firebase/php-jwt, full CRUD patterns, form validation, database query builder, error handling, and security (XSS filtering, CSRF protection, SQL injection prevention). Designed for the code-igniter-3-fullstack agent building production-grade CI3 REST APIs with consistent response envelopes and secure defaults. |
| version | 1.0.0 |
| author | opencode-agent-kit |
| license | MIT |
| metadata | {"target_agent":"code-igniter-3-fullstack","stack":["CodeIgniter 3","chriskacerguis/RestServer","firebase/php-jwt","MySQL","PostgreSQL","MVC Monolith"],"tags":["codeigniter","rest-api","jwt","crud","mvc"]} |
CI3 REST API Skill
Target Agent: @code-igniter-3-fullstack
Stack: CodeIgniter 3 · RestServer · firebase/php-jwt · MySQL/PostgreSQL · MVC Monolith
Comprehensive reference for building production-grade REST APIs with CodeIgniter 3, covering project structure, controller patterns, JWT authentication flow, CRUD conventions, validation, security hardening, and error handling.
1. Project Structure
Standard CI3 MVC layout for REST API projects:
project_root/
├── application/
│ ├── controllers/
│ │ ├── api/ # REST API controllers
│ │ └── web/ # Optional web controllers
│ ├── models/ # Data access layer
│ ├── views/ # Templates (JSON views or HTML)
│ ├── config/ # Configuration files
│ │ ├── rest.php # RestServer config
│ │ ├── jwt.php # JWT config (custom)
│ │ └── routes.php # Route definitions
│ ├── libraries/ # Custom libraries (JWT helper, etc.)
│ ├── helpers/ # Custom helpers
│ ├── hooks/ # Hooks (auth middleware, etc.)
│ ├── third_party/ # RestServer library
│ └── logs/ # Error logs
├── assets/ # Static assets (if any)
├── system/ # CI3 system core
└── composer.json # Dependency management (php-jwt, etc.)
Rationale
controllers/api/ — All REST endpoints live here, separated from web controllers
models/ — Single responsibility: one model per entity/table
views/ — Can serve JSON via $this->output->set_content_type('application/json') or render HTML
libraries/ — Custom classes like JWT_library or Auth_library
config/rest.php — RestServer configuration (auth methods, rate limiting, response formats)
config/jwt.php — Custom config for JWT secret, expiry, algorithms
2. RestServer Library Setup
Installation
- Download
chriskacerguis/codeigniter-restserver or add via Composer:
composer require chriskacerguis/codeigniter-restserver
- Extend
RestController instead of CI_Controller in API controllers:
defined('BASEPATH') OR exit('No direct script access allowed');
require_once APPPATH . '/libraries/REST_Controller.php';
use RestServer\REST_Controller;
class Users extends REST_Controller {
}
REST Controller Configuration (application/config/rest.php)
Key configuration options:
| Config Key | Description | Recommended Value |
|---|
$config['rest_default_format'] | Default response format | 'json' |
$config['rest_supported_formats'] | Allowed formats | ['json', 'xml'] |
$config['rest_auth'] | Authentication method | 'basic' or 'digest' |
$config['rest_valid_logins'] | Valid credentials for basic auth | [] (use JWT instead) |
$config['rest_ip_whitelist_enabled'] | IP whitelist | FALSE |
$config['rest_ip_whitelist'] | Allowed IPs | [] |
$config['rest_enable_logging'] | Log API requests | TRUE in production |
$config['rest_enable_keys'] | API key system | FALSE (use JWT) |
$config['rest_limits_method'] | Rate limiting method | 'ROUTED_URL' |
$config['rest_limits'] | Rate limits per method | [] |
Core Methods
| RestController Method | HTTP Verb | Purpose |
|---|
$this->get('key') | GET | Retrieve input from query string |
$this->post('key') | POST | Retrieve input from POST body |
$this->put('key') | PUT | Retrieve input from PUT body |
$this->delete('key') | DELETE | Retrieve input from DELETE body |
$this->patch('key') | PATCH | Retrieve input from PATCH body |
$this->options('key') | OPTIONS | Retrieve input from OPTIONS body |
$this->response($data, $status) | Any | Send JSON response with status code |
$this->set_response($data, $status) | Any | Alternate response method |
$this->verify_request() | Any | Validate auth (custom override) |
3. JWT Authentication
Dependencies
composer require firebase/php-jwt
JWT Configuration (application/config/jwt.php)
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
$config['jwt_key'] = getenv('JWT_SECRET') ?: 'your-secret-key-change-in-production';
$config['jwt_algorithm'] = 'HS256';
$config['jwt_expiry'] = 3600;
$config['jwt_refresh_expiry'] = 604800;
$config['jwt_issuer'] = 'ci3-api';
JWT Library (application/libraries/JWT_library.php)
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
class JWT_library {
protected $CI;
protected $key;
protected $algorithm;
public function __construct() {
$this->CI =& get_instance();
$this->CI->load->config('jwt', TRUE);
$this->key = $this->CI->config->item('jwt_key', 'jwt');
$this->algorithm = $this->CI->config->item('jwt_algorithm', 'jwt');
}
public function encode(array $payload): string {
$issuedAt = time();
$expiry = $this->CI->config->item('jwt_expiry', 'jwt');
$token = [
'iss' => $this->CI->config->item('jwt_issuer', 'jwt'),
'iat' => $issuedAt,
'exp' => $issuedAt + $expiry,
'data' => $payload,
];
return JWT::encode($token, $this->key, $this->algorithm);
}
public function decode(string $token): object {
try {
$decoded = JWT::decode($token, new Key($this->key, $this->algorithm));
return $decoded->data;
} catch (ExpiredException $e) {
throw new \RuntimeException('Token has expired');
} catch (\Exception $e) {
throw new \RuntimeException('Invalid token: ' . $e->getMessage());
}
}
public function encodeRefresh(array $payload): string {
$issuedAt = time();
$refreshExpiry = $this->CI->config->item('jwt_refresh_expiry', 'jwt');
$token = [
'iss' => $this->CI->config->item('jwt_issuer', 'jwt'),
'iat' => $issuedAt,
'exp' => $issuedAt + $refreshExpiry,
'data' => $payload,
'type' => 'refresh',
];
return JWT::encode($token, $this->key, $this->algorithm);
}
}
JWT Validation Hook / Middleware
Option A — Per-controller _is_logged_in() method:
private function _is_logged_in(): bool {
$this->load->library('JWT_library');
$authHeader = $this->input->get_request_header('Authorization');
if (!$authHeader) {
$this->response([
'status' => false,
'message' => 'Authorization header is missing',
], REST_Controller::HTTP_UNAUTHORIZED);
return false;
}
$parts = explode(' ', $authHeader);
if (count($parts) !== 2 || strtolower($parts[0]) !== 'bearer') {
$this->response([
'status' => false,
'message' => 'Invalid authorization format. Use: Bearer <token>',
], REST_Controller::HTTP_UNAUTHORIZED);
return false;
}
try {
$this->jwt_data = $this->jwt_library->decode($parts[1]);
return true;
} catch (\RuntimeException $e) {
$this->response([
'status' => false,
'message' => $e->getMessage(),
], REST_Controller::HTTP_UNAUTHORIZED);
return false;
}
}
Option B — CI3 Hook (global middleware):
In application/config/hooks.php:
$hook['post_controller_constructor'][] = [
'class' => 'Auth_hook',
'function' => 'authenticate',
'filename' => 'Auth_hook.php',
'filepath' => 'hooks',
];
Login / Token Endpoint
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
require_once APPPATH . '/libraries/REST_Controller.php';
use RestServer\REST_Controller;
class Auth extends REST_Controller {
public function __construct() {
parent::__construct();
$this->load->model('User_model');
}
public function login_post() {
$this->load->library('form_validation');
$this->form_validation->set_data([
'email' => $this->post('email'),
'password' => $this->post('password'),
]);
$this->form_validation->set_rules('email', 'Email', 'required|valid_email|trim');
$this->form_validation->set_rules('password', 'Password', 'required|trim');
if ($this->form_validation->run() === FALSE) {
$this->response([
'status' => false,
'message' => 'Validation failed',
'errors' => $this->form_validation->error_array(),
], REST_Controller::HTTP_UNPROCESSABLE_ENTITY);
return;
}
$user = $this->User_model->get_by_email($this->post('email'));
if (!$user || !password_verify($this->post('password'), $user->password)) {
$this->response([
'status' => false,
'message' => 'Invalid email or password',
], REST_Controller::HTTP_UNAUTHORIZED);
return;
}
$this->load->library('JWT_library');
$tokenPayload = [
'user_id' => $user->id,
'email' => $user->email,
'role' => $user->role ?? 'user',
];
$token = $this->jwt_library->encode($tokenPayload);
$refreshToken = $this->jwt_library->encodeRefresh(['user_id' => $user->id]);
$this->response([
'status' => true,
'message' => 'Login successful',
'data' => [
'token' => $token,
'refresh_token' => $refreshToken,
'expires_in' => $this->config->item('jwt_expiry', 'jwt'),
'user' => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name ?? '',
'role' => $user->role ?? 'user',
],
],
], REST_Controller::HTTP_OK);
}
public function refresh_post() {
$refreshToken = $this->post('refresh_token');
if (!$refreshToken) {
$this->response([
'status' => false,
'message' => 'Refresh token is required',
], REST_Controller::HTTP_BAD_REQUEST);
return;
}
$this->load->library('JWT_library');
try {
$data = $this->jwt_library->decode($refreshToken);
$user = $this->User_model->get($data->user_id);
if (!$user) {
throw new \RuntimeException('User not found');
}
$newToken = $this->jwt_library->encode([
'user_id' => $user->id,
'email' => $user->email,
'role' => $user->role ?? 'user',
]);
$this->response([
'status' => true,
'message' => 'Token refreshed',
'data' => [
'token' => $newToken,
'expires_in' => $this->config->item('jwt_expiry', 'jwt'),
],
], REST_Controller::HTTP_OK);
} catch (\RuntimeException $e) {
$this->response([
'status' => false,
'message' => $e->getMessage(),
], REST_Controller::HTTP_UNAUTHORIZED);
}
}
}
4. CRUD Patterns
Base CRUD Controller Template
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
require_once APPPATH . '/libraries/REST_Controller.php';
use RestServer\REST_Controller;
class Products extends REST_Controller {
protected $model_name = 'Product_model';
protected $validation_rules = [];
public function __construct() {
parent::__construct();
$this->load->model($this->model_name);
$this->load->library('form_validation');
$this->validation_rules = [
[
'field' => 'name',
'label' => 'Name',
'rules' => 'required|min_length[3]|max_length[255]|trim|xss_clean',
],
[
'field' => 'price',
'label' => 'Price',
'rules' => 'required|numeric|greater_than[0]|trim',
],
[
'field' => 'description',
'label' => 'Description',
'rules' => 'trim|xss_clean',
],
[
'field' => 'category_id',
'label' => 'Category',
'rules' => 'required|integer|trim',
],
];
}
public function index_get() {
$this->is_logged_in();
$page = (int) $this->get('page') ?: 1;
$limit = (int) $this->get('limit') ?: 20;
$offset = ($page - 1) * $limit;
$filters = [];
if ($this->get('search')) $filters['search'] = $this->get('search');
if ($this->get('category_id')) $filters['category_id'] = $this->get('category_id');
if ($this->get('status')) $filters['status'] = $this->get('status');
$result = $this->{$this->model_name}->get_all($filters, $limit, $offset);
$total = $this->{$this->model_name}->count_all($filters);
$this->response([
'status' => true,
'message' => 'Products retrieved successfully',
'data' => [
'items' => $result,
'total' => (int) $total,
'page' => $page,
'per_page' => $limit,
'pages' => ceil($total / $limit),
],
], REST_Controller::HTTP_OK);
}
public function show_get($id) {
$this->is_logged_in();
$product = $this->{$this->model_name}->get($id);
if (!$product) {
$this->response([
'status' => false,
'message' => 'Product not found',
], REST_Controller::HTTP_NOT_FOUND);
return;
}
$this->response([
'status' => true,
'message' => 'Product retrieved successfully',
'data' => $product,
], REST_Controller::HTTP_OK);
}
public function create_post() {
$this->is_logged_in();
$input = [
'name' => $this->post('name'),
'price' => $this->post('price'),
'description' => $this->post('description'),
'category_id' => $this->post('category_id'),
'status' => $this->post('status') ?: 'active',
];
$this->form_validation->set_data($input);
$this->form_validation->set_rules($this->validation_rules);
if ($this->form_validation->run() === FALSE) {
$this->response([
'status' => false,
'message' => 'Validation failed',
'errors' => $this->form_validation->error_array(),
], REST_Controller::HTTP_UNPROCESSABLE_ENTITY);
return;
}
$id = $this->{$this->model_name}->create($input);
if (!$id) {
$this->response([
'status' => false,
'message' => 'Failed to create product',
], REST_Controller::HTTP_INTERNAL_SERVER_ERROR);
return;
}
$product = $this->{$this->model_name}->get($id);
$this->response([
'status' => true,
'message' => 'Product created successfully',
'data' => $product,
], REST_Controller::HTTP_CREATED);
}
public function update_put($id) {
$this->is_logged_in();
$existing = $this->{$this->model_name}->get($id);
if (!$existing) {
$this->response([
'status' => false,
'message' => 'Product not found',
], REST_Controller::HTTP_NOT_FOUND);
return;
}
$input = [
'name' => $this->put('name'),
'price' => $this->put('price'),
'description' => $this->put('description'),
'category_id' => $this->put('category_id'),
'status' => $this->put('status'),
];
$input = array_filter($input, function ($value) {
return $value !== null;
});
if (!empty($input)) {
$this->form_validation->set_data($input);
$this->form_validation->set_rules($this->validation_rules);
if ($this->form_validation->run() === FALSE) {
$this->response([
'status' => false,
'message' => 'Validation failed',
'errors' => $this->form_validation->error_array(),
], REST_Controller::HTTP_UNPROCESSABLE_ENTITY);
return;
}
$updated = $this->{$this->model_name}->update($id, $input);
if (!$updated) {
$this->response([
'status' => false,
'message' => 'Failed to update product',
], REST_Controller::HTTP_INTERNAL_SERVER_ERROR);
return;
}
}
$product = $this->{$this->model_name}->get($id);
$this->response([
'status' => true,
'message' => 'Product updated successfully',
'data' => $product,
], REST_Controller::HTTP_OK);
}
public function delete_delete($id) {
$this->is_logged_in();
$existing = $this->{$this->model_name}->get($id);
if (!$existing) {
$this->response([
'status' => false,
'message' => 'Product not found',
], REST_Controller::HTTP_NOT_FOUND);
return;
}
$deleted = $this->{$this->model_name}->delete($id);
if (!$deleted) {
$this->response([
'status' => false,
'message' => 'Failed to delete product',
], REST_Controller::HTTP_INTERNAL_SERVER_ERROR);
return;
}
$this->response([
'status' => true,
'message' => 'Product deleted successfully',
], REST_Controller::HTTP_OK);
}
private function is_logged_in(): void {
$this->load->library('JWT_library');
$authHeader = $this->input->get_request_header('Authorization');
if (!$authHeader) {
$this->response([
'status' => false,
'message' => 'Authorization header is missing',
], REST_Controller::HTTP_UNAUTHORIZED);
exit;
}
$parts = explode(' ', $authHeader);
if (count($parts) !== 2 || strtolower($parts[0]) !== 'bearer') {
$this->response([
'status' => false,
'message' => 'Invalid authorization format. Use: Bearer <token>',
], REST_Controller::HTTP_UNAUTHORIZED);
exit;
}
try {
$this->jwt_data = $this->jwt_library->decode($parts[1]);
} catch (\RuntimeException $e) {
$this->response([
'status' => false,
'message' => $e->getMessage(),
], REST_Controller::HTTP_UNAUTHORIZED);
exit;
}
}
}
CRUD Method-to-Route Mapping
| HTTP Verb | URI Pattern | Controller Method | Purpose |
|---|
| GET | /api/resource | index_get() | List with pagination/filters |
| GET | /api/resource/:id | show_get($id) | Single resource detail |
| POST | /api/resource | create_post() | Create new resource |
| PUT | /api/resource/:id | update_put($id) | Full or partial update |
| PATCH | /api/resource/:id | update_patch($id) | Partial update (alt) |
| DELETE | /api/resource/:id | delete_delete($id) | Delete resource |
Routing (application/config/routes.php)
$route['api/products'] = 'api/products/index';
$route['api/products/(:num)'] = 'api/products/show/$1';
$route['api/products/create'] = 'api/products/create';
$route['api/products/update/(:num)'] = 'api/products/update/$1';
$route['api/products/delete/(:num)'] = 'api/products/delete/$1';
$route['api/example'] = 'api/example';
$route['api/example/(:num)'] = 'api/example/show/$1';
5. Model Patterns (Database Query Builder)
Base Model Template
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Product_model extends CI_Model {
protected $table = 'products';
protected $primary_key = 'id';
protected $soft_delete = TRUE;
protected $allowed_fields = ['name', 'price', 'description', 'category_id', 'status'];
public function __construct() {
parent::__construct();
$this->load->database();
}
public function get_all(array $filters = [], int $limit = 20, int $offset = 0, string $order_by = 'created_at DESC'): array {
$this->_apply_filters($filters);
if ($this->soft_delete) {
$this->db->where('deleted_at IS NULL');
}
$this->db->order_by($order_by);
$this->db->limit($limit, $offset);
$query = $this->db->get($this->table);
return $query->result();
}
public function get($id): ?object {
$this->db->where($this->primary_key, $id);
if ($this->soft_delete) {
$this->db->where('deleted_at IS NULL');
}
$query = $this->db->get($this->table);
return $query->row() ?: null;
}
public function get_by(string $field, $value): ?object {
$this->db->where($field, $value);
if ($this->soft_delete) {
$this->db->where('deleted_at IS NULL');
}
$query = $this->db->get($this->table);
return $query->row() ?: null;
}
public function get_by_email(string $email): ?object {
return $this->get_by('email', $email);
}
public function create(array $data): ?int {
$data = $this->_filter_allowed($data);
$data['created_at'] = date('Y-m-d H:i:s');
$data['updated_at'] = date('Y-m-d H:i:s');
if ($this->soft_delete) {
$data['deleted_at'] = null;
}
return $this->db->insert($this->table, $data) ? $this->db->insert_id() : null;
}
public function update($id, array $data): bool {
$data = $this->_filter_allowed($data);
$data['updated_at'] = date('Y-m-d H:i:s');
$this->db->where($this->primary_key, $id);
return $this->db->update($this->table, $data);
}
public function delete($id): bool {
if ($this->soft_delete) {
return $this->update($id, ['deleted_at' => date('Y-m-d H:i:s')]);
}
$this->db->where($this->primary_key, $id);
return $this->db->delete($this->table);
}
public function count_all(array $filters = []): int {
$this->_apply_filters($filters);
if ($this->soft_delete) {
$this->db->where('deleted_at IS NULL');
}
return $this->db->count_all_results($this->table);
}
protected function _apply_filters(array $filters): void {
foreach ($filters as $field => $value) {
if (in_array($field, $this->allowed_fields)) {
if ($field === 'search') {
$this->db->group_start();
$this->db->like('name', $value);
$this->db->or_like('description', $value);
$this->db->group_end();
} else {
$this->db->where($field, $value);
}
}
}
}
protected function _filter_allowed(array $data): array {
return array_intersect_key($data, array_flip($this->allowed_fields));
}
}
Query Builder Best Practices
| Pattern | Description | Example |
|---|
| Parameterized | Always use Query Builder — never raw string interpolation | $this->db->where('id', $id) |
| Whitelist fields | Prevent mass-assignment via _filter_allowed() | array_intersect_key($data, array_flip($allowed_fields)) |
| Soft delete | Use deleted_at column instead of hard delete | $this->db->where('deleted_at IS NULL') |
| Joins | Use Query Builder join() with escaped names | $this->db->join('categories', 'categories.id = products.category_id') |
| Subqueries | Use $this->db->select() with subquery | $this->db->select('(SELECT COUNT(*) FROM orders WHERE product_id = products.id) as order_count') |
| Aggregates | Safe COUNT, SUM, AVG | $this->db->select_sum('price') |
| Transactions | Wrap multi-table writes in transactions | See error handling section |
6. Form Validation
Validation Rules Reference
CI3 built-in rules commonly used in REST APIs:
| Rule | Usage | Example |
|---|
required | Field must not be empty | 'rules' => 'required' |
valid_email | Must be a valid email | 'rules' => 'required|valid_email' |
numeric | Must be numeric | 'rules' => 'required|numeric' |
integer | Must be an integer | 'rules' => 'required|integer' |
min_length[N] | Min length N characters | 'rules' => 'required|min_length[3]' |
max_length[N] | Max length N characters | 'rules' => 'required|max_length[255]' |
exact_length[N] | Exactly N characters | 'rules' => 'exact_length[10]' |
greater_than[N] | Must be > N | 'rules' => 'greater_than[0]' |
less_than[N] | Must be < N | 'rules' => 'less_than[1000000]' |
alpha | Only alphabetic chars | 'rules' => 'alpha' |
alpha_numeric | Alpha + digits | 'rules' => 'alpha_numeric' |
alpha_dash | Alpha + digits + - _ | 'rules' => 'alpha_dash' |
matches[field] | Must match another field | 'rules' => 'matches[password_confirm]' |
is_unique[table.field] | Unique in DB | 'rules' => 'is_unique[users.email]' |
xss_clean | XSS filtering | 'rules' => 'xss_clean' |
trim | Strip whitespace | 'rules' => 'trim' |
callback_* | Custom validation method | 'rules' => 'callback_check_stock' |
Validation Pattern for REST Controllers
$this->form_validation->set_data([
'name' => $this->post('name'),
'email' => $this->post('email'),
]);
$this->form_validation->set_rules([
[
'field' => 'name',
'label' => 'Name',
'rules' => 'required|min_length[3]|max_length[255]|trim|xss_clean',
],
[
'field' => 'email',
'label' => 'Email',
'rules' => 'required|valid_email|trim|xss_clean|is_unique[users.email]',
],
[
'field' => 'password',
'label' => 'Password',
'rules' => 'required|min_length[8]|trim',
],
]);
if ($this->form_validation->run() === FALSE) {
$this->response([
'status' => false,
'message' => 'Validation failed',
'errors' => $this->form_validation->error_array(),
], REST_Controller::HTTP_UNPROCESSABLE_ENTITY);
return;
}
Custom Validation Callback
public function valid_category($category_id): bool {
$this->load->model('Category_model');
$exists = $this->Category_model->get($category_id);
if (!$exists) {
$this->form_validation->set_message('valid_category', 'The selected category does not exist.');
return false;
}
return true;
}
7. Error Handling
Consistent Error Response Envelope
Success:
{
"status": true,
"message": "Products retrieved successfully",
"data": { ... }
}
Error (general):
{
"status": false,
"message": "Product not found"
}
Error (validation):
{
"status": false,
"message": "Validation failed",
"errors": {
"email": "The Email field must contain a valid email address.",
"name": "The Name field is required."
}
}
Error (server):
{
"status": false,
"message": "An internal error occurred. Please try again later."
}
HTTP Status Code Usage
| Code | Constant | When to Use |
|---|
| 200 | HTTP_OK | Successful GET, PUT, DELETE |
| 201 | HTTP_CREATED | Successful POST (resource created) |
| 204 | HTTP_NO_CONTENT | Successful DELETE (no body needed) |
| 400 | HTTP_BAD_REQUEST | Missing required parameters or malformed request |
| 401 | HTTP_UNAUTHORIZED | Missing/invalid/expired JWT token |
| 403 | HTTP_FORBIDDEN | Valid token but insufficient permissions |
| 404 | HTTP_NOT_FOUND | Resource does not exist |
| 409 | HTTP_CONFLICT | Duplicate resource or state conflict |
| 422 | HTTP_UNPROCESSABLE_ENTITY | Validation errors |
| 429 | HTTP_TOO_MANY_REQUESTS | Rate limit exceeded |
| 500 | HTTP_INTERNAL_SERVER_ERROR | Unexpected server error |
Database Transaction with Try-Catch
public function create_order_post() {
$this->is_logged_in();
$this->load->model('Order_model');
$this->load->model('Order_item_model');
$this->db->trans_start();
try {
$orderId = $this->Order_model->create([
'user_id' => $this->jwt_data->user_id,
'total' => $this->post('total'),
'status' => 'pending',
]);
if (!$orderId) {
throw new \RuntimeException('Failed to create order');
}
$items = $this->post('items') ?: [];
foreach ($items as $item) {
$itemCreated = $this->Order_item_model->create([
'order_id' => $orderId,
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $item['price'],
]);
if (!$itemCreated) {
throw new \RuntimeException('Failed to create order item');
}
}
$this->db->trans_complete();
$this->response([
'status' => true,
'message' => 'Order created successfully',
'data' => ['order_id' => $orderId],
], REST_Controller::HTTP_CREATED);
} catch (\Exception $e) {
$this->db->trans_rollback();
log_message('error', 'Order creation failed: ' . $e->getMessage());
$this->response([
'status' => false,
'message' => 'Failed to create order. Please try again.',
], REST_Controller::HTTP_INTERNAL_SERVER_ERROR);
}
}
Global Exception Handling
In application/config/config.php, enable hook-based error handling:
$config['log_threshold'] = 1;
Create application/hooks/App_hook.php:
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class App_hook {
public function handle_404() {
$ci =& get_instance();
if (strpos($_SERVER['REQUEST_URI'], '/api/') !== false) {
$ci->load->library('REST_Controller');
$ci->response([
'status' => false,
'message' => 'Endpoint not found',
], REST_Controller::HTTP_NOT_FOUND);
exit;
}
}
}
8. Security
8.1 XSS Prevention
| Layer | Implementation | Notes |
|---|
| Input filtering | Use xss_clean in validation rules | 'rules' => 'required|xss_clean' |
| Global XSS filter | $config['global_xss_filtering'] = TRUE; in config.php | Filters all $_GET, $_POST, $_COOKIE (deprecated but still works in CI3) |
| Output encoding | Use htmlspecialchars() or $this->security->xss_clean() | Never trust user data in output |
| Security helper | $this->load->helper('security'); | Provides xss_clean() function |
8.2 CSRF Protection
For REST APIs, CSRF is typically disabled because APIs use token-based auth (JWT). However, if your API also serves web views:
$config['csrf_protection'] = TRUE;
$config['csrf_token_name'] = 'csrf_token';
$config['csrf_cookie_name'] = 'csrf_cookie';
$config['csrf_expire'] = 7200;
$config['csrf_exclude_uris'] = [
'api/.*',
];
8.3 SQL Injection Prevention
CI3's Query Builder automatically escapes values. Never bypass it.
$this->db->where('email', $email);
$this->db->get('users');
$this->db->query('SELECT * FROM users WHERE email = ?', [$email]);
$this->db->query("SELECT * FROM users WHERE email = '$email'");
$this->db->where("name LIKE '%" . $search . "%'");
Anti-pattern checklist:
| Anti-pattern | Fix |
|---|
$this->db->query("WHERE id = $id") | $this->db->where('id', $id) |
"email = '$email'" | $this->db->where('email', $email) |
LIKE '%$term%' | $this->db->like('name', $term) |
IN ($ids) | $this->db->where_in('id', $ids_array) |
ORDER BY $_GET['sort'] | Whitelist allowed sort columns |
8.4 Additional Security Measures
Password hashing:
$data['password'] = password_hash($this->post('password'), PASSWORD_BCRYPT, ['cost' => 12]);
if (password_verify($this->post('password'), $user->password)) { }
Rate limiting (RestServer built-in):
$config['rest_limits'] = [
'api/products/index' => 60,
'api/products/create' => 30,
'api/auth/login' => 10,
];
Input sanitization before database:
$allowed = ['name', 'email', 'role'];
$data = array_intersect_key($input, array_flip($allowed));
$data = array_map(function ($value) {
return is_string($value) ? str_replace("\0", '', $value) : $value;
}, $data);
CORS headers (for client-side apps):
$hook['post_controller_constructor'] = [
'class' => 'Cors_hook',
'function' => 'set_headers',
'filename' => 'Cors_hook.php',
'filepath' => 'hooks',
];
class Cors_hook {
public function set_headers() {
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
header('Access-Control-Max-Age: 86400');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
}
}
Environment-based secret management:
JWT_SECRET=your-production-secret-here
DB_PASSWORD=secure-db-pass
$config['jwt_key'] = getenv('JWT_SECRET') ?: 'fallback-dev-only';
9. Response Envelope (Standard Contract)
Every API response must follow this contract:
{
"status": "<boolean> true | false",
"message": "<string> human-readable summary",
"data": "<object|array|null> payload (omitted on simple errors)"
}
For validation errors, include an errors object:
{
"status": false,
"message": "Validation failed",
"errors": {
"field_name": "Error message for that field"
}
}
For paginated lists, data contains:
{
"status": true,
"message": "Products retrieved successfully",
"data": {
"items": [ ... ],
"total": 42,
"page": 1,
"per_page": 20,
"pages": 3
}
}
10. Verification & Testing
Curl Commands for Manual Testing
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"secret123"}'
curl http://localhost:8000/api/products?page=1&limit=20 \
-H "Authorization: Bearer <TOKEN>"
curl http://localhost:8000/api/products/1 \
-H "Authorization: Bearer <TOKEN>"
curl -X POST http://localhost:8000/api/products \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TOKEN>" \
-d '{"name":"New Product","price":29.99,"category_id":1}'
curl -X PUT http://localhost:8000/api/products/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TOKEN>" \
-d '{"name":"Updated Name","price":39.99}'
curl -X DELETE http://localhost:8000/api/products/1 \
-H "Authorization: Bearer <TOKEN>"
curl -X POST http://localhost:8000/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"<REFRESH_TOKEN>"}'
Postman Collection
If the IT Leader's delegation includes postmanSync: true, use the api-documentation skill to sync a collection with:
- Auth endpoints (login, refresh)
- CRUD endpoints (list, detail, create, update, delete)
- Error scenarios (401, 404, 422, 500)
- Environment variables (
base_url, token, refresh_token)
11. Quick Reference — File Creation Checklist
When building a new CRUD resource:
| Step | File | Action |
|---|
| 1 | application/controllers/api/<Resource>.php | Create REST controller extending REST_Controller |
| 2 | application/models/<Resource>_model.php | Create model with Query Builder methods |
| 3 | application/config/routes.php | Add route entries for the resource |
| 4 | — | Ensure JWT library is available in application/libraries/JWT_library.php |
| 5 | — | Add validation rules matching the resource schema |
| 6 | — | Test each endpoint with curl |
12. Do's and Don'ts
Do:
- Always use Query Builder for database interactions
- Always validate input with form_validation
- Always hash passwords with
password_hash(PASSWORD_BCRYPT)
- Always return consistent response envelopes
- Always handle missing resources with 404
- Always log errors server-side with
log_message()
- Always filter allowed fields before insert/update
- Use transactions for multi-table writes
Don't:
- Never use raw SQL with string interpolation
- Never expose stack traces or debug output in responses
- Never bypass JWT validation on protected endpoints
- Never store plaintext passwords
- Never trust user input without validation
- Never hardcode secrets in config files (use environment variables)
- Never allow mass-assignment without field whitelisting
- Never use
extract() or parse_str() on user input
- Never commit
.env files to version control
13. Common Mistakes
| Mistake | Consequence | Fix |
|---|
| Forgetting JWT check on create/update/delete | Unauthenticated access | Call $this->is_logged_in() at start of protected methods |
Using $this->input->post() instead of $this->post() | Mixed input sources | Use RestServer's $this->post('key') |
| Not filtering allowed fields | Mass-assignment vulnerability | Always use _filter_allowed() in models |
| Returning raw DB error messages | Information leakage to client | Always wrap DB operations and return safe messages |
| Missing pagination on list endpoints | Performance issues for large datasets | Always implement limit/offset with page/per_page |
| Hardcoding JWT secret | Security breach | Use getenv('JWT_SECRET') with fallback |
| Not setting CORS headers | Frontend CORS errors | Add CORS hook for client-side apps |
Using $config['global_xss_filtering'] = TRUE | Modifies POST/PUT data unexpectedly | Use per-field xss_clean rule instead |
14. Skill Usage Examples
@ci3-rest-api Create GET /api/categories endpoint with JWT auth and pagination
@ci3-rest-api Add form validation for product create/update
@ci3-rest-api Implement JWT login/register flow
@ci3-rest-api Build soft-delete pattern for the inventory model
@ci3-rest-api Set up CORS and rate limiting for public endpoints
This skill provides complete CI3 REST API development patterns: RestServer controllers, JWT auth, CRUD models, form validation, security hardening, and consistent response contracts. Every endpoint follows the same predictable structure for maintainability and developer experience.