Pertama kali bikin REST API pakai CodeIgniter 4, saya agak bingung karena framework ini punya cara sendiri yang beda dari Laravel atau Express. Tapi setelah paham polanya, malah jadi lebih cepat buat prototyping. Di tutorial ini, saya mau sharing cara bikin REST API dari nol pakai CI4, lengkap sampai bisa dipakai sama frontend atau mobile app.
Kalau kamu baru mulai belajar CI4 atau mau bikin backend buat project Android/iOS, tutorial ini pas banget. Kita akan bikin API CRUD lengkap dengan validasi, autentikasi pakai token, dan handling error yang proper. Sebelum mulai, pastikan kamu sudah paham dasar PHP OOP dan punya Composer terinstall di komputer.
Ada beberapa alasan kenapa CI4 cocok banget buat bikin REST API:
Dibanding Laravel, CI4 memang lebih minimalis. Tapi untuk REST API sederhana sampai menengah, CI4 bisa diandalkan. Kalau project kamu butuh fitur enterprise seperti queue, event broadcasting, atau complex ORM relationship, baru deh pertimbangkan Laravel.
Pertama, install CI4 via Composer:
composer create-project codeigniter4/appstarter ci4-api
cd ci4-api
cp env .env
Buka file .env dan ubah environment ke development supaya error message tampil detail:
CI_ENVIRONMENT = development
Kalau kamu pakai MySQL, konfigurasi database di app/Config/Database.php:
<?php
namespace Config;
class Database extends \Config\Database
{
public $default = [
'DSN' => '',
'hostname' => '127.0.0.1',
'username' => 'root',
'password' => '',
'database' => 'ci4_api',
'DBDriver' => 'MySQLi',
'DBPrefix' => '',
'pConnect' => false,
'DBDebug' => true,
'charset' => 'utf8mb4',
'DBCollat' => 'utf8mb4_general_ci',
];
}
Jangan lupa buat database dan tabelnya dulu:
CREATE DATABASE ci4_api CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE ci4_api;
CREATE TABLE products (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10,2) NOT NULL DEFAULT 0,
stock INT UNSIGNED NOT NULL DEFAULT 0,
category VARCHAR(100),
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
deleted_at DATETIME DEFAULT NULL
) ENGINE=InnoDB;
Kenapa pakai DECIMAL(10,2) buat harga? Karena FLOAT dan DOUBLE punya masalah presisi floating point. 0.1 + 0.2 bisa jadi 0.30000000000000004. Buat aplikasi yang handle uang, selalu pakai DECIMAL.
CI4 punya fitur migration yang mirip dengan Laravel. Generate migration file:
php spark make:migration CreateProductsTable
Isi file migration yang ter-generate:
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateProductsTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'unsigned' => true,
'auto_increment' => true,
],
'name' => [
'type' => 'VARCHAR',
'constraint' => 255,
],
'description' => [
'type' => 'TEXT',
'null' => true,
],
'price' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'default' => 0,
],
'stock' => [
'type' => 'INT',
'unsigned' => true,
'default' => 0,
],
'category' => [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true,
],
'is_active' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 1,
],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->createTable('products');
}
public function down()
{
$this->forge->dropTable('products');
}
}
Jalankan migration:
php spark migrate
Sekarang bikin model. CI4 punya fitur allowedFields yang otomatis filter input supaya kolom yang tidak di-whitelist tidak bisa di-insert atau di-update. Ini penting banget untuk keamanan.
<?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 $useTimestamps = true;
protected $allowedFields = [
'name', 'description', 'price', 'stock', 'category', 'is_active'
];
protected $validationRules = [
'name' => 'required|min_length[3]|max_length[255]',
'price' => 'required|decimal|greater_than[0]',
'stock' => 'permit_empty|integer|greater_than_equal_to[0]',
];
protected $validationMessages = [
'name' => [
'required' => 'Nama produk wajib diisi',
'min_length' => 'Nama produk minimal 3 karakter',
],
'price' => [
'required' => 'Harga wajib diisi',
'greater_than' => 'Harga harus lebih dari 0',
],
];
}
Perhatikan $useSoftDeletes = true - ini artinya record tidak benar-benar dihapus dari database, tapi kolom deleted_at diisi dengan timestamp. Data masih ada kalau kamu perlu restore nanti.
CI4 punya fitur Resource routing yang otomatis bikin semua endpoint CRUD. Buka app/Config/Routes.php:
<?php
use CodeIgniter\Router\RouteCollection;
$routes->group('api', ['namespace' => 'App\Controllers\Api'], function ($routes) {
// Resource route - otomatis bikin GET, POST, PUT, DELETE
$routes->resource('products', ['controller' => 'ProductController']);
// Kalau mau versioning
$routes->group('v1', function ($routes) {
$routes->resource('products', ['controller' => 'ProductController']);
});
});
Dengan satu baris $routes->resource('products'), CI4 otomatis bikin endpoint ini:
GET /api/products - List semua produkGET /api/products/(:id) - Detail produk by IDPOST /api/products - Buat produk baruPUT /api/products/(:id) - Update produkPATCH /api/products/(:id) - Partial updateDELETE /api/products/(:id) - Hapus produkTapi kadang kamu butuh endpoint custom di luar CRUD standar. Misalnya endpoint untuk search atau bulk operations. Kamu bisa tambahkan manual:
<?php
$routes->group('api', ['namespace' => 'App\Controllers\Api'], function ($routes) {
$routes->resource('products', ['controller' => 'ProductController']);
// Custom endpoints
$routes->get('products/search/(:segment)', 'ProductController::search/$1');
$routes->post('products/bulk', 'ProductController::bulkCreate');
$routes->get('products/category/(:segment)', 'ProductController::byCategory/$1');
});
Ini bagian inti dari tutorial ini. Controller ini handle semua request dan response format JSON:
<?php
namespace App\Controllers\Api;
use App\Controllers\BaseController;
use App\Models\ProductModel;
use CodeIgniter\HTTP\ResponseInterface;
class ProductController extends BaseController
{
protected $productModel;
public function __construct()
{
$this->productModel = new ProductModel();
}
/**
* GET /api/products
* List semua produk dengan pagination
*/
public function index()
{
$page = $this->request->getVar('page') ?? 1;
$perPage = $this->request->getVar('per_page') ?? 10;
$search = $this->request->getVar('search');
$category = $this->request->getVar('category');
$builder = $this->productModel;
// Filter by category
if ($category) {
$builder = $builder->where('category', $category);
}
// Search by name
if ($search) {
$builder = $builder->like('name', $search);
}
$products = $builder->paginate($perPage, 'default', $page);
$pager = $this->productModel->pager;
return $this->response->setJSON([
'status' => 'success',
'data' => $products,
'meta' => [
'current_page' => $pager->getCurrentPage('default'),
'per_page' => $perPage,
'total' => $pager->getTotal('default'),
'total_pages' => $pager->getPageCount('default'),
],
]);
}
/**
* GET /api/products/(:id)
* Detail produk by ID
*/
public function show($id = null)
{
$product = $this->productModel->find($id);
if (!$product) {
return $this->response->setStatusCode(404)->setJSON([
'status' => 'error',
'message' => 'Produk tidak ditemukan',
]);
}
return $this->response->setJSON([
'status' => 'success',
'data' => $product,
]);
}
/**
* POST /api/products
* Buat produk baru
*/
public function create()
{
$data = $this->request->getJSON(true);
// Validasi manual (opsional, karena model sudah ada validation rules)
if (!$this->validate([
'name' => 'required|min_length[3]|max_length[255]',
'price' => 'required|decimal|greater_than[0]',
])) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 'error',
'message' => 'Validasi gagal',
'errors' => $this->validator->getErrors(),
]);
}
$productId = $this->productModel->insert($data);
if ($this->productModel->errors()) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 'error',
'message' => 'Validasi gagal',
'errors' => $this->productModel->errors(),
]);
}
$product = $this->productModel->find($productId);
return $this->response->setStatusCode(201)->setJSON([
'status' => 'success',
'message' => 'Produk berhasil dibuat',
'data' => $product,
]);
}
/**
* PUT /api/products/(:id)
* Update produk
*/
public function update($id = null)
{
$product = $this->productModel->find($id);
if (!$product) {
return $this->response->setStatusCode(404)->setJSON([
'status' => 'error',
'message' => 'Produk tidak ditemukan',
]);
}
$data = $this->request->getJSON(true);
$this->productModel->update($id, $data);
if ($this->productModel->errors()) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 'error',
'message' => 'Validasi gagal',
'errors' => $this->productModel->errors(),
]);
}
$updated = $this->productModel->find($id);
return $this->response->setJSON([
'status' => 'success',
'message' => 'Produk berhasil diupdate',
'data' => $updated,
]);
}
/**
* DELETE /api/products/(:id)
* Soft delete produk
*/
public function delete($id = null)
{
$product = $this->productModel->find($id);
if (!$product) {
return $this->response->setStatusCode(404)->setJSON([
'status' => 'error',
'message' => 'Produk tidak ditemukan',
]);
}
$this->productModel->delete($id);
return $this->response->setJSON([
'status' => 'success',
'message' => 'Produk berhasil dihapus',
]);
}
/**
* GET /api/products/search/(:segment)
* Cari produk by keyword
*/
public function search($keyword = '')
{
$products = $this->productModel
->like('name', $keyword)
->orLike('description', $keyword)
->findAll();
return $this->response->setJSON([
'status' => 'success',
'data' => $products,
'total' => count($products),
]);
}
/**
* GET /api/products/category/(:segment)
* Ambil produk by kategori
*/
public function byCategory($category = '')
{
$products = $this->productModel
->where('category', $category)
->where('is_active', 1)
->findAll();
return $this->response->setJSON([
'status' => 'success',
'category' => $category,
'data' => $products,
'total' => count($products),
]);
}
}
Kalau API kamu diakses dari frontend yang beda domain (misal React di localhost:3000 dan API di localhost:8080), kamu perlu handle CORS. CI4 punya filter CORS bawaan.
Buat file app/Filters/CorsFilter.php:
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class CorsFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
// Preflight request - langsung return
if ($request->getMethod(true) === 'OPTIONS') {
return service('response')
->setStatusCode(200)
->setHeader('Access-Control-Allow-Origin', '*')
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With')
->setHeader('Access-Control-Max-Age', '86400');
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
$response->setHeader('Access-Control-Allow-Origin', '*')
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
}
}
Register filter di app/Config/Filters.php:
<?php
public array $filters = [
'cors' => [
'before' => ['api/*'],
'after' => ['api/*'],
],
];
Kalau kamu pernah kena masalah CORS error, baca tutorial lengkapnya di cara mengatasi CORS error yang sudah saya tulis sebelumnya.
API tanpa autentikasi itu berbahaya. Siapa saja bisa create, update, atau delete data kamu. Kita pakai pendekatan sederhana dengan API key yang disimpan di database.
Buat tabel untuk API keys:
CREATE TABLE api_keys (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
api_key VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(100),
last_used_at DATETIME DEFAULT NULL,
expires_at DATETIME DEFAULT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL
) ENGINE=InnoDB;
Bikin filter autentikasi:
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class AuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$apiKey = $request->getHeaderLine('Authorization');
// Support format: "Bearer YOUR_API_KEY" atau langsung key
if (strpos($apiKey, 'Bearer ') === 0) {
$apiKey = substr($apiKey, 7);
}
if (empty($apiKey)) {
return service('response')->setStatusCode(401)->setJSON([
'status' => 'error',
'message' => 'API key tidak ditemukan. Kirim header Authorization: Bearer YOUR_API_KEY',
]);
}
$db = \Config\Database::connect();
$key = $db->table('api_keys')
->where('api_key', $apiKey)
->where('is_active', 1)
->get()
->getRow();
if (!$key) {
return service('response')->setStatusCode(401)->setJSON([
'status' => 'error',
'message' => 'API key tidak valid atau sudah expired',
]);
}
// Update last_used_at
$db->table('api_keys')
->where('id', $key->id)
->update(['last_used_at' => date('Y-m-d H:i:s')]);
// Simpan user_id ke request supaya controller bisa akses
$request->user_id = $key->user_id;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// Nothing to do after
}
}
Register di app/Config/Filters.php:
<?php
public array $filters = [
'cors' => [
'before' => ['api/*'],
'after' => ['api/*'],
],
'auth' => [
'before' => ['api/products'],
],
];
Cara generate API key yang aman:
<?php
// Generate random 64-char hex string
$apiKey = bin2hex(random_bytes(32));
echo $apiKey;
// Output: a1b2c3d4e5f6... (64 karakter)
Kesalahan banyak developer: error handling asal-asalan. API return HTML error page dari CI4, bukan JSON. Frontend bingung karena expect JSON tapi dapat HTML.
Override method initController di BaseController:
<?php
namespace App\Controllers\Api;
use CodeIgniter\API\ResponseTrait;
class BaseController extends \App\Controllers\BaseController
{
use ResponseTrait;
// Semua response otomatis JSON
protected $format = 'json';
}
Tambahkan juga exception handler di app/Config/Exceptions.php supaya error 404 dan 500 return JSON, bukan HTML:
<?php
// Di app/Config/App.php, set:
public string $baseURL = 'http://localhost:8080/';
// Atau kalau production:
public string $baseURL = 'https://api.kamu.com/';
Setelah semua setup selesai, jalankan development server:
php spark serve --port 8080
Test semua endpoint pakai cURL:
# GET - List semua produk
curl -s http://localhost:8080/api/products | python3 -m json.tool
# GET - Detail produk
curl -s http://localhost:8080/api/products/1 | python3 -m json.tool
# POST - Buat produk baru
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"name": "Laptop ASUS ROG",
"description": "Laptop gaming high-end",
"price": 25000000,
"stock": 10,
"category": "elektronik"
}'
# PUT - Update produk
curl -X PUT http://localhost:8080/api/products/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"price": 23000000,
"stock": 8
}'
# DELETE - Hapus produk
curl -X DELETE http://localhost:8080/api/products/1 \
-H "Authorization: Bearer YOUR_API_KEY"
# Search
curl -s "http://localhost:8080/api/products/search/laptop" | python3 -m json.tool
# Filter by category
curl -s "http://localhost:8080/api/products?category=elektronik&page=1&per_page=5" | python3 -m json.tool
Kalau tabel produk kamu sudah ribuan atau jutaan row, query tanpa index bisa jadi lambat banget. Tambahkan index untuk kolom yang sering di-filter:
-- Index untuk filter category + is_active
CREATE INDEX idx_products_category_active ON products(category, is_active);
-- Index untuk search by name
CREATE INDEX idx_products_name ON products(name);
-- Index untuk soft delete query
CREATE INDEX idx_products_deleted_at ON products(deleted_at);
Kalau kamu mau belajar lebih detail tentang MySQL indexing dan gimana cara optimasi query, cek tutorial tutorial MySQL indexing yang membahas strategi indexing dari dasar sampai advanced.
Tips tambahan untuk performa API:
SELECT * FROM products tanpa LIMIT bisa makan waktu beberapa detik dan consume memory besar.select('id, name, price') daripada select('*') kalau frontend butuhnya cuma beberapa kolom.Saat deploy ke production, ada beberapa hal yang harus diperhatikan:
# 1. Set environment ke production
# Edit .env file
CI_ENVIRONMENT = production
# 2. Matikan debug toolbar
# Edit app/Config/App.php
public bool $toolbarEnabled = false;
# 3. Set base URL yang benar
public string $baseURL = 'https://api.kamu.com/';
# 4. Pastikan writable folder punya permission yang benar
chmod -R 755 writable/
# 5. Generate key
php spark key:generate
Kalau kamu deploy di VPS Ubuntu dan pakai Nginx, pastikan semua request diarahkan ke index.php:
server {
listen 80;
server_name api.kamu.com;
root /var/www/html/ci4-api/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\. {
deny all;
}
}
Untuk konfigurasi Nginx yang lebih lengkap termasuk setup SSL dan caching, baca tutorial tentang debug Nginx 502 dan 504 error.
Bikin REST API pakai CodeIgniter 4 itu ternyata tidak serumit yang dibayangkan. Dengan fitur Resource routing, model validation, dan ResponseTrait, kamu bisa bikin API CRUD lengkap dalam waktu kurang dari 1 jam. Yang penting diperhatikan: selalu validasi input, handle error dengan return JSON (bukan HTML), dan tambahkan autentikasi sebelum deploy ke production.
Beberapa hal yang perlu diingat:
$allowedFields di model supaya kolom sensitif tidak bisa di-update dari luarMau coba bikin API sendiri? Mulai dari project kecil dulu, misal todo list API atau blog API. Kalau ada pertanyaan atau error yang aneh, tulis di komentar - saya bantu debug bareng.