
Ci4
- 1 installs
- 21 repo stars
- Updated April 11, 2026
- enlivenapp/codeigniter4-app-and-api-skills-for-claude-code
Helps with ai & agent building tasks.
About
ci4 is a Claude Code skill for ai & agent building. It helps you ship faster with AI-assisted development.
- ci4
- AI & Agent Building
- AI-coding skill
Ci4 by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,102 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/enlivenapp/codeigniter4-app-and-api-skills-for-claude-code --skill ci4Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 21 |
| Last updated | April 11, 2026 |
| Repository | enlivenapp/codeigniter4-app-and-api-skills-for-claude-code ↗ |
What it does
Helps with ai & agent building tasks.
Files
CodeIgniter 4 — Framework Reference
CodeIgniter 4 is a full-stack PHP 8+ MVC framework. It is not Laravel. Do not apply Laravel APIs, method names, or conventions here. When in doubt, check this skill and its references — not memory of another framework.
Related skills:ci4-apifor REST API patterns,ci4-shieldfor authentication/authorization.
Reference Documents
For deep dives, read the relevant reference from references/:
| Reference | When to read |
|---|---|
references/routing.md | Route groups, placeholders, named routes, resource routes |
references/controllers.md | ResourceController, request data, validation, redirects, flash data |
references/models.md | CRUD, soft deletes, timestamps, validation, pagination, scopes, callbacks, entities |
references/query-builder.md | SELECT, WHERE, JOIN, GROUP BY, batch ops, subqueries, transactions, debugging |
references/views.md | Layouts, sections, partials, escaping, view cells |
references/filters.md | Creating, registering, applying, global filters, filter arguments |
references/database.md | Migrations, seeds, forge, field types, adding/dropping columns |
references/validation.md | All validation rules, custom rules, file upload validation |
references/services-helpers.md | Services, helpers, caching, email, sessions, encryption |
references/spark-cli.md | All spark commands, generators, custom commands |
references/testing.md | PHPUnit, feature tests, database tests, mocking |
references/gotchas.md | Critical pitfalls, CI4 vs Laravel comparison table |
---
Directory Structure
app/
Config/ # All configuration classes (Routes, Database, Filters, Auth, etc.)
Controllers/ # HTTP controllers
Database/
Migrations/ # Migration files (timestamped)
Seeds/ # Seeder classes
Filters/ # Before/after request filters
Libraries/ # Custom libraries
Models/ # Model classes
Services/ # Custom service classes
Views/ # View templates (.php)
layouts/ # Layout templates
partials/ # Reusable partials
public/ # Web root — index.php entry point lives here
writable/ # Cache, logs, sessions (must be writable)
tests/
vendor/
.env # Environment config (copy from `env`)
spark # CLI entry point---
MVC Conventions — Hard Rules
1. Controllers handle HTTP — receive request, call model/service, return response. No business logic. 2. Models handle data — all database interaction lives here. No HTTP concerns. 3. Views handle display — no DB calls, no business logic. Only presentation. 4. Never call models from views. Ever. 5. Web controllers call models directly — that is standard CI4. 6. One controller = one resource. Name them descriptively (UserController, EventController).
---
Configuration
All config lives in app/Config/ as PHP classes (not arrays, not .ini files).
// app/Config/Database.php — DB connection
// app/Config/Routes.php — route definitions
// app/Config/Filters.php — filter aliases + global filters
// app/Config/App.php — base URL, timezone, etc..env overrides any config value using dot-notation:
database.default.hostname = localhost
database.default.database = mydb
app.baseURL = 'http://example.com/'
CI_ENVIRONMENT = development---
Routing
Defined in app/Config/Routes.php. See references/routing.md for complete reference.
$routes->get('users', 'UserController::index');
$routes->post('users', 'UserController::create');
$routes->get('users/(:num)', 'UserController::show/$1');
$routes->put('users/(:num)', 'UserController::update/$1');
$routes->delete('users/(:num)', 'UserController::delete/$1');
// Route groups
$routes->group('admin', ['namespace' => 'App\Controllers\Admin'], function ($routes) {
$routes->get('users', 'UsersController::index');
});
// Apply filter to a route
$routes->get('dashboard', 'DashboardController::index', ['filter' => 'session']);
// Resource routes — generates full CRUD
$routes->resource('photos');Route Placeholders
| Placeholder | Matches |
|---|---|
(:num) | Digits only |
(:alpha) | Alphabetic only |
(:alphanum) | Alphanumeric |
(:segment) | URL segment (no slashes) |
(:any) | Anything (use sparingly) |
---
Controllers
See references/controllers.md for complete reference.
<?php
namespace App\Controllers;
use App\Controllers\BaseController;
class UserController extends BaseController
{
public function index()
{
$model = new \App\Models\UserModel();
return view('users/index', ['users' => $model->findAll()]);
}
}Request Data
$this->request->getGet('name'); // GET param
$this->request->getPost('email'); // POST param
$this->request->getJSON(true); // JSON body as array
$this->request->getVar('key'); // GET + POST
$this->request->getFile('avatar'); // File uploadValidation
$rules = [
'email' => 'required|valid_email',
'name' => 'required|min_length[2]|max_length[100]',
];
if (!$this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}Redirects
return redirect()->to('/dashboard');
return redirect()->back();
return redirect()->back()->withInput()->with('error', 'Something went wrong.');GOTCHA: Never add PHP type hints (int $id) to overridden ResourceController methods like show($id = null). The parent signature uses $id = null — a type hint breaks the override.
---
Models
See references/models.md for complete reference.
<?php
namespace App\Models;
use CodeIgniter\Model;
class UserModel extends Model
{
protected $table = 'users';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'object'; // Always use 'object' (not 'array')
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['name', 'email', 'role'];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
}CRUD
$model = new UserModel();
$user = $model->find(1); // by ID
$users = $model->findAll(); // all
$user = $model->where('email', 'a@b.com')->first(); // where
$id = $model->insert(['name' => 'Rob', 'email' => 'r@b.com']);
$model->update(1, ['name' => 'Robert']);
$model->delete(1);
$users = $model->paginate(20); // pagination
$pager = $model->pager; // pager for view---
Query Builder
See references/query-builder.md for complete reference.
$db = \Config\Database::connect();
$builder = $db->table('users');
$builder->select('id, name, email')
->where('active', 1)
->orderBy('created_at', 'DESC')
->limit(10);
$rows = $builder->get()->getResult(); // array of objects
$row = $builder->get()->getRow(); // single objectCRITICAL GOTCHAS:
whereNull()does not exist in CI4. Use->where('col IS NULL').->select('DISTINCT col')returns wrong results. Use->select('col')->distinct().
---
Views
See references/views.md for complete reference.
// Controller
return view('users/index', ['users' => $users, 'title' => 'Users']);<!-- app/Views/users/index.php -->
<?= $this->extend('layouts/main') ?>
<?= $this->section('content') ?>
<h1><?= esc($title) ?></h1>
<?php foreach ($users as $user): ?>
<p><?= esc($user->name) ?></p>
<?php endforeach; ?>
<?= $this->endSection() ?>Always use `esc()` for output. Never use return inside a view that uses $this->extend().
---
Filters
See references/filters.md for complete reference.
<?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)
{
if (!auth()->loggedIn()) {
return redirect()->to('/login');
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {}
}Register in app/Config/Filters.php, apply via routes:
$routes->get('admin', 'AdminController::index', ['filter' => ['session', 'group:admin']]);---
Migrations
See references/database.md for complete reference.
php spark make:migration CreateUsersTable
php spark migratepublic function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 150],
'email' => ['type' => 'VARCHAR', 'constraint' => 255, 'unique' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('id');
$this->forge->createTable('users');
}---
Spark CLI Quick Reference
See references/spark-cli.md for complete reference.
php spark serve # dev server
php spark routes # list routes
php spark migrate # run migrations
php spark migrate:rollback # rollback last batch
php spark db:seed DatabaseSeeder # run seeder
php spark make:controller UserController # scaffold
php spark make:model UserModel # scaffold
php spark make:migration CreateUsersTable
php spark make:filter AuthFilter
php spark cache:clear---
Key Gotchas
See references/gotchas.md for the complete list and CI4 vs Laravel comparison.
1. whereNull() does not exist — use ->where('col IS NULL') 2. ->select('DISTINCT col') fails silently — use ->select('col')->distinct() 3. Never type-hint overridden ResourceController params 4. redirect() must be returned — without return it does nothing 5. insertBatch() bypasses $allowedFields — be deliberate about what you pass 6. Never use return inside a view using $this->extend() 7. View sections cannot be nested — close one before opening another 8. orLike() after where() needs groupStart()/groupEnd() 9. Model save() decides insert vs update by whether primary key is present 10. $returnType should always be 'object' — 'array' is inconsistent
CI4 Controllers — Complete Reference
Base Structure
All controllers extend BaseController (app/Controllers/BaseController.php).
<?php
namespace App\Controllers;
use App\Controllers\BaseController;
class UserController extends BaseController
{
public function index()
{
return view('users/index', ['users' => []]);
}
}BaseController Provides
$this->request— IncomingRequest$this->response— ResponseInterface$this->logger— Logger$this->helpers— array of helpers to auto-load
class BaseController extends Controller
{
protected $helpers = ['url', 'form']; // auto-loaded helpers
public function initController(...)
{
parent::initController(...);
// Custom initialization here
}
}ResourceController
For RESTful resources. Implements index, show, new, edit, create, update, delete.
<?php
namespace App\Controllers;
use CodeIgniter\RESTful\ResourceController;
class PhotoController extends ResourceController
{
protected $modelName = 'App\Models\PhotoModel';
protected $format = 'json'; // 'json' or 'xml'
public function index()
{
return $this->respond($this->model->findAll());
}
public function show($id = null) // NEVER type-hint override params
{
$photo = $this->model->find($id);
if (!$photo) return $this->failNotFound('Photo not found');
return $this->respond($photo);
}
public function create()
{
$data = $this->request->getJSON(true);
$id = $this->model->insert($data);
return $this->respondCreated($this->model->find($id));
}
public function update($id = null)
{
$data = $this->request->getJSON(true);
$this->model->update($id, $data);
return $this->respond($this->model->find($id));
}
public function delete($id = null)
{
$this->model->delete($id);
return $this->respondDeleted(['id' => $id]);
}
}GOTCHA: Never add PHP type hints (int $id) to overridden ResourceController methods. The parent signature uses $id = null — a type hint breaks the override.
GOTCHA: ResourceController has a protected format() method. Never define a private method named format() in a subclass — access level conflict / fatal error.
ResourceController Response Methods
$this->respond($data, 200); // Generic response
$this->respondCreated($data); // 201
$this->respondDeleted($data); // 200 with deleted confirmation
$this->respondNoContent(); // 204
$this->fail($message, 400); // Generic failure
$this->failNotFound($message); // 404
$this->failValidationErrors($errors); // 422
$this->failForbidden($message); // 403
$this->failUnauthorized($message); // 401
$this->failServerError($message); // 500
$this->failTooManyRequests($message); // 429Request Data
// GET parameters
$this->request->getGet('name'); // single
$this->request->getGet(); // all GET params
// POST parameters
$this->request->getPost('email'); // single
$this->request->getPost(); // all POST params
// JSON body (API endpoints)
$body = $this->request->getJSON(true); // true = associative array
// OR
$body = json_decode($this->request->getBody(), true);
// All input (GET + POST)
$this->request->getVar('key');
// Specific HTTP method data
$this->request->getRawInput(); // PUT/PATCH/DELETE body
// Request method
$this->request->getMethod(); // 'get', 'post', 'put', etc.
// Check if AJAX
$this->request->isAJAX();
// IP address
$this->request->getIPAddress();
// Headers
$this->request->getHeaderLine('Authorization');
$this->request->header('Content-Type');File Uploads
$file = $this->request->getFile('avatar');
// Check
$file->isValid(); // was it uploaded without errors?
$file->hasMoved(); // has it already been moved?
// Info
$file->getName(); // original filename
$file->getClientExtension(); // extension from client
$file->getClientMimeType(); // MIME from client
$file->getTempName(); // tmp path
$file->getSize(); // size in bytes (string)
$file->getSizeByUnit('mb'); // size in specified unit
// Move
$file->move(WRITEPATH . 'uploads'); // move to directory
$file->move(WRITEPATH . 'uploads', 'custom_name.jpg'); // with custom name
// Store (convenience — moves with random name)
$path = $file->store(); // returns relative path
$path = $file->store('avatars'); // store in subdirectory
// Multiple files
$files = $this->request->getFiles();
$files = $this->request->getFileMultiple('images');File Upload Validation
$rules = [
'avatar' => [
'label' => 'Avatar',
'rules' => [
'uploaded[avatar]',
'is_image[avatar]',
'mime_in[avatar,image/jpg,image/jpeg,image/png,image/webp]',
'max_size[avatar,2048]', // KB
'max_dims[avatar,1024,768]', // width, height
],
],
];
if (!$this->validateData([], $rules)) {
return redirect()->back()->with('errors', $this->validator->getErrors());
}Validation in Controllers
$rules = [
'email' => 'required|valid_email',
'name' => 'required|min_length[2]|max_length[100]',
];
if (!$this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
// With custom messages
$messages = [
'email' => [
'required' => 'Email address is required.',
'valid_email' => 'Please provide a valid email.',
],
];
if (!$this->validate($rules, $messages)) { ... }
// validateData() — validate arbitrary data (not just request data)
if (!$this->validateData($data, $rules)) { ... }Redirects
return redirect()->to('/dashboard');
return redirect()->back();
return redirect()->route('profile'); // named route
return redirect()->back()->withInput(); // preserve old input
return redirect()->back()->with('message', 'Saved!'); // flash data
return redirect()->back()->with('error', 'Something went wrong.');
return redirect()->to(url_to('UserController::show', $id));GOTCHA: redirect() must be returned — redirect()->to('/foo') without return does nothing.
Session Flash Data
// Set
session()->setFlashdata('message', 'User created.');
session()->setFlashdata('error', 'Something went wrong.');
session()->setFlashdata('errors', $this->validator->getErrors());
// Get (in controller or view)
session()->getFlashdata('message');
session()->getFlashdata('error');
// Shorthand via redirect
return redirect()->back()->with('message', 'Saved!');
// In view: session('message') or session()->getFlashdata('message')Returning Responses
// View
return view('users/index', $data);
// JSON
return $this->response->setJSON($data);
// With status code
return $this->response->setStatusCode(201)->setJSON($data);
// With headers
return $this->response->setHeader('X-Custom', 'value')->setJSON($data);
// Download
return $this->response->download('filename.pdf', $fileData);
// No content
return $this->response->setStatusCode(204);CI4 Database — Migrations, Seeds & Forge Reference
Migrations
Create a Migration
php spark make:migration CreateUsersTable
php spark make:migration AddPhoneToUsersCreates timestamped file in app/Database/Migrations/.
Migration File Structure
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateUsersTable extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 150],
'email' => ['type' => 'VARCHAR', 'constraint' => 255],
'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->addPrimaryKey('id');
$this->forge->addKey('email', true); // true = unique index
$this->forge->createTable('users');
}
public function down(): void
{
$this->forge->dropTable('users');
}
}Adding Columns
public function up(): void
{
$fields = [
'phone' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true, 'after' => 'email'],
];
$this->forge->addColumn('users', $fields);
}
public function down(): void
{
$this->forge->dropColumn('users', 'phone');
}Modifying Columns
public function up(): void
{
$fields = [
'name' => [
'name' => 'name', // same name = modify, not rename
'type' => 'VARCHAR',
'constraint' => 255, // was 150
],
];
$this->forge->modifyColumn('users', $fields);
}Renaming Columns
public function up(): void
{
$fields = [
'name' => [
'name' => 'full_name', // different name = rename
'type' => 'VARCHAR',
'constraint' => 150,
],
];
$this->forge->modifyColumn('users', $fields);
}Adding Indexes
// In createTable context
$this->forge->addPrimaryKey('id');
$this->forge->addKey('email', true); // unique
$this->forge->addKey('created_at'); // regular index
$this->forge->addKey(['role', 'active']); // composite index
$this->forge->addUniqueKey('slug'); // unique index
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
// CASCADE = on update / on delete
// After table exists
$this->forge->addKey('phone');
$this->forge->processIndexes('users');Dropping
$this->forge->dropTable('users'); // drop table
$this->forge->dropTable('users', true); // IF EXISTS
$this->forge->dropColumn('users', 'phone'); // drop column
$this->forge->dropColumn('users', ['phone', 'fax']); // drop multiple
$this->forge->dropKey('users', 'email'); // drop index
$this->forge->dropForeignKey('orders', 'orders_user_id_foreign'); // drop FKCommon Field Types
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true]
'name' => ['type' => 'VARCHAR', 'constraint' => 150]
'slug' => ['type' => 'VARCHAR', 'constraint' => 255, 'unique' => true]
'body' => ['type' => 'TEXT']
'long_text' => ['type' => 'LONGTEXT']
'price' => ['type' => 'DECIMAL', 'constraint' => '10,2']
'active' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1]
'count' => ['type' => 'INT', 'constraint' => 11, 'default' => 0, 'unsigned' => true]
'status' => ['type' => 'ENUM', 'constraint' => ['pending', 'active', 'closed'], 'default' => 'pending']
'metadata' => ['type' => 'JSON', 'null' => true]
'created_at' => ['type' => 'DATETIME', 'null' => true]
'sort_order' => ['type' => 'INT', 'constraint' => 11, 'default' => 0]
// Nullable field
'phone' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true]
// Field positioning
'phone' => ['type' => 'VARCHAR', 'constraint' => 20, 'after' => 'email']
'priority' => ['type' => 'INT', 'constraint' => 3, 'first' => true]Migration Spark Commands
php spark migrate # run pending migrations
php spark migrate:rollback # roll back last batch
php spark migrate:rollback -b 2 # roll back 2 batches
php spark migrate:status # show migration status
php spark migrate:refresh # rollback all + re-migrate
php spark migrate:reset # rollback all---
Seeds
<?php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
class UserSeeder extends Seeder
{
public function run(): void
{
$data = [
[
'name' => 'Admin',
'email' => 'admin@example.com',
'role' => 'admin',
'created_at' => date('Y-m-d H:i:s'),
],
[
'name' => 'User',
'email' => 'user@example.com',
'role' => 'user',
'created_at' => date('Y-m-d H:i:s'),
],
];
$this->db->table('users')->insertBatch($data);
}
}Master Seeder
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call('UserSeeder');
$this->call('SettingsSeeder');
$this->call('CategorySeeder');
}
}Run Seeds
php spark db:seed DatabaseSeeder
php spark db:seed UserSeeder---
Database Configuration
// app/Config/Database.php or .env
database.default.hostname = localhost
database.default.database = mydb
database.default.username = root
database.default.password = secret
database.default.DBDriver = MySQLi
database.default.port = 3306
database.default.charset = utf8mb4
database.default.DBCollat = utf8mb4_general_ci
// Multiple connections
database.tests.hostname = localhost
database.tests.database = test_dbUsing Multiple Connections
// Default connection
$db = \Config\Database::connect();
// Named connection
$db = \Config\Database::connect('tests');
// In a model
protected $DBGroup = 'tests';---
Gotchas
- Backtick-quote reserved words in raw SQL: `
key,order,index` app_settingstables often usekeyas a column name — always quote it in raw SQLinsertBatch()in seeds ignores model$allowedFields— it inserts everything you give it- Migration timestamps must be unique — if two migrations have the same timestamp, one may be skipped
- Always provide
down()methods for rollback support - Foreign keys should be dropped in
down()before dropping the parent table
CI4 Filters — Complete Reference
Filters run before and/or after a controller method. Defined in app/Filters/ and registered in app/Config/Filters.php.
Creating a Filter
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class AuthFilter implements FilterInterface
{
/**
* Runs before the controller.
* Return nothing to continue; return a Response to stop the chain.
*/
public function before(RequestInterface $request, $arguments = null)
{
if (!auth()->loggedIn()) {
return redirect()->to('/login');
}
}
/**
* Runs after the controller.
* Can modify $response or return nothing.
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// Optional post-processing
}
}Registering Filters
// app/Config/Filters.php
public array $aliases = [
'session' => \App\Filters\SessionAuthFilter::class,
'api_auth' => \App\Filters\ApiAuthFilter::class,
'api_group' => \App\Filters\ApiGroupFilter::class,
'admin_auth' => \App\Filters\AdminFilter::class,
'throttle' => \App\Filters\ThrottleFilter::class,
];Applying Filters
On Individual Routes
// Single filter
$routes->get('dashboard', 'DashboardController::index', ['filter' => 'session']);
// Multiple filters (run in order)
$routes->get('admin', 'AdminController::index', ['filter' => ['session', 'group:admin']]);
// Filter with arguments (accessible as $arguments in the filter)
$routes->get('admin', 'AdminController::index', ['filter' => 'group:admin,superadmin']);
// $arguments = ['admin', 'superadmin'] in the filter's before() methodOn Route Groups
$routes->group('admin', ['filter' => 'session'], function ($routes) {
$routes->get('dashboard', 'Admin\DashboardController::index');
$routes->get('users', 'Admin\UsersController::index');
});
// Nested groups — filters are NOT inherited/merged from parent
$routes->group('admin', ['filter' => 'session'], function ($routes) {
$routes->get('/', 'Admin\DashboardController::index');
// This group does NOT inherit 'session' from parent — must declare it
$routes->group('users', ['filter' => ['session', 'group:admin']], function ($routes) {
$routes->get('/', 'Admin\UsersController::index');
});
});GOTCHA: Filter options on parent route groups are not merged into child groups. Each group must specify its own filters explicitly.
Global Filters
// app/Config/Filters.php
// Run on every request
public array $globals = [
'before' => [
'csrf',
'honeypot',
],
'after' => [
'toolbar', // debug toolbar (only in development)
],
];
// Except certain routes
public array $globals = [
'before' => [
'csrf' => ['except' => ['api/*']], // skip CSRF for API routes
],
];URI-Pattern Filters
// app/Config/Filters.php
public array $filters = [
'session' => [
'before' => ['admin/*', 'dashboard'],
],
'group:admin' => [
'before' => ['admin/*'],
],
'throttle' => [
'before' => ['api/*'],
],
];Filter Arguments
// Route definition
$routes->get('admin', 'AdminController::index', ['filter' => 'group:admin,superadmin']);
// In the filter
public function before(RequestInterface $request, $arguments = null)
{
// $arguments = ['admin', 'superadmin']
$user = auth()->user();
foreach ((array) $arguments as $group) {
if ($user->inGroup($group)) {
return; // allowed — continue to controller
}
}
return redirect()->to('/forbidden');
}Filter Execution Order
1. Global before filters run first 2. Route-specific before filters run next (in the order specified) 3. Controller method executes 4. Route-specific after filters run 5. Global after filters run last
Common Filter Patterns
Maintenance Mode Filter
class MaintenanceFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
if (setting('App.maintenanceMode') && !str_starts_with(current_url(), site_url('admin'))) {
return service('response')->setStatusCode(503)->setBody(view('maintenance'));
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {}
}CORS Filter
class CorsFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
if ($request->getMethod() === 'options') {
return service('response')
->setStatusCode(204)
->setHeader('Access-Control-Allow-Origin', '*')
->setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
return $response
->setHeader('Access-Control-Allow-Origin', '*')
->setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
}
}Rate Limiting Filter
class ThrottleFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$throttler = service('throttler');
$maxRequests = (int) ($arguments[0] ?? 60);
$perSeconds = (int) ($arguments[1] ?? 60);
$key = 'throttle-' . $request->getIPAddress();
if (!$throttler->check($key, $maxRequests, $perSeconds)) {
return service('response')
->setStatusCode(429)
->setHeader('Retry-After', $throttler->getTokenTime())
->setJSON(['error' => 'Too many requests.']);
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {}
}Shield's Auto-Registered Filters
Shield provides these filters automatically (no manual registration needed):
| Filter | Purpose |
|---|---|
session | Requires session authentication |
tokens | Requires Bearer token authentication |
hmac | Requires HMAC token authentication |
jwt | Requires JWT authentication |
chain | Tries authenticators in sequence |
group | Checks group membership (e.g., group:admin) |
permission | Checks permission (e.g., permission:users.edit) |
force-reset | Checks if password reset is required |
auth-rates | Rate limiting for auth routes |
See the ci4-shield skill for complete Shield filter documentation.
CI4 Common Pitfalls & Gotchas
Query Builder
1. `whereNull()` does not exist in CI4. Use ->where('col IS NULL'). This is a Laravel method — it will not error, but it won't work correctly.
2. `->select('DISTINCT col')` returns wrong results silently. Use ->select('col')->distinct() instead.
3. `orLike()` after a `where()` produces wrong SQL without grouping. Wrap with groupStart()/groupEnd():
$builder->where('active', 1)
->groupStart()
->like('name', 'rob')
->orLike('email', 'rob')
->groupEnd();4. `->select()` is cumulative — calling it multiple times appends columns, it does not replace them.
5. `insertBatch()` bypasses model `$allowedFields` — it inserts everything you give it. Be deliberate about what columns you pass.
Models
6. Always use `protected $returnType = 'object'` — 'array' means $row['key'] instead of $row->key, which is inconsistent and error-prone across the codebase.
7. `getJSON(true)` returns `array` — use $body['key'], not $body->key. This is a common source of "Trying to get property of non-object" errors.
8. Model `save()` decides insert vs update based on whether the primary key is present in data — there is no magic upsert().
9. Soft delete `delete()` only sets `deleted_at` — queries automatically exclude soft-deleted rows. Use withDeleted() to include them.
10. `{id}` in validation rules (e.g., is_unique[users.email,id,{id}]) only works when the model's primary key is in the data being validated. It is NOT replaced automatically from URL segments.
Controllers
11. Never type-hint overridden ResourceController params: show($id = null) not show(int $id = null). The parent signature uses $id = null — a type hint breaks the override.
12. ResourceController has a `protected format()` method — never define a private function format() in a subclass. Access level conflict causes a fatal error.
13. `redirect()` must be `return`ed — redirect()->to('/foo') without return does nothing. The redirect creates a response object; without return, it's discarded.
14. `$this->request->getJSON(true)` can return `null` if the request body isn't valid JSON. Always handle that:
$body = $this->request->getJSON(true);
$body = is_array($body) ? $body : [];Views
15. Never use `return` or early exit inside a view that uses $this->extend(). Layout rendering requires all sections to complete. Use if/else for conditional content.
16. View sections cannot be nested. Always call $this->endSection() before opening another section. This fails silently — no error, just missing output.
17. `$this->include()` passes parent variables. But view() called inside a view does NOT pass parent variables — you must pass data explicitly: view('partial', ['var' => $var]).
Database / Migrations
18. Backtick-quote reserved words in raw SQL: ` key , order , index , group `. Without quotes, MySQL treats them as keywords.
19. `app_settings` tables often use `key` as a column — always quote it in raw SQL.
20. Migration timestamps must be unique — if two migrations share a timestamp, one may be skipped silently.
21. Foreign keys must be dropped in `down()` before dropping the parent table.
Configuration
22. `.env` values need no quotes for simple strings, but URLs with special chars should be quoted: app.baseURL = 'http://example.com/'
23. `CI_ENVIRONMENT` controls error display. In production, errors are hidden and logged to writable/logs/. In development, they show on screen.
Sessions
24. Session regeneration — call session()->regenerate() after login to prevent session fixation attacks.
25. Flash data is only available on the NEXT request — you can't set flash data and read it in the same request. Use keepFlashdata() to persist it one more request.
---
CI4 vs Laravel — Do Not Confuse
| What | Laravel | CI4 |
|---|---|---|
| Find by ID | User::find(1) | $model->find(1) |
| Find with where | User::where()->get() | $model->where()->findAll() |
| Where NULL | whereNull('col') | ->where('col IS NULL') |
| Or Where | orWhere() | ->orWhere() (same) |
| Request input | $request->input('key') | $this->request->getPost('key') or getVar() |
| Validate | $request->validate([]) | $this->validate([]) |
| Debug dump | dd() | d() or var_dump(); exit; |
| CLI | artisan | spark |
| Writable dir | storage/ | writable/ |
| Query scopes | Eloquent scopes | Method chaining returning static |
| Middleware | Middleware | Filters |
| Service providers | Service Providers | app/Config/Services.php |
| Facades | Facades | \Config\Services::serviceName() |
| Auth check | Auth::check() | auth()->loggedIn() |
| Current user | Auth::user() | auth()->user() |
| Auth attempt | Auth::attempt() (returns bool) | auth()->attempt() (returns Result object) |
| Roles | $user->hasRole('admin') | $user->inGroup('admin') |
| Permissions | $user->can('edit posts') | $user->can('posts.edit') |
Blade @auth | @auth ... @endauth | <?php if (auth()->loggedIn()): ?> |
| Migrations up | Schema::create() | $this->forge->createTable() |
| Auth routes | Auth::routes() | Shield auto-registers routes |
| Token auth | Sanctum | $user->generateAccessToken() |
| Guards | Guards | Authenticators |
| Middleware groups | Middleware groups | Filter aliases + route groups |
Route::middleware() | Route::middleware('auth') | ['filter' => 'session'] |
| Eager loading | with('relation') | CI4 has no built-in eager loading |
| Blade directives | @foreach, @if | <?php foreach (): ?>, <?php if (): ?> |
Request::has() | $request->has('key') | $this->request->getVar('key') !== null |
CI4 Models — Complete Reference
Full Model Template
<?php
namespace App\Models;
use CodeIgniter\Model;
class UserModel extends Model
{
protected $table = 'users';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'object'; // Always use 'object' (not 'array')
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['name', 'email', 'role'];
// Timestamps
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at'; // only needed with soft deletes
// Validation
protected $validationRules = [];
protected $validationMessages = [];
protected $skipValidation = false;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
}CRUD Operations
$model = new UserModel();
// ─── Find ─────────────────────────────────────────────
$user = $model->find(1); // by primary key, returns object
$users = $model->find([1, 2, 3]); // by multiple IDs
$users = $model->findAll(); // all rows
$user = $model->first(); // first result
$users = $model->findAll(10, 20); // limit 10, offset 20
// ─── Where ────────────────────────────────────────────
$user = $model->where('email', 'a@b.com')->first();
$users = $model->where('active', 1)->findAll();
$users = $model->where('role', 'admin')->orderBy('name')->findAll();
// ─── Insert ───────────────────────────────────────────
$id = $model->insert(['name' => 'Rob', 'email' => 'r@b.com']);
// Returns inserted ID or false on failure
// ─── Update ───────────────────────────────────────────
$model->update(1, ['name' => 'Robert']);
// Update multiple
$model->whereIn('id', [1, 2])->set(['active' => 0])->update();
// ─── Delete ───────────────────────────────────────────
$model->delete(1);
$model->delete([1, 2, 3]); // delete multiple
// ─── Count ────────────────────────────────────────────
$count = $model->countAll();
$count = $model->where('active', 1)->countAllResults();
// ─── Check existence ──────────────────────────────────
$exists = $model->where('email', 'r@b.com')->countAllResults() > 0;Save (Insert or Update)
save() decides insert vs update based on whether the primary key is present in data:
// Insert (no primary key in data)
$model->save(['name' => 'Rob', 'email' => 'r@b.com']);
// Update (primary key present in data)
$model->save(['id' => 1, 'name' => 'Robert']);Soft Deletes
protected $useSoftDeletes = true;
protected $deletedField = 'deleted_at';
$model->delete(1); // sets deleted_at, does NOT remove row
$model->delete(1, true); // hard delete (permanently removes row)
$model->withDeleted()->findAll(); // includes soft-deleted rows
$model->onlyDeleted()->findAll(); // only soft-deleted rows
$model->purgeDeleted(); // permanently remove all soft-deleted rowsTimestamps
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
// Automatically sets created_at on insert, updated_at on update.
// Columns must exist in the table.
// Disable for a single operation
$model->skipTimestamps(true)->update(1, $data);Validation in Models
protected $validationRules = [
'email' => 'required|valid_email|is_unique[users.email,id,{id}]',
'name' => 'required|min_length[2]',
];
protected $validationMessages = [
'email' => [
'is_unique' => 'That email is already taken.',
],
];
$model->save($data); // validates before saving
$model->errors(); // returns validation errors after failed save
$model->skipValidation(true); // bypass validation for this call
// {id} placeholder in is_unique — automatically replaced with the current
// record's primary key value during updates, so it won't fail on itself.Pagination
// Controller
$users = $model->paginate(20); // 20 per page, reads ?page= from URL
$pager = $model->pager; // pager instance for view
// With where clause
$users = $model->where('active', 1)->paginate(20);
$pager = $model->pager;
// Named pager group (for multiple pagers on one page)
$users = $model->paginate(20, 'users');
$events = $eventModel->paginate(10, 'events');
// View
echo $pager->links(); // full pagination links
echo $pager->simpleLinks(); // Previous / Next only
echo $pager->links('users', 'bootstrap_full'); // named group + templateQuery Scopes (Method Chaining)
// Define chainable scope methods that return static
public function active(): static
{
return $this->where('active', 1);
}
public function byRole(string $role): static
{
return $this->where('role', $role);
}
public function recent(int $days = 30): static
{
return $this->where('created_at >', date('Y-m-d', strtotime("-{$days} days")));
}
// Usage
$users = $model->active()->byRole('admin')->recent()->findAll();Callbacks (Model Events)
protected $beforeInsert = ['hashPassword'];
protected $afterInsert = ['logCreation'];
protected $beforeUpdate = ['hashPassword'];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
protected function hashPassword(array $data): array
{
if (isset($data['data']['password'])) {
$data['data']['password'] = password_hash($data['data']['password'], PASSWORD_DEFAULT);
}
return $data;
}
protected function logCreation(array $data): array
{
log_message('info', 'User created with ID: ' . $data['id']);
return $data;
}Callback Data Structure
- beforeInsert:
$data['data']contains the row data being inserted - afterInsert:
$data['id']contains the new record's ID,$data['data']contains the row data - beforeUpdate:
$data['data']contains the update data,$data['id']contains the primary key(s) - afterUpdate: same as beforeUpdate plus
$data['result'](bool) - beforeFind:
$data['method']contains the find method name - afterFind:
$data['data']contains the found row(s) - beforeDelete:
$data['id']contains the primary key(s) - afterDelete:
$data['id']contains the primary key(s),$data['result'](bool)
Entities
Entities are typed object representations of a row. Optional but useful for transformation logic.
<?php
namespace App\Entities;
use CodeIgniter\Entity\Entity;
class User extends Entity
{
// Cast columns to PHP types
protected $casts = [
'active' => 'boolean',
'metadata' => 'json',
'created_at' => 'datetime',
];
// Custom setter — auto-transforms on assignment
public function setPassword(string $pass): static
{
$this->attributes['password'] = password_hash($pass, PASSWORD_DEFAULT);
return $this;
}
// Custom getter
public function getDisplayName(): string
{
return $this->attributes['first_name'] . ' ' . $this->attributes['last_name'];
}
}Using Entities with Models
// In model
protected $returnType = 'App\Entities\User';
// Usage
$user = $model->find(1); // returns User entity, not stdClass
$user->name = 'New Name'; // uses setter if defined
$user->password = 'secret'; // calls setPassword()
$model->save($user); // entity implements toArray() for saveAvailable Casts
| Cast | PHP Type |
|---|---|
'integer' | int |
'float' | float |
'double' | float |
'string' | string |
'boolean' | bool |
'array' | array (from JSON/serialized) |
'object' | object (from JSON/serialized) |
'json' | array (from JSON column) |
'json-array' | array (from JSON column) |
'datetime' | Time instance |
'timestamp' | int (Unix timestamp) |
'uri' | URI instance |
'int-bool' | bool (from 0/1 column) |
'csv' | array (from comma-separated string) |
CI4 Query Builder — Complete Reference
The Query Builder is available via $this->db in models, or \Config\Database::connect() anywhere.
$db = \Config\Database::connect();
$builder = $db->table('users');SELECT
$builder->select('id, name, email');
$builder->select('COUNT(*) AS total');
$builder->selectMin('age');
$builder->selectMax('age');
$builder->selectAvg('score');
$builder->selectSum('amount');
$builder->selectCount('id');
$builder->selectCount('id', 'user_count'); // aliasedDISTINCT
// CORRECT
$builder->select('role')->distinct();
// WRONG — silently returns wrong results in CI4
$builder->select('DISTINCT role');WHERE
$builder->where('active', 1);
$builder->where('age >', 18);
$builder->where('name !=', 'Rob');
$builder->where('created_at >', '2024-01-01');
// Multiple WHERE (AND)
$builder->where('active', 1)->where('role', 'admin');
// OR WHERE
$builder->orWhere('role', 'superadmin');
// WHERE IN
$builder->whereIn('id', [1, 2, 3]);
$builder->whereNotIn('status', ['banned', 'suspended']);
$builder->orWhereIn('role', ['admin', 'superadmin']);
$builder->orWhereNotIn('status', ['inactive']);
// WHERE NULL — CRITICAL GOTCHA
$builder->where('deleted_at IS NULL'); // CORRECT
// $builder->whereNull('deleted_at'); // DOES NOT EXIST in CI4
// WHERE NOT NULL
$builder->where('deleted_at IS NOT NULL'); // CORRECT
// LIKE
$builder->like('name', 'rob'); // LIKE '%rob%'
$builder->like('name', 'rob', 'after'); // LIKE 'rob%'
$builder->like('name', 'rob', 'before'); // LIKE '%rob'
$builder->notLike('name', 'test');
// OR LIKE — GOTCHA: must use groupStart/groupEnd after a where()
$builder->where('active', 1)
->groupStart()
->like('name', 'rob')
->orLike('email', 'rob')
->groupEnd();
// BETWEEN (no native method — use where)
$builder->where('age >=', 18)->where('age <=', 65);
// Raw WHERE
$builder->where("YEAR(created_at) = ", 2024);GROUP START / GROUP END
For complex WHERE clauses with OR logic:
// WHERE active = 1 AND (role = 'admin' OR role = 'superadmin')
$builder->where('active', 1)
->groupStart()
->where('role', 'admin')
->orWhere('role', 'superadmin')
->groupEnd();
// Nested groups
$builder->where('active', 1)
->groupStart()
->groupStart()
->where('role', 'admin')
->where('verified', 1)
->groupEnd()
->orGroupStart()
->where('role', 'superadmin')
->groupEnd()
->groupEnd();
// OR group start
$builder->where('status', 'active')
->orGroupStart()
->where('role', 'admin')
->where('override', 1)
->groupEnd();
// NOT group start
$builder->notGroupStart()
->where('role', 'banned')
->orWhere('role', 'suspended')
->groupEnd();GROUP BY / HAVING / ORDER BY / LIMIT
$builder->groupBy('role');
$builder->groupBy(['role', 'department']); // multiple
$builder->having('count >', 5);
$builder->having('total >=', 100);
$builder->orderBy('created_at', 'DESC');
$builder->orderBy('name', 'ASC');
$builder->orderBy('RANDOM()'); // random order
$builder->limit(10);
$builder->limit(10, 20); // limit 10, offset 20
$builder->offset(20);JOINS
$builder->join('orders', 'orders.user_id = users.id');
$builder->join('roles', 'roles.id = users.role_id', 'left');
$builder->join('profiles', 'profiles.user_id = users.id', 'left outer');
// Join types: 'inner' (default), 'left', 'right', 'outer', 'left outer', 'right outer'
// Complex join conditions
$builder->join('orders', 'orders.user_id = users.id AND orders.status = "active"', 'left');GET (Execute)
$query = $builder->get(); // run query
$rows = $query->getResult(); // array of objects
$rows = $query->getResultArray(); // array of arrays
$row = $query->getRow(); // first row as object
$row = $query->getRowArray(); // first row as array
$count = $query->getNumRows(); // number of rows
$fields = $query->getFieldData(); // column metadata
$names = $query->getFieldNames(); // column names
// Get with limit
$query = $builder->get(10); // LIMIT 10
$query = $builder->get(10, 20); // LIMIT 10 OFFSET 20INSERT
$builder->insert(['name' => 'Rob', 'email' => 'r@b.com']);
$id = $db->insertID(); // last inserted ID
// Ignore duplicates
$builder->ignore(true)->insert($data); // INSERT IGNOREUPDATE
$builder->where('id', 1)->update(['name' => 'Robert']);
// SET
$builder->set('name', 'Robert');
$builder->set('views', 'views+1', false); // false = no escaping (raw SQL)
$builder->where('id', 1)->update();
// Increment / Decrement (raw SQL via set)
$builder->set('count', 'count+1', false)->where('id', 1)->update();
$builder->set('stock', 'stock-1', false)->where('id', 1)->update();DELETE
$builder->where('id', 1)->delete();
$builder->emptyTable(); // DELETE all rows (no WHERE)
$builder->truncate(); // TRUNCATE table (faster, resets auto-increment)Batch Operations
// Insert multiple rows at once
$data = [
['name' => 'Alice', 'email' => 'alice@example.com'],
['name' => 'Bob', 'email' => 'bob@example.com'],
];
$builder->insertBatch($data); // returns number of rows inserted
// Update multiple rows at once
$data = [
['id' => 1, 'name' => 'Alice Updated'],
['id' => 2, 'name' => 'Bob Updated'],
];
$builder->updateBatch($data, 'id'); // second arg = match column
// Upsert (insert or update on conflict)
$builder->upsertBatch($data); // CI4 4.3+GOTCHA: insertBatch() bypasses model $allowedFields — pass only the columns you intend to insert.
Subqueries
$subquery = $db->table('orders')->select('user_id')->where('total >', 100);
$builder->whereIn('id', $subquery);
// Subquery in SELECT
$builder->selectSubquery($db->table('orders')->selectCount('id')->where('orders.user_id = users.id'), 'order_count');Raw Queries
// Positional binds
$query = $db->query("SELECT * FROM users WHERE id = ?", [$id]);
$rows = $query->getResult();
// Named binds
$query = $db->query("SELECT * FROM users WHERE email = :email:", ['email' => $email]);
// Multiple results
$rows = $query->getResult(); // objects
$rows = $query->getResultArray(); // arrays
$row = $query->getRow(); // single objectDebugging
// Get compiled SQL without executing
$sql = $builder->where('active', 1)->getCompiledSelect();
// → "SELECT * FROM `users` WHERE `active` = 1"
// Reset = false preserves the query for continued chaining
$sql = $builder->where('active', 1)->getCompiledSelect(false);
$rows = $builder->limit(10)->get()->getResult();
// Other compiled methods
$sql = $builder->getCompiledInsert();
$sql = $builder->getCompiledUpdate();
$sql = $builder->getCompiledDelete();
// Last query (after execution)
$lastQuery = $db->getLastQuery();
echo (string) $lastQuery; // full SQL with values interpolated---
Transactions
Transactions wrap multiple queries so they either all succeed or all roll back.
High-Level (Recommended)
$db = \Config\Database::connect();
$db->transStart();
$db->table('orders')->insert(['user_id' => 1, 'total' => 50.00]);
$db->table('ticket_reservations')->insert(['order_id' => $db->insertID(), 'qty' => 2]);
if ($db->transStatus() === false) {
// Something failed — transComplete() will roll back
}
$db->transComplete(); // commits on success, rolls back on failureException Mode
$db->transException(true)->transStart();
try {
$db->table('orders')->insert([...]);
$db->table('tickets')->insert([...]);
$db->transComplete();
} catch (\Throwable $e) {
$db->transRollback();
// handle error
}Manual Control
$db->transBegin();
// ... queries ...
$db->transCommit(); // explicit commit
$db->transRollback(); // explicit rollbackTransaction Notes
transStart()is the recommended high-level wrapper; it callstransBegin()internally.- Nested transactions use reference counting (
transDepth) — the outermosttransComplete()commits. transStatus()returnsfalseif any query in the transaction failed.
CI4 Routing — Complete Reference
All routes defined in app/Config/Routes.php.
HTTP Verb Routes
$routes->get('users', 'UserController::index');
$routes->post('users', 'UserController::create');
$routes->put('users/(:num)', 'UserController::update/$1');
$routes->patch('users/(:num)', 'UserController::update/$1');
$routes->delete('users/(:num)', 'UserController::delete/$1');
$routes->match(['get', 'post'], 'form', 'FormController::index');Route Placeholders
| Placeholder | Matches | Regex |
|---|---|---|
(:num) | Digits only | [0-9]+ |
(:alpha) | Alphabetic only | [a-zA-Z]+ |
(:alphanum) | Alphanumeric | [a-zA-Z0-9]+ |
(:segment) | URL segment (no slashes) | [^/]+ |
(:any) | Anything including slashes | .+ |
Custom Regex Placeholders
$routes->get('products/([a-z]{2})/(:num)', 'Products::show/$1/$2');Route Groups
$routes->group('admin', ['namespace' => 'App\Controllers\Admin'], function ($routes) {
$routes->get('dashboard', 'DashboardController::index');
$routes->get('users', 'UsersController::index');
$routes->resource('events');
});
// Nested groups
$routes->group('api', function ($routes) {
$routes->group('v1', ['namespace' => 'App\Controllers\Api\V1'], function ($routes) {
$routes->resource('users');
});
});Filters on Routes
// Single filter
$routes->get('dashboard', 'DashboardController::index', ['filter' => 'session']);
// Multiple filters
$routes->get('admin', 'AdminController::index', ['filter' => ['session', 'group:admin']]);
// Filter with arguments
$routes->get('admin', 'AdminController::index', ['filter' => 'group:admin,superadmin']);
// Filter on a group
$routes->group('admin', ['filter' => 'session'], function ($routes) {
$routes->get('/', 'Admin\DashboardController::index');
});GOTCHA: Filter options on parent route groups are not merged with child groups. Each group must declare its own filters.
Named Routes
$routes->get('profile', 'ProfileController::index', ['as' => 'profile']);
$routes->get('users/(:num)', 'UserController::show/$1', ['as' => 'user.show']);
// Generate URL from named route
$url = url_to('profile');
$url = url_to('user.show', 42); // /users/42Redirects
$routes->addRedirect('old-path', 'new-path');
$routes->addRedirect('old/(:any)', 'new/$1');Resource Routes
// Generates full CRUD routes
$routes->resource('photos');
// GET /photos → Photos::index()
// GET /photos/new → Photos::new()
// POST /photos → Photos::create()
// GET /photos/(:segment) → Photos::show($id)
// GET /photos/(:segment)/edit → Photos::edit($id)
// PUT /photos/(:segment) → Photos::update($id)
// DELETE /photos/(:segment) → Photos::delete($id)
// Limit methods
$routes->resource('photos', ['only' => ['index', 'show', 'create']]);
$routes->resource('photos', ['except' => ['new', 'edit']]);
// Custom controller
$routes->resource('photos', ['controller' => 'App\Controllers\Admin\PhotosController']);
// API-only resource (no new/edit views)
$routes->presenter('photos');CLI Routes
$routes->cli('maintenance/on', 'MaintenanceController::enable');
$routes->cli('maintenance/off', 'MaintenanceController::disable');Route Configuration
// Default namespace
$routes->setDefaultNamespace('App\Controllers');
// Default controller
$routes->setDefaultController('Home');
// Default method
$routes->setDefaultMethod('index');
// 404 override
$routes->set404Override(function () {
return view('errors/custom_404');
});
// Auto-routing (disabled by default in CI4 — keep it off for security)
$routes->setAutoRoute(false);Route Priority
Routes are matched in the order they are defined. First match wins. Place more specific routes before generic ones:
// CORRECT order
$routes->get('users/new', 'UserController::new'); // specific first
$routes->get('users/(:num)', 'UserController::show/$1'); // generic second
// WRONG order — 'new' would match (:num) pattern... wait, 'new' is not numeric
// But with (:segment):
$routes->get('users/(:segment)', 'UserController::show/$1'); // catches 'new' too!
$routes->get('users/new', 'UserController::new'); // never reachedCI4 Services, Helpers, Caching, Email & Sessions Reference
Services
Services are singleton-like helpers, resolved via \Config\Services.
Built-in Services
$session = \Config\Services::session();
$validation = \Config\Services::validation();
$uri = \Config\Services::uri();
$request = \Config\Services::request();
$response = \Config\Services::response();
$cache = \Config\Services::cache();
$logger = \Config\Services::logger();
$email = \Config\Services::email();
$throttler = \Config\Services::throttler();
$encrypter = \Config\Services::encrypter();
$curlrequest = \Config\Services::curlrequest();
// Shorthand
service('session');
service('validation');
service('cache');Custom Service
// Register in app/Config/Services.php
public static function stripe(bool $getShared = true): \App\Services\StripeService
{
if ($getShared) return static::getSharedInstance('stripe');
return new \App\Services\StripeService();
}
// Usage
$stripe = \Config\Services::stripe();
$stripe = service('stripe');Shared vs Non-Shared
// Shared (singleton — same instance every time, default)
$db = \Config\Services::database();
// Non-shared (new instance)
$db = \Config\Services::database(false);---
Helpers
Loading Helpers
// Load single
helper('url');
// Load multiple
helper(['url', 'form', 'text']);
// Auto-load in BaseController
class BaseController extends Controller
{
protected $helpers = ['url', 'form'];
}
// Auto-load globally in app/Config/Autoload.php
public $helpers = ['url', 'form'];URL Helper
helper('url');
base_url('path/to/resource'); // full URL from web root
site_url('users/1'); // full URL with index.php (if configured)
url_to('ControllerName::method', $arg); // URL from controller method
url_to('named-route', $arg); // URL from named route
current_url(); // current full URL
previous_url(); // previous URL (from session)
uri_string(); // current URI path only (no domain)
anchor('users', 'View Users'); // <a href="...">View Users</a>Form Helper
helper('form');
form_open('users/create'); // <form action="..." method="post">
form_open('users/create', ['class' => 'form-inline']);
form_open_multipart('users/create'); // with enctype for file uploads
form_close(); // </form>
csrf_field(); // CSRF hidden input
form_input('name', old('name'), ['class' => 'form-control']);
form_password('password');
form_textarea('bio', old('bio'), ['rows' => 5]);
form_dropdown('role', ['admin' => 'Admin', 'user' => 'User'], old('role'));
form_checkbox('active', '1', old('active') == '1');
form_radio('gender', 'male', old('gender') === 'male');
form_submit('submit', 'Save');
form_hidden('user_id', $id);
set_value('name'); // same as old('name')Text Helper
helper('text');
word_limiter($text, 25); // limit by words
character_limiter($text, 100); // limit by characters
ellipsize($text, 30); // truncate with ellipsis in middle
ascii_to_entities($text); // convert to HTML entitiesFilesystem Helper
helper('filesystem');
write_file('./path/to/file.txt', $data);
$contents = read_file('./path/to/file.txt'); // deprecated — use file_get_contents
delete_files('./path/to/dir/', true); // true = delete dir too
$files = get_filenames('./path/');
$info = get_file_info('./path/to/file.txt');
$size = get_dir_file_info('./path/');Date Helper
helper('date');
now(); // current timestamp
timezone_select(); // timezone dropdown HTMLNumber Helper
helper('number');
number_to_size(1024); // "1 KB"
number_to_amount(1234567); // "1.23 million"
number_to_currency(1234.56, 'USD'); // "$1,234.56"
number_to_roman(14); // "XIV"Custom Helpers
// app/Helpers/my_helper.php
<?php
if (!function_exists('format_phone')) {
function format_phone(string $phone): string
{
return preg_replace('/(\d{3})(\d{3})(\d{4})/', '($1) $2-$3', $phone);
}
}
// Usage
helper('my'); // loads my_helper.php
echo format_phone('5551234567');---
Caching
Basic Usage
$cache = \Config\Services::cache();
$cache->save('key', $data, 300); // save for 300 seconds (5 min)
$data = $cache->get('key'); // retrieve (null if missing/expired)
$cache->delete('key'); // delete single
$cache->clean(); // clear all cache
// Check if key exists
if ($cache->get('key') !== null) { ... }Remember Pattern
$data = cache()->remember('expensive_query', 300, function () {
return model('UserModel')->where('active', 1)->findAll();
});Cache Drivers
Configured in app/Config/Cache.php:
public string $handler = 'file'; // file, redis, memcached, predis, wincache
// Redis config
public array $redis = [
'host' => '127.0.0.1',
'password' => null,
'port' => 6379,
'timeout' => 0,
'database' => 0,
];Tagging (Redis/Memcached only)
$cache->save('user_1', $data, 300, ['users']);
$cache->save('user_2', $data, 300, ['users']);
$cache->deleteMatching('users'); // delete all tagged 'users'---
Configuration
app/Config/Email.php or .env:
email.fromEmail = noreply@example.com
email.fromName = My App
email.protocol = smtp
email.SMTPHost = smtp.example.com
email.SMTPUser = user
email.SMTPPass = pass
email.SMTPPort = 587
email.SMTPCrypto = tls
email.mailType = htmlSending Email
$email = \Config\Services::email();
$email->setFrom('noreply@example.com', 'My App');
$email->setTo('user@example.com');
$email->setCC('cc@example.com');
$email->setBCC('bcc@example.com');
$email->setSubject('Welcome!');
$email->setMessage(view('emails/welcome', ['name' => $name]));
if (!$email->send()) {
log_message('error', $email->printDebugger(['headers']));
}
// Reset for next send
$email->clear();Attachments
$email->attach('/path/to/file.pdf');
$email->attach('/path/to/image.png', 'inline'); // inline image---
Sessions
Configuration
app/Config/Session.php or .env:
session.driver = CodeIgniter\Session\Handlers\DatabaseHandler
session.cookieName = ci_session
session.expiration = 7200
session.savePath = ci_sessions # table name for DB driver
session.matchIP = false
session.timeToUpdate = 300
session.regenerateDestroy = falseDatabase Session Table
php spark session:migration # generates migration for session table
php spark migrateUsage
$session = session(); // or \Config\Services::session()
// Set
$session->set('key', 'value');
$session->set(['key1' => 'val1', 'key2' => 'val2']);
// Get
$value = $session->get('key');
$value = session('key'); // shorthand
$all = $session->get(); // all session data
// Check
$session->has('key'); // bool
// Remove
$session->remove('key');
$session->remove(['key1', 'key2']);
// Flash data (available only on next request)
$session->setFlashdata('message', 'Success!');
$session->getFlashdata('message');
$session->keepFlashdata('message'); // keep for one more request
// Temp data (auto-expires)
$session->setTempdata('token', $value, 300); // expires in 300 seconds
$session->getTempdata('token');
// Destroy
$session->destroy();
// Regenerate ID (do this after login)
$session->regenerate();---
Encryption
// Generate key (run once, add to .env)
// php spark key:generate
$encrypter = \Config\Services::encrypter();
// Encrypt
$encrypted = $encrypter->encrypt('sensitive data');
// Decrypt
$plaintext = $encrypter->decrypt($encrypted);
// Encrypt for URL/cookie (base64-safe)
$encrypted = base64_encode($encrypter->encrypt('data'));
$plaintext = $encrypter->decrypt(base64_decode($encrypted));---
HTTP Client (CURLRequest)
$client = \Config\Services::curlrequest();
// GET
$response = $client->get('https://api.example.com/users');
$body = $response->getBody();
$status = $response->getStatusCode();
$json = json_decode($body, true);
// POST with JSON
$response = $client->post('https://api.example.com/users', [
'headers' => ['Content-Type' => 'application/json'],
'json' => ['name' => 'Rob', 'email' => 'r@b.com'],
]);
// POST with form data
$response = $client->post('https://api.example.com/login', [
'form_params' => ['email' => 'r@b.com', 'password' => 'secret'],
]);
// With auth header
$response = $client->get('https://api.example.com/me', [
'headers' => ['Authorization' => 'Bearer ' . $token],
]);
// Options
$response = $client->get($url, [
'timeout' => 10, // seconds
'verify' => false, // skip SSL verification (dev only)
'allow_redirects' => true,
]);---
Events
// app/Config/Events.php
use CodeIgniter\Events\Events;
// Register a listener
Events::on('user_created', static function ($user) {
log_message('info', "User created: {$user->email}");
});
// Trigger an event (from anywhere)
Events::trigger('user_created', $user);
// Multiple listeners — they run in registration order
Events::on('order_placed', static function ($order) { /* send email */ });
Events::on('order_placed', static function ($order) { /* update inventory */ });---
Logging
log_message('debug', 'Debug message');
log_message('info', 'Informational message');
log_message('notice', 'Notice');
log_message('warning', 'Warning message');
log_message('error', 'Error message');
log_message('critical', 'Critical error');
log_message('alert', 'Alert');
log_message('emergency', 'System is unusable');
// With context
log_message('error', 'User {id} failed login', ['id' => $userId]);Logs written to writable/logs/. Configure threshold in app/Config/Logger.php.
CI4 Spark CLI — Complete Reference
Development
php spark serve # dev server on port 8080
php spark serve --port 3000 # custom port
php spark serve --host 0.0.0.0 # listen on all interfacesRoutes
php spark routes # list all registered routesMigrations
php spark migrate # run pending migrations
php spark migrate:rollback # roll back last batch
php spark migrate:rollback -b 2 # roll back 2 batches
php spark migrate:status # show migration status
php spark migrate:refresh # rollback all + re-migrate (destructive!)
php spark migrate:reset # rollback all (destructive!)Seeds
php spark db:seed DatabaseSeeder # run a seeder
php spark db:seed UserSeeder # run specific seederGenerators (Scaffolding)
# Controllers
php spark make:controller UserController
php spark make:controller Api/UserController --restful # ResourceController
php spark make:controller Admin/UsersController --suffix # keeps "Controller" suffix
# Models
php spark make:model UserModel
php spark make:model UserModel --entity # also creates Entity class
# Migrations
php spark make:migration CreateUsersTable
php spark make:migration AddPhoneToUsers
# Filters
php spark make:filter AuthFilter
# Seeds
php spark make:seeder UserSeeder
# Entities
php spark make:entity UserEntity
# Libraries
php spark make:library PaymentGateway
# Commands (custom spark commands)
php spark make:command ClearExpiredTokens
# Validation rules
php spark make:validation CustomRules
# Config
php spark make:config MyConfig
# Cells (view cells)
php spark make:cell RecentPostsGenerator Options
--namespace App # target namespace (default: App)
--suffix # append type suffix to class name
--force # overwrite existing fileCache
php spark cache:clear # clear all cache
php spark cache:info # cache driver infoSessions
php spark session:migration # generate session DB table migrationEncryption
php spark key:generate # generate encryption key for .envDatabase
php spark db:table # list all tables
php spark db:table users # show table structure
php spark db:create my_database # create databaseShield (Auth)
php spark shield:setup # publish config + migrations
php spark shield:user create # create user via CLI
php spark shield:user activate # activate a user
php spark shield:user deactivate # deactivate a user
php spark shield:user changename # change username
php spark shield:user changepassword # change passwordMaintenance
php spark env # show current environment
php spark phpini:check # check php.ini settingsNamespaces
php spark namespaces # list all registered namespacesCustom Commands
Create a custom spark command:
<?php
// app/Commands/ClearExpiredTokens.php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class ClearExpiredTokens extends BaseCommand
{
protected $group = 'App';
protected $name = 'tokens:clear';
protected $description = 'Clear expired access tokens';
protected $usage = 'tokens:clear [days]';
protected $arguments = [
'days' => 'Number of days to keep (default: 30)',
];
protected $options = [
'--dry-run' => 'Show what would be deleted without deleting',
];
public function run(array $params)
{
$days = (int) ($params[0] ?? 30);
$dryRun = CLI::getOption('dry-run');
$cutoff = date('Y-m-d H:i:s', strtotime("-{$days} days"));
$db = \Config\Database::connect();
$count = $db->table('auth_identities')
->where('type', 'access_token')
->where('last_used_at <', $cutoff)
->countAllResults(false);
if ($dryRun) {
CLI::write("Would delete {$count} expired tokens.", 'yellow');
return;
}
$db->table('auth_identities')
->where('type', 'access_token')
->where('last_used_at <', $cutoff)
->delete();
CLI::write("Deleted {$count} expired tokens.", 'green');
}
}Run: php spark tokens:clear 60 --dry-run
CLI Helper Methods
// Output
CLI::write('Normal text');
CLI::write('Green text', 'green');
CLI::write('Red bold', 'red', 'bold');
CLI::error('Error message'); // stderr, red
CLI::newLine();
// Input
$name = CLI::prompt('What is your name?');
$role = CLI::prompt('Role?', ['admin', 'user']); // with choices
$confirm = CLI::prompt('Are you sure?', ['y', 'n']);
// Progress
CLI::showProgress(false); // start
for ($i = 1; $i <= 100; $i++) {
CLI::showProgress($i, 100); // update
}
CLI::showProgress(false); // end
// Table
CLI::table([
['Name', 'Email', 'Role'],
['Rob', 'rob@example.com', 'admin'],
['Alice', 'alice@example.com', 'user'],
]);CI4 Testing — Complete Reference
CI4 uses PHPUnit. Test files go in tests/.
Running Tests
composer test
# or
php vendor/bin/phpunit
php vendor/bin/phpunit tests/unit/UserModelTest.php
php vendor/bin/phpunit --filter testFindUser
php vendor/bin/phpunit --group databaseUnit Test
<?php
namespace Tests\Unit;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\UserModel;
class UserModelTest extends CIUnitTestCase
{
public function testFindUser(): void
{
$model = new UserModel();
$user = $model->find(1);
$this->assertNotNull($user);
$this->assertEquals('Rob', $user->name);
}
public function testInsertUser(): void
{
$model = new UserModel();
$id = $model->insert([
'name' => 'Test User',
'email' => 'test@example.com',
]);
$this->assertIsInt($id);
$this->assertGreaterThan(0, $id);
}
}Feature / HTTP Test
<?php
namespace Tests\Feature;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
class UserControllerTest extends CIUnitTestCase
{
use FeatureTestTrait;
public function testIndex(): void
{
$result = $this->get('users');
$result->assertStatus(200);
$result->assertSee('Users');
}
public function testShow(): void
{
$result = $this->get('users/1');
$result->assertStatus(200);
$result->assertSee('Rob');
}
public function testCreate(): void
{
$result = $this->post('users', [
'name' => 'New User',
'email' => 'new@example.com',
]);
$result->assertRedirectTo(base_url('users'));
}
public function testCreateValidationFails(): void
{
$result = $this->post('users', [
'name' => '', // required field empty
]);
$result->assertStatus(200); // re-displays form
$result->assertSee('name field is required');
}
public function testApiEndpoint(): void
{
$result = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Content-Type' => 'application/json',
])->call('post', 'api/v1/users', json_encode([
'name' => 'API User',
'email' => 'api@example.com',
]));
$result->assertStatus(201);
$result->assertJSONFragment(['name' => 'API User']);
}
}FeatureTestTrait Methods
// HTTP methods
$result = $this->get($uri);
$result = $this->post($uri, $data);
$result = $this->put($uri, $data);
$result = $this->patch($uri, $data);
$result = $this->delete($uri);
$result = $this->options($uri);
// Generic call
$result = $this->call('get', $uri);
$result = $this->call('post', $uri, $data);
// With headers
$result = $this->withHeaders(['X-Custom' => 'value'])->get($uri);
// With session data
$result = $this->withSession(['user_id' => 1])->get('dashboard');
// With body (for JSON APIs)
$result = $this->withBody(json_encode($data))->call('post', $uri);Test Result Assertions
// Status
$result->assertStatus(200);
$result->assertOK(); // 200
$result->assertRedirect(); // 3xx
$result->assertRedirectTo($url);
// Content
$result->assertSee('text'); // text appears in body
$result->assertDontSee('text');
$result->assertSeeElement('h1'); // CSS selector
$result->assertDontSeeElement('.error');
$result->assertSeeLink('Click Me'); // <a> with text
$result->assertSeeInField('name', 'Rob');
// JSON
$result->assertJSONFragment(['key' => 'value']);
$result->assertJSONExact($expected);
// Headers
$result->assertHeader('Content-Type', 'application/json');
$result->assertHeaderMissing('X-Custom');
// Cookies
$result->assertCookie('session');
$result->assertCookieMissing('old_cookie');
$result->assertCookieExpired('temp');
// Session
$result->assertSessionHas('key');
$result->assertSessionHas('key', 'value');
$result->assertSessionMissing('key');Database Test Trait
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
class UserModelTest extends CIUnitTestCase
{
use DatabaseTestTrait;
// Rolls back DB changes after each test
protected $refresh = true;
// Run this seeder before each test
protected $seed = 'UserSeeder';
// Use a specific DB group
protected $DBGroup = 'tests';
public function testUserCount(): void
{
$this->assertCount(5, model('UserModel')->findAll());
}
}Database Assertions
$this->seeInDatabase('users', ['email' => 'rob@example.com']);
$this->dontSeeInDatabase('users', ['email' => 'deleted@example.com']);
$this->seeNumRecords(5, 'users', ['active' => 1]);
$this->grabFromDatabase('users', 'name', ['id' => 1]); // returns column valueTesting with Shield Authentication
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\DatabaseTestTrait;
class AdminTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
protected $refresh = true;
public function testAdminPageRequiresAuth(): void
{
$result = $this->get('admin');
$result->assertRedirectTo(base_url('login'));
}
public function testAdminPageAccessible(): void
{
$user = $this->createUser();
$user->addGroup('admin');
$result = $this->actingAs($user)->get('admin');
$result->assertStatus(200);
}
private function createUser(): \CodeIgniter\Shield\Entities\User
{
$users = auth()->getProvider();
$user = new \CodeIgniter\Shield\Entities\User([
'username' => 'testuser',
'email' => 'test@example.com',
'password' => 'TestPass123!',
]);
$users->save($user);
return $users->findById($users->getInsertID());
}
}actingAs() — Authenticate for a test
$this->actingAs($user)->get('admin/dashboard');
$this->actingAs($user)->post('api/posts', $data);Mocking Services
// Mock a service
$mock = $this->createMock(\App\Services\PaymentService::class);
$mock->method('charge')->willReturn(true);
// Inject into Services
\Config\Services::injectMock('payment', $mock);
// Mock the cache
$mockCache = $this->createMock(\CodeIgniter\Cache\CacheInterface::class);
$mockCache->method('get')->willReturn(null);
\Config\Services::injectMock('cache', $mockCache);
// Reset mocks after test
\Config\Services::reset();Test Configuration
phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/codeigniter4/framework/system/Test/bootstrap.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/feature</directory>
</testsuite>
</testsuites>
<php>
<server name="app.baseURL" value="http://localhost:8080/"/>
<const name="HOMEPATH" value="./"/>
<const name="CONFIGPATH" value="./app/Config/"/>
<const name="PUBLICPATH" value="./public/"/>
</php>
</phpunit>Test .env
Create phpunit.xml or .env.testing with test database config:
database.tests.hostname = localhost
database.tests.database = test_db
database.tests.username = root
database.tests.password =
database.tests.DBDriver = MySQLiCI4 Validation — Complete Reference
Using Validation
In Controllers
$rules = [
'email' => 'required|valid_email',
'name' => 'required|min_length[2]|max_length[100]',
];
if (!$this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
// With custom error messages
$messages = [
'email' => [
'required' => 'Email is required.',
'valid_email' => 'Please enter a valid email.',
],
];
if (!$this->validate($rules, $messages)) { ... }validateData() — Validate Arbitrary Data
// Validate data not from the request (useful for file upload rules)
if (!$this->validateData($data, $rules)) { ... }
if (!$this->validateData([], $fileRules)) { ... } // file validationIn Models
protected $validationRules = [
'email' => 'required|valid_email|is_unique[users.email,id,{id}]',
'name' => 'required|min_length[2]',
];
protected $validationMessages = [
'email' => [
'is_unique' => 'That email is already taken.',
],
];
// {id} placeholder — auto-replaced with the current record's
// primary key during updates, so uniqueness check skips the current rowStandalone Validation Service
$validation = \Config\Services::validation();
$validation->setRules([
'email' => 'required|valid_email',
'name' => 'required',
]);
if (!$validation->run($data)) {
$errors = $validation->getErrors();
}All Built-in Rules
General Rules
| Rule | Description | Example |
|---|---|---|
required | Field must be present and not empty | required |
permit_empty | Allow empty value, skip other rules if empty | `permit_empty\ |
if_exist | Only validate if field exists in data | `if_exist\ |
in_list | Must be one of the listed values | in_list[admin,user,staff] |
not_in_list | Must NOT be one of the listed values | not_in_list[banned,deleted] |
matches | Must match another field | matches[password_confirm] |
differs | Must differ from another field | differs[username] |
is_unique | Must be unique in DB table | is_unique[users.email] |
is_not_unique | Must already exist in DB table | is_not_unique[roles.name] |
String Rules
| Rule | Description | Example |
|---|---|---|
min_length | Minimum string length | min_length[3] |
max_length | Maximum string length | max_length[255] |
exact_length | Exact string length | exact_length[10] |
alpha | Only alphabetic characters | alpha |
alpha_numeric | Only alphanumeric | alpha_numeric |
alpha_numeric_space | Alphanumeric + spaces | alpha_numeric_space |
alpha_dash | Alpha + dashes + underscores | alpha_dash |
alpha_numeric_punct | Alphanumeric + common punctuation | alpha_numeric_punct |
regex_match | Must match regex pattern | regex_match[/^[A-Z]/] |
valid_email | Valid email format | valid_email |
valid_emails | Comma-separated valid emails | valid_emails |
valid_url | Valid URL | valid_url |
valid_url_strict | Valid URL (stricter) | valid_url_strict[https] |
valid_ip | Valid IP address | valid_ip |
valid_base64 | Valid base64 string | valid_base64 |
valid_json | Valid JSON string | valid_json |
valid_date | Valid date string | valid_date[Y-m-d] |
Numeric Rules
| Rule | Description | Example |
|---|---|---|
numeric | Numeric (including decimals) | numeric |
integer | Integer only | integer |
decimal | Decimal number | decimal |
is_natural | Natural number (0+) | is_natural |
is_natural_no_zero | Natural number (1+) | is_natural_no_zero |
greater_than | Greater than value | greater_than[0] |
greater_than_equal_to | Greater than or equal | greater_than_equal_to[1] |
less_than | Less than value | less_than[100] |
less_than_equal_to | Less than or equal | less_than_equal_to[99] |
Database Rules
| Rule | Description | Example |
|---|---|---|
is_unique | Unique in table.column | is_unique[users.email] |
is_unique | Unique, ignoring a row | is_unique[users.email,id,{id}] |
is_not_unique | Must exist in table | is_not_unique[roles.name] |
The is_unique ignore syntax: is_unique[table.column,ignore_field,ignore_value]
- During updates, use
{id}as ignore_value — auto-replaced with the current record's primary key - Example:
is_unique[users.email,id,{id}]— unique email, but skip the current user
File Upload Rules
Important: File rules must use validateData([], $rules) not validate($rules).
| Rule | Description | Example |
|---|---|---|
uploaded | File was actually uploaded | uploaded[avatar] |
max_size | Max file size in KB | max_size[avatar,2048] |
max_dims | Max image dimensions | max_dims[avatar,1024,768] |
min_dims | Min image dimensions (4.6+) | min_dims[avatar,100,100] |
mime_in | Allowed MIME types | mime_in[avatar,image/png,image/jpeg] |
ext_in | Allowed extensions | ext_in[avatar,png,jpg,gif] |
is_image | Must be an image | is_image[avatar] |
$rules = [
'avatar' => [
'label' => 'Avatar',
'rules' => [
'uploaded[avatar]',
'is_image[avatar]',
'mime_in[avatar,image/jpg,image/jpeg,image/png,image/webp]',
'max_size[avatar,2048]',
'max_dims[avatar,1024,768]',
],
],
];
if (!$this->validateData([], $rules)) { ... }Custom Validation Rules
Inline Closure
$rules = [
'username' => [
'required',
static function (string $value): bool {
return !str_contains($value, 'admin');
},
],
];Rule Class
// app/Validation/CustomRules.php
<?php
namespace App\Validation;
class CustomRules
{
public function not_reserved(string $str): bool
{
$reserved = ['admin', 'root', 'system'];
return !in_array(strtolower($str), $reserved);
}
public function valid_slug(string $str): bool
{
return preg_match('/^[a-z0-9-]+$/', $str) === 1;
}
}
// Register in app/Config/Validation.php
public array $ruleSets = [
\CodeIgniter\Validation\StrictRules\CreditCardRules::class,
\CodeIgniter\Validation\StrictRules\FileRules::class,
\CodeIgniter\Validation\StrictRules\FormatRules::class,
\CodeIgniter\Validation\StrictRules\Rules::class,
\App\Validation\CustomRules::class, // add your class
];
// Usage
$rules = ['slug' => 'required|valid_slug'];Validation Rule Groups
Define reusable rule sets in app/Config/Validation.php:
// app/Config/Validation.php
public array $userCreate = [
'name' => 'required|min_length[2]|max_length[150]',
'email' => 'required|valid_email|is_unique[users.email]',
'role' => 'required|in_list[admin,user]',
];
public array $userUpdate = [
'name' => 'if_exist|min_length[2]|max_length[150]',
'email' => 'if_exist|valid_email',
'role' => 'if_exist|in_list[admin,user]',
];
// Usage in controller
if (!$this->validate('userCreate')) { ... }Error Display
// Get all errors
$errors = $this->validator->getErrors();
// ['email' => 'The email field is required.', 'name' => 'The name field...']
// Get single field error
$error = $this->validator->getError('email');
// Check if field has error
$this->validator->hasError('email'); // bool
// In view — display all errors
<?php if (session()->getFlashdata('errors')): ?>
<ul>
<?php foreach (session()->getFlashdata('errors') as $error): ?>
<li><?= esc($error) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
// In view — display per-field error
<?php if (isset($errors['email'])): ?>
<span class="text-danger"><?= esc($errors['email']) ?></span>
<?php endif; ?>CI4 Views — Complete Reference
Views live in app/Views/ as .php files.
Basic View
// In controller
return view('users/index', ['users' => $users, 'title' => 'Users']);
// In view (app/Views/users/index.php)
<h1><?= esc($title) ?></h1>
<?php foreach ($users as $user): ?>
<p><?= esc($user->name) ?></p>
<?php endforeach; ?>Always use `esc()` for output — it XSS-escapes by default.
Layout + Section Pattern
// Layout (app/Views/layouts/main.php)
<!DOCTYPE html>
<html>
<head><title><?= $this->renderSection('title') ?></title></head>
<body>
<?= $this->renderSection('content') ?>
<?= $this->renderSection('extra_scripts') ?>
</body>
</html>
// Child view
<?= $this->extend('layouts/main') ?>
<?= $this->section('title') ?>My Page<?= $this->endSection() ?>
<?= $this->section('content') ?>
<p>Hello</p>
<?= $this->endSection() ?>
<?= $this->section('extra_scripts') ?>
<script>console.log('hi')</script>
<?= $this->endSection() ?>GOTCHA: Never use `return` or early exit inside a CI4 view that uses `$this->extend()`. Layout rendering requires all sections to complete. Use if/else to conditionally show content, never return.
GOTCHA: CI4 view sections cannot be nested. Always call $this->endSection() for the content section BEFORE starting $this->section('extra_scripts'). This fails silently — no error, just missing output.
// WRONG — nested sections, scripts will be swallowed
<?= $this->section('content') ?>
<p>Hello</p>
<?= $this->section('extra_scripts') ?> // opened inside content
<script>console.log('hi')</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
// CORRECT — close content first, then open extra_scripts
<?= $this->section('content') ?>
<p>Hello</p>
<?= $this->endSection() ?> // content closed
<?= $this->section('extra_scripts') ?>
<script>console.log('hi')</script>
<?= $this->endSection() ?>Partials
// Include a partial (inherits parent view variables)
<?= $this->include('partials/_navbar') ?>
// Include with explicit data
<?= view('partials/_card', ['item' => $item]) ?>GOTCHA: $this->include() does pass parent variables. view() inside a view does NOT pass the parent view's variables automatically — pass data explicitly.
View Data Escaping
esc($value); // HTML (default)
esc($value, 'html'); // HTML entities
esc($value, 'js'); // JavaScript context
esc($value, 'attr'); // HTML attribute context
esc($value, 'url'); // URL encoding
esc($value, 'raw'); // No escaping (use carefully)View Cells
View Cells are mini-controllers that generate HTML fragments. Useful for reusable components.
// Simple cell (function-based)
// app/Cells/RecentPostsCell.php
<?php
namespace App\Cells;
class RecentPostsCell
{
public function render(array $params = []): string
{
$limit = $params['limit'] ?? 5;
$posts = model('PostModel')->orderBy('created_at', 'DESC')->findAll($limit);
return view('cells/recent_posts', ['posts' => $posts]);
}
}
// Usage in any view
<?= view_cell('App\Cells\RecentPostsCell', ['limit' => 10]) ?>
<?= view_cell('\App\Cells\RecentPostsCell::render', 'limit=10') ?>Controlled Cells (Class-based)
// app/Cells/AlertMessage.php
<?php
namespace App\Cells;
use CodeIgniter\View\Cells\Cell;
class AlertMessage extends Cell
{
public string $type = 'info';
public string $message = '';
// Computed property
public function getClassAttribute(): string
{
return match($this->type) {
'error' => 'alert alert-danger',
'success' => 'alert alert-success',
default => 'alert alert-info',
};
}
}
// app/Cells/alert_message.php (view — auto-discovered by naming convention)
<div class="<?= $classAttribute ?>">
<?= esc($message) ?>
</div>
// Usage
<?= view_cell('AlertMessage', ['type' => 'success', 'message' => 'Saved!']) ?>Caching Views
// Cache a view for 60 seconds
return view('expensive_page', $data, ['cache' => 60]);
// Cache with a custom name
return view('expensive_page', $data, ['cache' => 60, 'cache_name' => 'my_page_cache']);Conditional Display Patterns
// Conditional content based on data
<?php if (empty($users)): ?>
<p>No users found.</p>
<?php else: ?>
<?php foreach ($users as $user): ?>
<p><?= esc($user->name) ?></p>
<?php endforeach; ?>
<?php endif; ?>
// Flash data display
<?php if (session()->getFlashdata('message')): ?>
<div class="alert alert-success"><?= session()->getFlashdata('message') ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
<?php endif; ?>
// Validation errors
<?php if (session()->getFlashdata('errors')): ?>
<ul>
<?php foreach (session()->getFlashdata('errors') as $error): ?>
<li><?= esc($error) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>Form Helper in Views
<?php helper('form'); ?>
<?= form_open('users/create') ?>
<?= form_open_multipart('users/create') ?> <!-- for file uploads -->
<?= csrf_field() ?> <!-- CSRF token hidden field -->
<?= form_input('name', old('name'), ['class' => 'form-control']) ?>
<?= form_password('password', '', ['class' => 'form-control']) ?>
<?= form_textarea('bio', old('bio'), ['rows' => 5]) ?>
<?= form_dropdown('role', ['admin' => 'Admin', 'user' => 'User'], old('role')) ?>
<?= form_checkbox('active', '1', old('active') == '1') ?>
<?= form_submit('submit', 'Save') ?>
<?= form_close() ?>
// Old input (repopulate after validation failure)
<input type="text" name="email" value="<?= old('email') ?>">