Frameworks

Tutorial CodeIgniter 4 REST API - Membuat API dari Nol

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.

Kenapa Pakai CodeIgniter 4 untuk REST API?

Ada beberapa alasan kenapa CI4 cocok banget buat bikin REST API:

  • Ringan - CI4 punya footprint kecil, cocok buat VPS dengan resource terbatas. Kalau kamu pernah deploy Laravel di VPS 1GB RAM, pasti tahu rasanya. CI4 jauh lebih hemat resource.
  • Routing cepat - Routing CI4 cepat banget, hampir setara dengan framework micro seperti Lumen.
  • Fitur built-in - Udah ada fitur CORS, content negotiation, rate limiting, dan API versioning out of the box.
  • Learning curve rendah - Dokumentasinya jelas dan komunitas Indonesia aktif banget.

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.

Setup Project CodeIgniter 4

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.

Bikin Model dan Migration

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.

Routing REST API

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 produk
  • GET /api/products/(:id) - Detail produk by ID
  • POST /api/products - Buat produk baru
  • PUT /api/products/(:id) - Update produk
  • PATCH /api/products/(:id) - Partial update
  • DELETE /api/products/(:id) - Hapus produk

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

Bikin Controller untuk REST API

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

Handling CORS untuk Frontend

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.

Autentikasi API dengan Token

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)

Error Handling yang Proper

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/';

Testing API dengan cURL

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

Optimasi Query untuk API yang Cepat

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:

  • Pagination wajib - Jangan pernah return semua record sekaligus. Kalau tabel punya 10.000 row, SELECT * FROM products tanpa LIMIT bisa makan waktu beberapa detik dan consume memory besar.
  • Select kolom yang dibutuhkan - Pakai select('id, name, price') daripada select('*') kalau frontend butuhnya cuma beberapa kolom.
  • Caching - Untuk data yang jarang berubah (misal kategori), cache response-nya. CI4 punya fitur caching built-in.
  • Rate limiting - Batasi request per IP supaya API tidak di-abuse. CI4 punya fitur Throttler.

Deploy ke Production

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.

Kesimpulan

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:

  • Pakai $allowedFields di model supaya kolom sensitif tidak bisa di-update dari luar
  • Soft delete lebih aman daripada hard delete untuk data production
  • Selalu return response dalam format JSON yang konsisten
  • Test semua endpoint pakai cURL atau Postman sebelum integrasi dengan frontend
  • Tambahkan index di kolom yang sering di-query kalau data sudah besar

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


You may also like


0 Comments


Leave a Reply

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