Dulu waktu pertama kali bikin API pakai CodeIgniter 4, saya cuma pakai controller biasa return JSON. Work sih, tapi begitu butuh authentication, rate limiting, dan proper error handling, semuanya berantakan. Setelah refactoring tiga kali, akhirnya saya nemu pola yang rapi dan reusable.
Di artikel ini, saya share cara bangun REST API yang production-ready pakai CI4. Dari setup awal, JWT authentication, sampai error handling yang konsisten. Semua code sudah saya pakai di project real, bukan sekadar teori.
CI4 itu ringan banget dibanding Laravel. Memory footprint kecil, boot time cepat, dan routing-nya fleksibel. Buat API yang perlu handle banyak request per detik, CI4 punya overhead yang jauh lebih kecil. Plus, built-in features kayak Filter, Entity, dan Model udah cukup buat bikin API yang solid tanpa banyak dependency tambahan.
Kelebihan CI4 buat API development:
$this->respond() dan $this->fail() udah tersediaInstall CI4 via Composer, langsung buat project baru:
composer create-project codeigniter4/appstarter ci4-api
cd ci4-api
Setelah install, copy file environment:
cp env .env
Edit .env, set CI_ENVIRONMENT = development dan konfigurasi database:
CI_ENVIRONMENT = development
database.default.hostname = localhost
database.default.database = ci4_api
database.default.username = root
database.default.password =
database.default.DBDriver = MySQLi
Buat database dan tabel untuk users:
CREATE DATABASE ci4_api CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE ci4_api;
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
api_key VARCHAR(64) DEFAULT NULL,
status TINYINT DEFAULT 1,
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
deleted_at DATETIME DEFAULT NULL
);
CREATE TABLE products (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
price DECIMAL(12,2) NOT NULL DEFAULT 0,
stock INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
deleted_at DATETIME DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CI4 punya konsep Entity yang bikin data handling lebih bersih. Buat model dan entity untuk users:
<?php
// app/Models/UserModel.php
namespace App\Models;
use CodeIgniter\Model;
class UserModel extends Model
{
protected $table = 'users';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = \App\Entities\User::class;
protected $useSoftDeletes = true;
protected $allowedFields = ['name', 'email', 'password', 'api_key', 'status'];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
protected $validationRules = [
'name' => 'required|min_length[3]|max_length[100]',
'email' => 'required|valid_email|is_unique[users.email,id,{id}]',
'password' => 'required|min_length[8]',
];
protected $validationMessages = [
'email' => [
'is_unique' => 'Email sudah terdaftar',
],
];
protected $beforeInsert = ['hashPassword'];
protected $beforeUpdate = ['hashPassword'];
protected function hashPassword(array $data): array
{
if (isset($data['data']['password'])) {
$data['data']['password'] = password_hash($data['data']['password'], PASSWORD_BCRYPT);
}
return $data;
}
}
Buat Entity class:
<?php
// app/Entities/User.php
namespace App\Entities;
use CodeIgniter\Entity\Entity;
class User extends Entity
{
protected $datamap = [];
protected $dates = ['created_at', 'updated_at', 'deleted_at'];
protected $casts = [
'id' => 'integer',
'status' => 'integer',
];
// Jangan expose password di response
public function toSafeArray(): array
{
$data = $this->toArray();
unset($data['password']);
unset($data['api_key']);
return $data;
}
}
Buat JWT tanpa library tambahan, kita bisa pakai cara manual dengan HMAC-SHA256. Tapi biar lebih reliable, install firebase/php-jwt:
composer require firebase/php-jwt
Buat helper class untuk handle JWT:
<?php
// app/Libraries/JWTHandler.php
namespace App\Libraries;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Config\App;
class JWTHandler
{
private string $secretKey;
private string $algo = 'HS256';
private int $expiry = 3600; // 1 jam
public function __construct()
{
$this->secretKey = env('jwt.secret', 'ganti-dengan-secret-key-yang-panjang-dan-random');
}
public function generateToken(int $userId, string $email): string
{
$issuedAt = time();
$expire = $issuedAt + $this->expiry;
$payload = [
'iss' => 'ci4-api',
'sub' => $userId,
'iat' => $issuedAt,
'exp' => $expire,
'email' => $email,
];
return JWT::encode($payload, $this->secretKey, $this->algo);
}
public function validateToken(string $token): ?object
{
try {
$decoded = JWT::decode($token, new Key($this->secretKey, $this->algo));
return $decoded;
} catch (\Exception $e) {
return null;
}
}
}
Tambahkan secret key di .env:
jwt.secret = k0d3-s3cr3t-y4ng-s4ng4t-p4nj4ng-d4n-r4nd0m-12345
CI4 pakai Filter untuk intercept request sebelum masuk controller. Ini lebih clean daripada cek auth di setiap method:
<?php
// app/Filters/JWTAuthFilter.php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
use App\Libraries\JWTHandler;
class JWTAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$header = $request->getHeaderLine('Authorization');
if (!$header || !str_starts_with($header, 'Bearer ')) {
return service('response')
->setStatusCode(401)
->setJSON([
'success' => false,
'message' => 'Token tidak ditemukan, silakan login dulu',
]);
}
$token = substr($header, 7);
$jwt = new JWTHandler();
$decoded = $jwt->validateToken($token);
if (!$decoded) {
return service('response')
->setStatusCode(401)
->setJSON([
'success' => false,
'message' => 'Token expired atau tidak valid',
]);
}
// Simpan user info di request supaya bisa diakses controller
service('request')->user = $decoded;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// Tidak perlu apa-apa di sini
}
}
Daftarkan filter di app/Config/Filters.php:
<?php
// app/Config/Filters.php - tambahkan di property $aliases
public array $aliases = [
'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class,
'invalidchars' => InvalidChars::class,
'secureheaders' => SecureHeaders::class,
'jwt-auth' => \App\Filters\JWTAuthFilter::class,
];
Biar semua API response konsisten, bikin base controller:
<?php
// app/Controllers/BaseApiController.php
namespace App\Controllers;
use CodeIgniter\RESTful\ResourceController;
class BaseApiController extends ResourceController
{
protected string $format = 'json';
protected function respondSuccess($data = null, string $message = 'OK', int $code = 200)
{
return $this->respond([
'success' => true,
'message' => $message,
'data' => $data,
], $code);
}
protected function respondError(string $message, int $code = 400, $errors = null)
{
$response = [
'success' => false,
'message' => $message,
];
if ($errors) {
$response['errors'] = $errors;
}
return $this->respond($response, $code);
}
protected function respondPaginated($data, int $total, int $page, int $perPage)
{
return $this->respond([
'success' => true,
'data' => $data,
'meta' => [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => ceil($total / $perPage),
],
]);
}
}
<?php
// app/Controllers/AuthController.php
namespace App\Controllers;
use App\Models\UserModel;
use App\Libraries\JWTHandler;
class AuthController extends BaseApiController
{
public function register()
{
$rules = [
'name' => 'required|min_length[3]|max_length[100]',
'email' => 'required|valid_email|is_unique[users.email]',
'password' => 'required|min_length[8]',
];
if (!$this->validate($rules)) {
return $this->respondError('Validasi gagal', 422, $this->validator->getErrors());
}
$model = new UserModel();
$userId = $model->insert([
'name' => $this->request->getVar('name'),
'email' => $this->request->getVar('email'),
'password' => $this->request->getVar('password'),
]);
if (!$userId) {
return $this->respondError('Gagal membuat akun', 500, $model->errors());
}
$jwt = new JWTHandler();
$token = $jwt->generateToken($userId, $this->request->getVar('email'));
return $this->respondSuccess([
'user' => $model->find($userId)->toSafeArray(),
'token' => $token,
], 'Registrasi berhasil', 201);
}
public function login()
{
$email = $this->request->getVar('email');
$password = $this->request->getVar('password');
if (!$email || !$password) {
return $this->respondError('Email dan password wajib diisi');
}
$model = new UserModel();
$user = $model->where('email', $email)->first();
if (!$user || !password_verify($password, $user->password)) {
return $this->respondError('Email atau password salah', 401);
}
$jwt = new JWTHandler();
$token = $jwt->generateToken($user->id, $user->email);
return $this->respondSuccess([
'user' => $user->toSafeArray(),
'token' => $token,
], 'Login berhasil');
}
public function profile()
{
$userId = service('request')->user->sub;
$model = new UserModel();
$user = $model->find($userId);
if (!$user) {
return $this->respondError('User tidak ditemukan', 404);
}
return $this->respondSuccess($user->toSafeArray());
}
}
Sekarang bikin controller untuk CRUD products. Perhatikan gimana kita pakai filter jwt-auth untuk protect route:
<?php
// app/Controllers/ProductController.php
namespace App\Controllers;
use App\Models\ProductModel;
class ProductController extends BaseApiController
{
public function index()
{
$page = (int) ($this->request->getGet('page') ?? 1);
$perPage = (int) ($this->request->getGet('per_page') ?? 10);
$search = $this->request->getGet('search');
$model = new ProductModel();
$query = $model->where('user_id', service('request')->user->sub);
if ($search) {
$query = $query->groupStart()
->like('name', $search)
->orLike('description', $search)
->groupEnd();
}
$total = $query->countAllResults(false);
$data = $query->paginate($perPage, 'default', $page);
return $this->respondPaginated($data, $total, $page, $perPage);
}
public function create()
{
$rules = [
'name' => 'required|max_length[200]',
'description' => 'permit_empty|string',
'price' => 'required|decimal|greater_than[0]',
'stock' => 'required|integer|greater_than_equal_to[0]',
];
if (!$this->validate($rules)) {
return $this->respondError('Validasi gagal', 422, $this->validator->getErrors());
}
$model = new ProductModel();
$data = [
'user_id' => service('request')->user->sub,
'name' => $this->request->getVar('name'),
'description' => $this->request->getVar('description'),
'price' => $this->request->getVar('price'),
'stock' => $this->request->getVar('stock'),
];
$id = $model->insert($data);
if (!$id) {
return $this->respondError('Gagal menambahkan produk', 500, $model->errors());
}
return $this->respondSuccess($model->find($id), 'Produk berhasil ditambahkan', 201);
}
public function show($id = null)
{
$model = new ProductModel();
$product = $model->where('user_id', service('request')->user->sub)->find($id);
if (!$product) {
return $this->respondError('Produk tidak ditemukan', 404);
}
return $this->respondSuccess($product);
}
public function update($id = null)
{
$model = new ProductModel();
$product = $model->where('user_id', service('request')->user->sub)->find($id);
if (!$product) {
return $this->respondError('Produk tidak ditemukan', 404);
}
$rules = [
'name' => 'required|max_length[200]',
'description' => 'permit_empty|string',
'price' => 'required|decimal|greater_than[0]',
'stock' => 'required|integer|greater_than_equal_to[0]',
];
if (!$this->validate($rules)) {
return $this->respondError('Validasi gagal', 422, $this->validator->getErrors());
}
$model->update($id, [
'name' => $this->request->getVar('name'),
'description' => $this->request->getVar('description'),
'price' => $this->request->getVar('price'),
'stock' => $this->request->getVar('stock'),
]);
return $this->respondSuccess($model->find($id), 'Produk berhasil diupdate');
}
public function delete($id = null)
{
$model = new ProductModel();
$product = $model->where('user_id', service('request')->user->sub)->find($id);
if (!$product) {
return $this->respondError('Produk tidak ditemukan', 404);
}
$model->delete($id);
return $this->respondSuccess(null, 'Produk berhasil dihapus');
}
}
Buat model untuk products:
<?php
// app/Models/ProductModel.php
namespace App\Models;
use CodeIgniter\Model;
class ProductModel extends Model
{
protected $table = 'products';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'object';
protected $useSoftDeletes = true;
protected $allowedFields = ['user_id', 'name', 'description', 'price', 'stock'];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
protected $validationRules = [
'name' => 'required|max_length[200]',
'price' => 'required|decimal',
];
}
Sekarang daftarkan semua route di app/Config/Routes.php:
<?php
// app/Config/Routes.php
use CodeIgniter\Router\RouteCollection;
/**
* @var RouteCollection $routes
*/
// Public routes
$routes->group('api', function ($routes) {
$routes->post('auth/register', 'AuthController::register');
$routes->post('auth/login', 'AuthController::login');
});
// Protected routes (butuh JWT token)
$routes->group('api', ['filter' => 'jwt-auth'], function ($routes) {
$routes->get('auth/profile', 'AuthController::profile');
// Products CRUD
$routes->get('products', 'ProductController::index');
$routes->post('products', 'ProductController::create');
$routes->get('products/(:num)', 'ProductController::show/$1');
$routes->put('products/(:num)', 'ProductController::update/$1');
$routes->delete('products/(:num)', 'ProductController::delete/$1');
});
Supaya semua error response konsisten, override method initController di base controller atau pakai Events:
<?php
// app/Config/Events.php - tambahkan
use CodeIgniter\Events\Events;
Events::on('pre_system', function () {
// Custom 404 response untuk API
$request = service('request');
if (str_starts_with($request->getPath(), 'api/')) {
// Override error handler untuk API routes
}
});
Alternatif yang lebih simpel, tambahkan method ini di BaseApiController:
// Tambahkan di BaseApiController
protected function failNotFound(string $message = 'Resource tidak ditemukan')
{
return $this->respond([
'success' => false,
'message' => $message,
], 404);
}
protected function failForbidden(string $message = 'Akses ditolak')
{
return $this->respond([
'success' => false,
'message' => $message,
], 403);
}
Sekarang coba test semua endpoint. Register user baru:
# Register
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name": "Budi Santoso", "email": "[email protected]", "password": "rahasia123"}'
Response yang diharapkan:
{
"success": true,
"message": "Registrasi berhasil",
"data": {
"user": {
"id": 1,
"name": "Budi Santoso",
"email": "[email protected]",
"status": 1
},
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
}
}
Login dan simpan token:
# Login - simpan token
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "rahasia123"}' | jq -r '.data.token')
echo "Token: $TOKEN"
Tambah produk:
# Create product
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Laptop ASUS ROG", "description": "Laptop gaming", "price": 15000000, "stock": 5}'
Get semua products dengan search:
# List products with search
curl -X GET "http://localhost:8080/api/products?search=asus&per_page=5&page=1" \
-H "Authorization: Bearer $TOKEN"
Update product:
# Update product
curl -X PUT http://localhost:8080/api/products/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Laptop ASUS ROG Strix", "description": "Laptop gaming updated", "price": 16000000, "stock": 3}'
Delete product:
# Delete product
curl -X DELETE http://localhost:8080/api/products/1 \
-H "Authorization: Bearer $TOKEN"
Kalau API diakses dari frontend beda domain, CORS wajib dikonfigurasi. Buat CORS filter:
<?php
// app/Filters/CorsFilter.php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
class CorsFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
// Handle preflight request
if ($request->getMethod() === 'options') {
$response = service('response');
$response->setHeader('Access-Control-Allow-Origin', '*');
$response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
$response->setHeader('Access-Control-Max-Age', '86400');
$response->setStatusCode(200);
return $response;
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
$response->setHeader('Access-Control-Allow-Origin', '*');
$response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
}
}
Daftarkan di Filters.php dan pakai di route groups:
// Di Filters.php
public array $aliases = [
// ... existing
'cors' => \App\Filters\CorsFilter::class,
];
// Di Routes.php - pakai cors filter di semua API routes
$routes->group('api', ['filter' => ['cors', 'jwt-auth']], function ($routes) {
// ... protected routes
});
Supaya API nggak di-spam, tambahkan rate limiting pakai cache:
<?php
// app/Filters/RateLimitFilter.php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
class RateLimitFilter implements FilterInterface
{
private int $maxRequests = 60;
private int $windowSeconds = 60;
public function before(RequestInterface $request, $arguments = null)
{
$cache = service('cache');
$ip = $request->getIPAddress();
$key = 'rate_limit_' . md5($ip);
$requests = $cache->get($key);
if ($requests === null) {
$cache->save($key, 1, $this->windowSeconds);
return;
}
if ($requests >= $this->maxRequests) {
$retryAfter = $this->windowSeconds;
return service('response')
->setStatusCode(429)
->setHeader('Retry-After', (string) $retryAfter)
->setJSON([
'success' => false,
'message' => "Terlalu banyak request, coba lagi dalam {$retryAfter} detik",
]);
}
$cache->save($key, $requests + 1, $this->windowSeconds);
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// Tidak perlu
}
}
Saat deploy ke production, jangan lupa:
openssl rand -base64 64toolbar = false di .envContoh nginx config untuk CI4 API:
server {
listen 80;
server_name api.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name api.example.com;
root /var/www/ci4-api/public;
index index.php;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
# Security headers
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
}
Beberapa hal yang sering saya lihat di project API orang lain dan bikin masalah di production:
toSafeArray() atau castlog_message('error', ...) supaya gampang debug/api/v1/ supaya bisa update tanpa break client lamaItu dia cara bangun REST API production-ready pakai CodeIgniter 4. Dari authentication sampai deployment, semua sudah tercover. Pola ini udah saya pakai di beberapa project client dan sejauh ini stabil handle traffic lumayan tinggi. Kalau ada pertanyaan atau mau diskusi soal API design, boleh tinggalin komentar di bawah.