Frameworks

Bangun REST API dengan CodeIgniter 4 - JWT Authentication dan Best Practice

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.

Kenapa CodeIgniter 4 untuk REST API

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:

  • Lightweight: Framework size kecil, cocok buat VPS dengan resource terbatas
  • Fast routing: Support regex routing dan route groups
  • Built-in CORS filter: Tinggal konfigurasi, langsung jalan
  • Flexible response format: $this->respond() dan $this->fail() udah tersedia
  • Environment config: Bedakan staging dan production dengan mudah

Setup Project dari Nol

Install 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
);

Setup Model dan Entity

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;
    }
}

JWT Authentication dari Awal

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

Buat API Filter untuk Autentikasi

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,
];

Base Controller untuk API

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),
            ],
        ]);
    }
}

Auth Controller - Register dan Login


<?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());
    }
}

Products CRUD API

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',
    ];
}

Routing yang Rapi

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');
});

Error Handling Global

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);
}

Testing API dengan cURL

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"

CORS Configuration

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
});

Rate Limiting Sederhana

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
    }
}

Deployment ke Production

Saat deploy ke production, jangan lupa:

  • Set CI_ENVIRONMENT = production di .env
  • Generate JWT secret yang kuat: openssl rand -base64 64
  • Matikan toolbar: set toolbar = false di .env
  • Konfigurasi nginx untuk forward semua request ke index.php
  • Enable HTTPS - JWT token dikirim di header, wajib pakai SSL

Contoh 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";
}

Best Practice yang Sering Dilupakan

Beberapa hal yang sering saya lihat di project API orang lain dan bikin masalah di production:

  • Jangan return password atau data sensitif - pakai Entity toSafeArray() atau cast
  • Pakai pagination - jangan pernah return semua data sekaligus, bisa crash kalau data ribuan
  • Validasi input di server - jangan percaya client-side validation
  • Log semua error - pakai log_message('error', ...) supaya gampang debug
  • Gunakan soft delete - data user jangan dihapus permanen
  • Rate limiting - protect dari brute force dan spam
  • Versioning API - pakai prefix /api/v1/ supaya bisa update tanpa break client lama

Itu 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.


You may also like


0 Comments


Leave a Reply

Comments with links or spam keywords will be rejected.
Scroll to Top