CodeIgniter 4 — REST API Reference
A well-built CI4 API is stateless, versioned, consistently enveloped, and callable from any client — web, mobile, third-party — without breaking.
Related skills:
ci4for core framework patterns,ci4-shieldfor auth deep dives.
Reference Documents
For deep dives, read the relevant reference from references/:
| Reference | When to read |
|---|---|
references/base-controller.md |
BaseApiController, response helpers, auth helpers, body parsing |
references/authentication.md |
Login/logout/me endpoints, token lifecycle, filters |
references/webhooks.md |
Receiving webhooks (Stripe, GitHub), sending webhooks, signature verification |
references/rate-limiting.md |
Throttle filter, per-route limits, inline throttling |
references/cors.md |
CORS filter, preflight handling, production config |
references/error-handling.md |
Status code guide, error envelope, common pitfalls |
Guiding Principles
- Stateless — every request is self-contained. No session state. Auth via Bearer token.
- Consistent envelope — every response uses the same JSON shape.
- Versioned — breaking changes get a new version. Existing clients never break.
- Explicit HTTP status codes —
200,201,400,401,403,404,409,422,500. Never return200with an error body. - One source of truth — the API owns the data. Web, mobile, and third parties all call the API.
Directory Structure
app/
Controllers/
Api/
V1/
BaseApiController.php # Base for all API controllers
UsersController.php
EventsController.php
AuthController.php
StripeController.php # Webhook receiver
Filters/
ApiAuthFilter.php # Bearer token validation
ApiGroupFilter.php # Group/role restriction
ApiThrottleFilter.php # Rate limiting
Response Envelope
Every API response — success or failure — uses the same shape:
{
"status": "success",
"message": "User retrieved.",
"data": { ... },
"errors": null
}
| Field | Type | Notes |
|---|---|---|
status |
"success" | "error" |
Always present |
message |
string | Human-readable summary |
data |
object | array | null | Payload on success, null on error |
errors |
object | null | Validation errors keyed by field, null on success |
For paginated responses, add a meta object (see references/base-controller.md).
Versioning
URL Versioning (Recommended)
/api/v1/users
/api/v2/users ← breaking change gets new version
// app/Config/Routes.php
$routes->group('api/v1', [
'namespace' => 'App\Controllers\Api\V1',
], function ($routes) {
// Public routes
$routes->post('auth/login', 'AuthController::login');
$routes->post('auth/register', 'AuthController::register');
// Authenticated routes
$routes->group('', ['filter' => 'api_auth'], function ($routes) {
$routes->get('auth/me', 'AuthController::me');
$routes->post('auth/logout', 'AuthController::logout');
$routes->resource('users');
$routes->resource('events');
});
// Admin-only routes
$routes->group('', ['filter' => ['api_auth', 'api_group:admin']], function ($routes) {
$routes->get('admin/stats', 'AdminController::stats');
});
// Webhooks — NO auth filter
$routes->post('webhooks/stripe', 'StripeController::webhook');
});
API Controller Pattern
All API controllers extend BaseApiController (see references/base-controller.md for full implementation).
<?php
namespace App\Controllers\Api\V1;
use App\Models\UserModel;
use CodeIgniter\HTTP\ResponseInterface;
class UsersController extends BaseApiController
{
protected UserModel $model;
public function __construct()
{
$this->model = new UserModel();
}
// GET /api/v1/users
public function index(): ResponseInterface
{
$perPage = (int) ($this->request->getGet('per_page') ?? 20);
$users = $this->model->paginate($perPage);
return $this->paginated($users, $this->model->pager, 'Users retrieved.');
}
// GET /api/v1/users/:id
public function show($id = null): ResponseInterface
{
$user = $this->model->find($id);
if (!$user) return $this->notFound('User not found.');
return $this->success($user, 'User retrieved.');
}
// POST /api/v1/users
public function create(): ResponseInterface
{
$body = $this->getBody();
$rules = [
'name' => 'required|min_length[2]|max_length[150]',
'email' => 'required|valid_email|is_unique[users.email]',
];
if (!$this->validate($rules)) {
return $this->validationError($this->validator->getErrors());
}
$id = $this->model->insert([
'name' => $body['name'],
'email' => $body['email'],
]);
if (!$id) return $this->serverError('Could not create user.');
return $this->created($this->model->find($id), 'User created.');
}
// PUT /api/v1/users/:id
public function update($id = null): ResponseInterface
{
$user = $this->model->find($id);
if (!$user) return $this->notFound('User not found.');
$body = $this->getBody();
$rules = [
'name' => 'if_exist|min_length[2]|max_length[150]',
'email' => "if_exist|valid_email|is_unique[users.email,id,{$id}]",
];
if (!$this->validate($rules)) {
return $this->validationError($this->validator->getErrors());
}
$this->model->update($id, $body);
return $this->success($this->model->find($id), 'User updated.');
}
// DELETE /api/v1/users/:id
public function delete($id = null): ResponseInterface
{
$user = $this->model->find($id);
if (!$user) return $this->notFound('User not found.');
$this->model->delete($id);
return $this->noContent();
}
}
Validation
$rules = [
'name' => 'required|min_length[2]|max_length[150]',
'email' => 'required|valid_email|is_unique[users.email]',
'age' => 'required|integer|greater_than[0]',
'role' => 'required|in_list[admin,user,staff]',
// if_exist — only validate if the field is present (good for PUT/PATCH)
'name' => 'if_exist|min_length[2]|max_length[150]',
];
if (!$this->validate($rules)) {
return $this->validationError($this->validator->getErrors());
}
// Custom error messages
$messages = [
'email' => [
'required' => 'Email address is required.',
'valid_email' => 'Please provide a valid email address.',
],
];
if (!$this->validate($rules, $messages)) { ... }
Filters
Register
// app/Config/Filters.php
public array $aliases = [
'api_auth' => \App\Filters\ApiAuthFilter::class,
'api_group' => \App\Filters\ApiGroupFilter::class,
'api_throttle' => \App\Filters\ApiThrottleFilter::class,
];
See references/authentication.md for filter implementations.
Key Gotchas
See references/error-handling.md for the complete list.
requireAuth()returns a union type — always check:if ($user instanceof ResponseInterface) return $user;getBody()returnsarray— use$body['key'], not$body->key- Never type-hint override params —
show($id = null)notshow(int $id = null) format()name conflict —ResourceControllerhasprotected format(), never define your own- Webhook routes must NOT have
api_authfilter - Always
returnresponses —$this->success(...)withoutreturnsends nothing - Token
raw_tokenis only available once — capture it at generation time - CORS preflight is OPTIONS — handle it or browsers block cross-origin calls
- Always return
200from webhooks — non-200 causes retries from services like Stripe