Cheatsheet CodeIgniter
Framework PHP leve e simples
CodeIgniter
Installation and Structure
Installation
// Create project with Composer: composer create-project codeigniter4/appstarter my-project cd my-project // Start the development server: php spark serve // Requirements: // - PHP 8.1+ // - ext-intl, ext-mbstring // - Composer // Alternative (manual download): // codeigniter.com/download
composer create-project creates the project with all dependencies. php spark serve starts the development server on port 8080. CodeIgniter 4 requires PHP 8.1+ with the intl and mbstring extensions. It is the lightest PHP framework — no mandatory dependencies beyond the core.
Autoloading and Namespaces
// app/Config/Autoload.php
public array $psr4 = [
APP_NAMESPACE => APPPATH,
'App' => APPPATH,
];
// Add a custom namespace:
public array $psr4 = [
'App' => APPPATH,
'Acme' => ROOTPATH . 'acme/src',
];
// Usage in controllers/models:
use App\Models\ProdutoModel;
use Acme\Payments\Gateway;
// Classmap (specific files):
public array $classmap = [
'MyClass' => APPPATH . 'Libraries/MyClass.php',
];CodeIgniter 4 uses PSR-4 autoloading — each namespace maps to a directory. APP_NAMESPACE points to app/. Custom namespaces let you organize external libraries. classmap maps individual classes. The autoloader is fast and doesn't require composer dump-autoload for classes in app/.
BaseController
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
class BaseController extends Controller
{
protected $helpers = ['url', 'form'];
protected $session;
public function initController(
$request, $response, $logger
) {
parent::initController(
$request, $response, $logger
);
$this->session = session();
}
}
// All controllers extend BaseController
class Product extends BaseController
{
// $this->session already available
}BaseController is the parent of all controllers — ideal for loading helpers, starting the session and defining shared data. initController() is the CI4 "constructor" (don't use __construct). $helpers loads helpers automatically in all controllers. Properties defined here become available across the whole application.
Folder structure
app/
Config/ // settings
Controllers/ // controllers
Models/ // models
Views/ // templates
Database/
Migrations/ // migrations
Seeds/ // seeders
Filters/ // middleware
Libraries/ // custom libraries
public/
index.php // front controller
assets/ // CSS, JS, images
writable/
logs/ // application logs
cache/ // cache
uploads/ // uploaded filesThe app/ folder contains all the application code (MVC). public/ is the only public directory (front controller index.php). writable/ stores logs, cache and uploads (outside web access). This separation protects the source code — only public/ is served by the web server.
Constants and paths
// Path constants (app/Config/Paths.php):
APPPATH // app/
ROOTPATH // root do project
WRITEPATH // writable/
PUBLICPATH // public/
SYSTEMPATH // vendor/codeigniter4/framework/system/
// Usage:
$logFile = WRITEPATH . 'logs/app.log';
$uploadDir = PUBLICPATH . 'uploads/';
$configFile = APPPATH . 'Config/App.php';
// Environment constants:
ENVIRONMENT // 'development' or 'production'
CI_DEBUG // true em development
// Check the environment:
if (ENVIRONMENT === 'development') {
// code only em dev
}Path constants (APPPATH, WRITEPATH, PUBLICPATH) avoid fragile relative paths. ENVIRONMENT indicates the current mode (development/production). CI_DEBUG is true in development. These constants are defined in app/Config/Paths.php and available globally without an import.
Composer and dependencies
// composer.json (dependencies):
{
"require": {
"php": "^8.1",
"codeigniter4/framework": "^4.4"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"codeigniter4/devkit": "^1.2",
"phpunit/phpunit": "^10.5"
}
}
// Install dependencies:
composer install
// Add a package:
composer require dompdf/dompdf
// Update the framework:
composer update codeigniter4/framework
// Scripts:
composer test // execute tests
composer analyze // analysis staticCodeIgniter 4 is installed via Composer — codeigniter4/framework is the core. codeigniter4/devkit adds development tools (debugbar, faker). composer require adds third-party packages. The framework is minimalist — most features are native, without extra packages.
Configuration (.env)
# Copy the template: cp env .env # .env (environment configuration): CI_ENVIRONMENT = development app.baseURL = "http://localhost:8080/" app.forceGlobalSecureRequests = false database.default.hostname = localhost database.default.database = my_db database.default.username = root database.default.password = database.default.DBDriver = MySQLi # Production: # CI_ENVIRONMENT = production
The .env file overrides the app/Config/ settings — never commit it (it is in the .gitignore). CI_ENVIRONMENT controls the debug bar and error reporting. In production, setting production disables error details. app.baseURL must be the project's root URL.
Config App.php
// app/Config/App.php public string $baseURL = 'http://localhost:8080/'; public string $indexPage = 'index.php'; public string $uriProtocol = 'REQUEST_URI'; public string $defaultLocale = 'pt'; public bool $negotiateLocale = false; public array $supportedLocales = ['pt', 'en']; public string $defaultTimezone = 'Europe/Lisbon'; // Session: public string $sessionDriver = 'CodeIgniter\Session\Handlers\FileHandler'; public string $sessionCookieName = 'ci_session'; public int $sessionExpiration = 7200; // Security: public bool $CSPEnabled = false;
app/Config/App.php holds global settings: baseURL, locale, timezone, session and security. defaultLocale sets the default language. sessionExpiration is in seconds (7200 = 2h). CSPEnabled enables Content Security Policy. Most of it can be overridden in the .env.
DB configuration
// app/Config/Database.php
public array $default = [
'DSN' => '',
'hostname' => 'localhost',
'username' => 'root',
'password' => '',
'database' => 'my_db',
'DBDriver' => 'MySQLi',
'DBPrefix' => '',
'pConnect' => false,
'DBDebug' => true,
'charset' => 'utf8mb4',
'DBCollat' => 'utf8mb4_general_ci',
];
// Supported drivers:
// MySQLi, Postgre, SQLite3, SQLSRV, OCI8The DB configuration lives in app/Config/Database.php or in the .env. DBDriver sets the driver (MySQLi, Postgre, SQLite3, SQLSRV). charset utf8mb4 supports emoji and special characters. DBDebug shows SQL errors in development. It supports multiple connections with different groups.
Multiple environments
# .env (development): CI_ENVIRONMENT = development database.default.hostname = localhost database.default.database = my_db_dev # .env.production: CI_ENVIRONMENT = production database.default.hostname = db.server.com database.default.database = my_db_prod # Behavior per environment: # development: # - Debug toolbar visible # - Errors detalhados # - CI_DEBUG = true # production: # - Errors generic (without stack trace) # - Debug toolbar oculta # - CI_DEBUG = false
CI_ENVIRONMENT controls the application's behavior. In development, the debug toolbar shows queries, time and memory. In production, errors are generic (without exposing stack traces). You can use different .env files per environment. CodeIgniter has no .env.example — it uses the env file the a template.
Routes and Controllers
Basic routes
// app/Config/Routes.php
use CodeIgniter\Router\RouteCollection;
$routes->get('/', 'Home::index');
$routes->get('products', 'Product::list');
$routes->post('products', 'Product::create');
$routes->get('products/(:num)', 'Product::ver/$1');
$routes->put('products/(:num)', 'Product::update/$1');
$routes->delete('products/(:num)', 'Product::delete/$1');
// Placeholders:
// (:num) - only numbers
// (:alpha) - only letters
// (:any) - any caractere
// (:segment) - segmento URI (without /)
// (:alphanum) - alphanumericRoutes are defined in app/Config/Routes.php with HTTP verbs (get, post, put, delete). Placeholders like (:num) capture URI segments and pass them the arguments ($1). The format is 'Controller::method'. Routes are evaluated in the order they are defined.
Redirection
// Redirect to URL:
return redirect()->to('/products');
// To a named route:
return redirect()->route('route_name');
// Go back:
return redirect()->back();
// With flash messages:
return redirect()->back()
->with('success', 'Saved successfully!');
// With old input (re-populate form):
return redirect()->back()
->withInput()
->with('error', 'Data invalid');
// Custom HTTP code:
return redirect()->to('/login')->with('msg', 'Session expired');
// In the view:
// <?= session()->getFlashdata('success') ?>redirect() returns a redirection response. ->with() sets flash messages (available only on the next request). ->withInput() preserves the form input (via old() in the view). ->route() uses route names (safer than hardcoded URLs). Flash messages are ideal for post-action feedback (PRG pattern).
Request and response
// In the controller:
public function index()
{
// Request:
$method = $this->request->getMethod();
$uri = $this->request->getUri();
$ip = $this->request->getIPAddress();
$isAjax = $this->request->isAJAX();
$isSecure = $this->request->isSecure();
// Headers:
$auth = $this->request->getHeaderLine('Authorization');
// Custom response:
return $this->response
->setStatusCode(201)
->setHeader('X-Custom', 'value')
->setBody($content);
// Download:
return $this->response
->download('file.pdf', $data);
}$this->request provides access to the HTTP request: method, URI, IP, headers, AJAX. $this->response allows a custom response with a status code, headers and body. ->download() forces a file download. isAJAX() detects XMLHttpRequest requests. These objects are automatically injected into the controller via initController().
Resource routes
// Automatic RESTful routes:
$routes->resource('products');
// Generates 7 routes:
// GET /products -> index
// GET /products/new -> new
// POST /products -> create
// GET /products/(:num) -> show
// GET /products/(:num)/edit -> edit
// PUT /products/(:num) -> update
// DELETE /products/(:num) -> delete
// Presenter (HTML forms):
$routes->presenter('products');
// Limit methods:
$routes->resource('products', [
'only' => ['index', 'show', 'create']
]);
// Different controller:
$routes->resource('products', [
'controller' => 'Api\ProdutoController'
]);$routes->resource() generates the 7 RESTful routes automatically — ideal for a full CRUD. $routes->presenter() is a variant for HTML forms (uses new/edit instead of JSON). only limits the generated methods. The controller must extend ResourceController for API or ResourcePresenter for HTML.
Named routes
// Set a name:
$routes->get('products/(:num)', 'Product::ver/$1',
['the' => 'product.ver']
);
$routes->get('profile', 'Profile::index',
['the' => 'profile']
);
// Use the name in redirects:
return redirect()->route('product.ver', [$id]);
// Generate URL in the view:
<a href="<?= url_to('product.ver', $p['id']) ?>">
Ver product
</a>
<a href="<?= url_to('profile') ?>">My Profile</a>
// Advantage: change the URL without breaking links
// (just change the route, names stay the same)Named routes (['the' => 'name']) decouple URLs from links. url_to() generates the URL from the name and parameters. redirect()->route() redirects by name. If the URL changes, only change the route definition — all links update automatically. Essential for maintenance and refactoring without breaking references.
Error handling
// Throw 404:
throw \CodeIgniter\Exceptions\PageNotFoundException
::forPageNotFound('Product not found');
// Custom error:
throw new \RuntimeException('Error no payment', 500);
// Custom error pages:
// app/Views/errors/html/error_404.php
// app/Views/errors/html/error_exception.php
// app/Views/errors/html/production.php
// Global handler (app/Config/Exceptions.php):
// Customize the error response
// Error logging:
log_message('error', 'Failure no payment: ' . $e->getMessage());
// In production:
// Errors show a generic page (no details)PageNotFoundException returns 404 with a custom page (error_404.php). In development, errors show the full stack trace; in production, a generic page. Error pages live in app/Views/errors/html/. log_message() writes to writable/logs/. Never expose error details in production (security).
Basic controller
<?php
namespace App\Controllers;
class Product extends BaseController
{
public function index()
{
$model = new \App\Models\ProdutoModel();
$data['products'] = $model->findAll();
return view('products/list', $data);
}
public function ver($id)
{
$model = new \App\Models\ProdutoModel();
$data['product'] = $model->find($id);
if (!$data['product']) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
return view('products/detail', $data);
}
}Controllers extend BaseController and contain action methods. return view() renders a template with data. PageNotFoundException returns 404 automatically. The controller orchestrates: it receives the request, calls the model, passes data to the view. Keep business logic in the models/services, not in the controller.
Route filters
// Filter on an individual route:
$routes->get('admin', 'Admin::index',
['filter' => 'auth']
);
// Multiple filters:
$routes->get('admin/relatorios', 'Admin::Relatorios',
['filter' => ['auth', 'admin']
);
// Filter with parameters:
$routes->get('api/data', 'Api::data',
['filter' => 'throttle:60']
);
// Global filters (Config/Filters.php):
public array $globals = [
'before' => ['csrf', 'honeypot'],
'after' => ['toolbar', 'secureheaders'],
];
// Filter by URI pattern:
public array $filters = [
'auth' => ['before' => ['admin/*']],
];Filters are the CI4 middleware — they run before (before) and/or after (after) the controller. They can be applied per route, group, URI pattern or globally. csrf and honeypot are built-in. Custom filters implement FilterInterface. They can receive parameters (throttle:60). Ideal for authentication, logging and rate limiting.
Route constraints
// Restrict by hostname:
$routes->get('docs', 'Docs::index',
['hostname' => 'docs.meusite.com']
);
// Restrict by subdomain:
$routes->group('', ['hostname' => 'api.*'],
function ($routes) {
$routes->get('users', 'Api\Users::index');
}
);
// Custom regex in the placeholder:
$routes->get('users/([a-z-]+)', 'Users::ver/$1');
// Route priority:
$routes->setPrioritize();
// More specific routes first
// Check the current route:
$route = service('router')->getRouteName();
$controller = service('router')->controllerName();
$method = service('router')->methodName();Routes can be restricted by hostname (subdomains, multi-site). Custom regex in placeholders allows complex patterns. setPrioritize() enables priority by specificity. service('router') provides the current route info (useful in layouts for active menus). Routes are evaluated in order — define specific ones before generic ones.
Groups and namespaces
// Group with prefix:
$routes->group('admin',
['namespace' => 'App\Controllers\Admin'],
function ($routes) {
$routes->get('dashboard', 'Dashboard::index');
$routes->get('users', 'Users::index');
$routes->get('settings', 'Settings::index');
}
);
// URLs: /admin/dashboard, /admin/users
// Group with filter (middleware):
$routes->group('api',
['filter' => 'auth:api'],
function ($routes) {
$routes->get('profile', 'Api\Profile::show');
}
);
// Sub-groups:
$routes->group('admin', function ($routes) {
$routes->group('users', function ($routes) {
$routes->get('/', 'Admin\Users::index');
});
});$routes->group() groups routes with a shared URI prefix, namespace and filters. It avoids repeating prefixes and applies middleware to multiple routes. Sub-groups allow hierarchy (/admin/users). The group's namespace avoids qualifying each controller. Filters on the group apply to all inner routes.
Auto-routing
// app/Config/Routes.php // Auto-routing (CI4.5+): $routes->setAutoRoute(true); // URL: /products/list // -> App\Controllers\Products::list() // URL: /admin/users/index // -> App\Controllers\Admin\Users::index() // Disable (recommended in production): $routes->setAutoRoute(false); // Auto-routing with namespace translation: $routes->setAutoRoute(true); $routes->setTranslateURIDashes(true); // /my-products -> MeusProdutos controller // Priority: defined routes > auto-route
Auto-routing maps URIs directly to controllers/methods without an explicit definition. Convention: /controller/method/params. setTranslateURIDashes converts dashes to CamelCase. In production, it is recommended to disable it (setAutoRoute(false)) and define routes explicitly — safer and documented. Defined routes take priority over auto-routing.
Models and BD
Create Model
<?php
namespace App\Models;
use CodeIgniter\Model;
class ProdutoModel extends Model
{
protected $table = 'products';
protected $primaryKey = 'id';
protected $allowedFields = ['name', 'price', 'category'];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $returnType = 'array';
protected $useSoftDeletes = false;
}
// Create via CLI:
// php spark make:model ProductThe CI4 Model is configured via properties: $table, $primaryKey, $allowedFields (safe mass assignment). $useTimestamps manages created_at/updated_at automatically. $returnType defines whether it returns an array or an object. php spark make:model generates the file automatically.
Pagination
// In the controller:
public function index()
{
$model = new ProdutoModel();
$data['products'] = $model
->orderBy('name', 'ASC')
->paginate(10);
$data['pager'] = $model->pager;
return view('products/list', $data);
}
// In the view:
<?php foreach ($products as $p): ?>
<p><?= esc($p['name']) ?></p>
<?php endforeach; ?>
<?= $pager->links() ?>
// Links with a group:
<?= $pager->links('grupo1') ?>
// Simple pagination (previous/next):
<?= $pager->simpleLinks() ?>paginate(10) returns 10 records and configures the pager automatically. $model->pager provides the pagination object. $pager->links() renders HTML links (numbers, previous, next). It reads the page from the query string (?page=2). It supports multiple pagination groups on the same page. Integrates with the Query Builder.
Relations between Models
// CI4 has no Eloquent-style relations
// Use joins or queries in the Model:
class PedidoModel extends Model
{
protected $table = 'orders';
public function comItens(int $pedidoId): array
{
return $this->db->table('order_items')
->where('order_id', $pedidoId)
->get()
->getResultArray();
}
public function comCliente()
{
return $this->select('orders.*, clients.name')
->join('clients',
'clients.id = orders.client_id')
->findAll();
}
}
// Alternative: Entity with methods:
// $order->items() returns the order's itemsCI4 has no Eloquent-style relations (hasMany, belongsTo). Relations are implemented with join() or custom methods in the Model that query the related table. Entities can have methods that load related data. It is more explicit and performant — no surprise lazy loading. For complex relations, consider packages like codeigniter4-relations.
CRUD with Model
$model = new ProdutoModel();
// Create:
$model->insert([
'name' => 'TV Samsung',
'price' => 500,
'category' => 'electronic'
]);
$id = $model->getInsertID();
// Read:
$product = $model->find($id);
$all = $model->findAll();
$first = $model->findAll(10); // limit
// Update:
$model->update($id, ['price' => 450]);
// Delete:
$model->delete($id);
// Check for errors:
if (!$model->insert($data)) {
$errors = $model->errors();
}CRUD operations are direct methods: insert(), find(), findAll(), update(), delete(). getInsertID() returns the generated ID. errors() shows validation errors on failure. $allowedFields protects against mass assignment — only listed fields are inserted/updated.
Direct query
$db = \Config\Database::connect();
// Query with binding (safe):
$query = $db->query(
"SELECT * FROM products WHERE price > ? AND category = ?",
[100, 'electronic']
);
// Results:
$results = $query->getResultArray(); // array
$objects = $query->getResult(); // stdClass
// One row:
$line = $query->getRowArray();
// Named bindings:
$query = $db->query(
"SELECT * FROM users WHERE email = :email:",
['email' => $email]
);
// Query Builder without a Model:
$builder = $db->table('products');
$builder->where('active', 1)->get()->getResultArray();Database::connect() gets the connection. query() runs raw SQL with binding (? or :name:) — it prevents SQL injection. getResultArray() returns arrays, getResult() objects. For complex queries without a Model, use $db->table() the a builder. Prefer the Query Builder whenever possible.
Transactions
$db = \Config\Database::connect();
// Manual transaction:
$db->transStart();
$db->table('orders')->insert($order);
$db->table('order_items')->insertBatch($items);
$db->table('products')
->where('id', $produtoId)
->set('stock', 'stock - 1', false)
->update();
$db->transComplete();
if ($db->transStatus() === false) {
// Automatic rollback
log_message('error', 'Transaction failure');
}
// Transaction with try/catch:
try {
$db->transBegin();
// operations...
$db->transCommit();
} catch (\Exception $e) {
$db->transRollback();
throw $e;
}Transactions guarantee atomicity — all operations succeed or none do. transStart()/transComplete() is the simple mode (automatic rollback on failure). transBegin()/transCommit()/transRollback() gives manual control. Essential for multi-table operations (order + items + stock). set('stock', 'stock - 1', false) avoids escaping.
Query Builder
$model = new ProdutoModel();
// Chained conditions:
$results = $model
->where('price >', 100)
->where('category', 'electronic')
->where('active', 1)
->orderBy('name', 'ASC')
->limit(10)
->findAll();
// Like:
$model->like('name', 'tv')
->orLike('description', 'television')
->findAll();
// Count:
$total = $model->where('active', 1)
->countAllResults();
// whereIn:
$model->whereIn('id', [1, 2, 3])->findAll();
// whereNotIn:
$model->whereNotIn('category', ['removidos'])->findAll();The Query Builder allows chained queries without raw SQL — it protects against SQL injection automatically. where(), like(), orderBy(), limit() compose the query. countAllResults() counts without returning data. whereIn()/whereNotIn() for value lists. Methods are chainable and readable.
Soft deletes
// In the Model:
class ProdutoModel extends Model
{
protected $useSoftDeletes = true;
protected $deletedField = 'deleted_at';
}
// Delete (soft - marks deleted_at):
$model->delete($id);
// Include deleted:
$model->withDeleted()->findAll();
// Only deleted:
$model->onlyDeleted()->findAll();
// Delete permanently:
$model->delete($id, true);
// Restore:
$model->update($id, ['deleted_at' => null]);
// In the migration (required field):
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
]Soft deletes ($useSoftDeletes = true) mark records with deleted_at instead of removing them. delete() does a soft delete; delete($id, true) removes permanently. withDeleted() includes deleted rows in queries. onlyDeleted() shows only deleted rows. Essential for auditing and data recovery. Requires a deleted_at field on the table.
Batch operations
$model = new ProdutoModel();
// Multiple insert:
$data = [
['name' => 'TV', 'price' => 500],
['name' => 'Radio', 'price' => 80],
['name' => 'PC', 'price' => 1200],
];
$model->insertBatch($data);
// Multiple update:
$model->updateBatch($data, 'name');
// Updates where 'name' matches
// Multiple delete:
$model->whereIn('id', [1, 2, 3])->delete();
// Chunk (process in batches):
$model->chunk(100, function ($row) {
// Process each row
log_message('debug', $row['name']);
});
// Count total:
$total = $model->countAll();insertBatch() inserts multiple rows in one query (much faster than a loop of insert()). updateBatch() updates by a matching key. chunk() processes records in batches without loading everything into memory — essential for thousands of records. countAll() counts without returning data. Batch operations are O(1) queries vs O(n).
Select and Join
// Specific select:
$model->select('id, name, price')
->findAll();
// Select with alias:
$model->select('products.*, categories.name the cat')
->join('categories',
'categories.id = products.category_id')
->findAll();
// Join with type:
$model->select('p.*, c.name the client')
->join('clients c', 'c.id = p.client_id', 'left')
->findAll();
// Multiple joins:
$model->select('p.*, c.name the cat, f.name the forn')
->join('categories c', 'c.id = p.category_id')
->join('suppliers f', 'f.id = p.supplier_id')
->findAll();
// groupBy / having:
$model->select('category, COUNT(*) the total')
->groupBy('category')
->having('total >', 5)
->findAll();select() limits the returned columns (avoid SELECT * in production). join() accepts table, condition and type (inner, left, right). Multiple joins chain together. groupBy() and having() for aggregations. Use an alias (the cat) to avoid name conflicts between tables.
Callbacks and events
class ProdutoModel extends Model
{
protected $beforeInsert = ['hashSlug'];
protected $afterFind = ['formatarPreco'];
protected $beforeUpdate = ['updateSlug'];
protected function hashSlug(array $data)
{
$data['data']['slug'] = url_title(
$data['data']['name'], '-', true
);
return $data;
}
protected function formatarPreco(array $data)
{
if (isset($data['data']['price'])) {
$data['data']['price_fmt'] =
number_format($data['data']['price'], 2);
}
return $data;
}
}
// Available callbacks:
// beforeInsert, afterInsert
// beforeUpdate, afterUpdate
// beforeFind, afterFind
// beforeDelete, afterDeleteCallbacks run automatically before/after Model operations. $beforeInsert modifies data before inserting (e.g. generate a slug). $afterFind formats results (e.g. format the price). They receive and return $data (an array with the 'data' key). Ideal for cross-cutting logic without cluttering controllers.
Entities
// app/Entities/Product.php
namespace App\Entities;
use CodeIgniter\Entity\Entity;
class Product extends Entity
{
protected $casts = [
'price' => 'float',
'active' => 'boolean',
'tags' => 'json-array',
];
// Mutator (setter):
public function setName(string $val): self
{
$this->attributes['name'] = ucfirst($val);
$this->attributes['slug'] = url_title($val, '-', true);
return $this;
}
// Accessor (getter):
public function getPrecoFormatado(): string
{
return number_format($this->price, 2) . ' €';
}
}
// In the Model:
protected $returnType = \App\Entities\Product::class;
// Usage:
$product = $model->find(1);
echo $product->price_formatado;Entities are rich objects representing DB records. $casts converts types automatically (float, boolean, json-array). Mutators (setField) transform on set; accessors (getField) on read. $returnType in the Model makes find() return an Entity instead of an array. Ideal for domain logic and formatting.
Views e Layouts
Simple view
// Controller:
public function index()
{
$data = [
'title' => 'My Products',
'products' => $model->findAll(),
];
return view('products/list', $data);
}
// app/Views/products/list.php:
<h1><?= esc($title) ?></h1>
<?php if (empty($products)): ?>
<p>Sem products.</p>
<?php else: ?>
<?php foreach ($products as $p): ?>
<div>
<h3><?= esc($p['name']) ?></h3>
<p><?= esc($p['price']) ?> €</p>
</div>
<?php endforeach; ?>
<?php endif; ?>view('folder/file', $data) renders a template with data extracted the variables. Always use esc() on output to prevent XSS. Templates are plain PHP with alternative syntax (foreach:/endforeach;). Views live in app/Views/ organized into subdirectories. Do not put business logic in views.
esc() and security
// ALWAYS escape output: <?= esc($name) ?> <?= esc($html, 'html') ?> <?= esc($url, 'url') ?> <?= esc($js, 'js') ?> <?= esc($attr, 'attr') ?> // Contexts: // 'html' - HTML content (default) // 'url' - URLs // 'js' - JavaScript // 'attr' - HTML attributes // 'css' - CSS // CSRF in forms: <?= csrf_field() ?> // Generates: <input type="hidden" name="csrf_token" ...> // Meta tag (for AJAX): <meta name="csrf-token" content="<?= csrf_hash() ?>"> // NEVER: // <?= $input_do_user ?> ← XSS!
esc() is the defense against XSS — it escapes output according to the context (html, url, js, attr, css). ALWAYS use it on user data. csrf_field() generates a CSRF token in forms. csrf_hash() for meta tags (AJAX). Without esc(), the application is vulnerable to script injection. Rule: every dynamic output goes through esc().
Output filters
// app/Config/Filters.php
// Output filter (after):
public array $globals = [
'after' => ['minify'],
];
// app/Filters/MinifyFilter.php
class MinifyFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null) {}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
$body = $response->getBody();
$body = preg_replace('/\s+/', ' ', $body);
$response->setBody($body);
return $response;
}
}
// Security filter (headers):
$response->setHeader('X-Content-Type-Options', 'nosniff');
$response->setHeader('X-Frame-Options', 'DENY');after filters process the response before sending it to the browser — ideal for minifying HTML, adding security headers, compressing output. FilterInterface has before() and after(). Global filters apply to all routes. They can modify the body, headers and status code. Useful for CSP, compression and response logging.
Layouts and sections
// Layout: app/Views/layouts/main.php
<!DOCTYPE html>
<html>
<head>
<title><?= $this->renderSection('title') ?></title>
</head>
<body>
<?= $this->include('partials/nav') ?>
<main>
<?= $this->renderSection('content') ?>
</main>
<?= $this->renderSection('scripts') ?>
</body>
</html>
// Page:
<?= $this->extend('layouts/main') ?>
<?= $this->section('title') ?>Products<?= $this->endSection() ?>
<?= $this->section('content') ?>
<h1>List de Products</h1>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script src="/js/products.js"></script>
<?= $this->endSection() ?>Template inheritance with extend() and section()/endSection(). The layout uses renderSection() the placeholders. Each page defines the content of each section. It supports multiple sections (title, content, scripts). It eliminates HTML repetition (header, footer, nav). Similar to Laravel's Blade but with plain PHP syntax.
old() and validation in the view
// Repopulate the form after an error:
<input type="text" name="name"
value="<?= old('name') ?>">
<textarea name="description">
<?= old('description') ?>
</textarea>
<select name="category">
<option value="1"
<?= old('category') == '1' ? 'selected' : '' ?>>
Electronic
</option>
</select>
// Show validation errors:
<?php if (session('error_name')): ?>
<span class="error"><?= session('error_name') ?></span>
<?php endif; ?>
// All errors:
<?php if (session('errors')): ?>
<ul>
<?php foreach (session('errors') as $e): ?>
<li><?= esc($e) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>old('field') returns the previously submitted value (flash) — it repopulates forms after a validation error. Errors are passed via session flash. session('error_field') shows an individual error. Combined with redirect()->back()->withInput() in the controller. Essential for UX — the user doesn't lose data on submit.
Data sharing between views
// Share data with all views:
// In the BaseController:
public function initController($request, $response, $logger)
{
parent::initController($request, $response, $logger);
// Available in all views:
$this->data['user'] = session('user');
$this->data['app_name'] = 'My App';
}
// Global renderer:
$view = \Config\Services::renderer();
$view->setData([
'site_name' => 'My Store',
'year' => date('Y'),
]);
// In the view:
<footer>
<?= esc($site_name) ?> © <?= $year ?>
</footer>
// View composer (data per view):
$view->setData(['menu' => $this->getMenu()], 'layout');Shared data avoids passing the same variables to each view. setData() on the renderer makes data global. In the BaseController, set $this->data with user/session info. View composers allow data specific to each template. It reduces repetition and centralizes common data (user, menu, settings).
Partials (include)
// Include a fragment:
<?= $this->include('partials/header') ?>
<?= $this->include('partials/nav') ?>
<main>
<?= $content ?>
</main>
<?= $this->include('partials/footer') ?>
// Include with data:
<?= $this->include('partials/card', ['product' => $p]) ?>
// Include with options:
<?= $this->include('partials/alert', [
'type' => 'success',
'msg' => 'Saved!'
], ['saveData' => false]) ?>
// partials/alert.php:
<div class="alert alert-<?= $type ?>">
<?= esc($msg) ?>
</div>$this->include() inserts reusable view fragments (header, footer, cards, alerts). It accepts data the the second argument. The third argument is options (saveData, cache). Partials live in app/Views/partials/. Combined with layouts, they eliminate all HTML repetition. Simpler than JS frameworks' components.
View cells
// app/Cells/MenuCell.php
namespace App\Cells;
use CodeIgniter\View\Cells\Cell;
class MenuCell extends Cell
{
protected array $links;
public function mount(): void
{
$this->links = [
['url' => '/', 'label' => 'Start'],
['url' => '/products', 'label' => 'Products'],
];
}
}
// View: app/Cells/menu_view.php
<nav>
<?php foreach ($links as $link): ?>
<a href="<?= base_url($link['url']) ?>">
<?= esc($link['label']) ?>
</a>
<?php endforeach; ?>
</nav>
// Usage in any view:
<?= view_cell('App\Cells\MenuCell') ?>View Cells are mini-controllers for views — they encapsulate the logic and data of reusable components. mount() loads data (like a controller). The cell's view is rendered in isolation. view_cell() invokes it in any template. Ideal for menus, sidebars, widgets that need DB data. They replace include + manual query.
Assets and URLs
// Recommended structure:
// public/assets/css/style.css
// public/assets/js/app.js
// public/assets/img/logo.png
// In the view:
<link rel="stylesheet"
href="<?= base_url('assets/css/style.css') ?>">
<script
src="<?= base_url('assets/js/app.js') ?>"></script>
<img src="<?= base_url('assets/img/logo.png') ?>"
alt="Logo">
// With versioning (cache busting):
<link rel="stylesheet"
href="<?= base_url('assets/css/style.css?v=' . filemtime(PUBLICPATH . 'assets/css/style.css')) ?>">
// Favicon:
<link rel="icon"
href="<?= base_url('favicon.ico') ?>">
// NEVER use relative paths:
// <link href="/css/style.css"> ← breaks in subdirsAssets live in public/assets/ (accessible by the browser). Always use base_url() to generate URLs — it works in subdirectories and different domains. Cache busting with filemtime() or a hash in the file name. Never relative paths (/css/) — they break if the app is not at the root. Organize by type (css, js, img).
URL and Form helpers
// Load helpers:
helper('url');
helper(['form', 'html']);
// URL helpers:
base_url() // http://localhost:8080/
base_url('products') // .../products
site_url('admin/panel') // with index.php if configured
current_url() // current URL
previous_url() // previous URL
uri_string() // URI segment
// Form helpers:
echo form_open('products/save');
echo form_input('name', old('name'));
echo form_textarea('description');
echo form_dropdown('cat', $options, $selected);
echo form_submit('', 'Save');
echo form_close();
// anchor:
echo anchor('products/1', 'Ver', ['class' => 'btn']);Helpers are utility functions loaded with helper(). base_url() generates absolute URLs. form_open() creates a <form> with an automatic CSRF token. form_input(), form_dropdown() generate HTML fields. anchor() creates links with attributes. Form helpers integrate with validation via old().
View caching
// View cache (in seconds):
return view('products/list', $data, ['cache' => 300]);
// Cache with a custom key:
return view('page', $data, [
'cache' => 600,
'cache_name' => 'page_home'
]);
// Invalidate cache:
$cache = \Config\Services::cache();
$cache->delete('page_home');
// Full-page cache (in the controller):
public function index()
{
// Cache for 5 minutes:
$this->cachePage(300);
return view('home');
}
// Configure the cache driver:
// app/Config/Cache.php
public string $handler = 'file';
// Options: file, memcached, redis, predisViews can be cached with the ['cache' => seconds] option — it avoids re-rendering. cachePage() caches the entire response (ideal for static pages). cache_name allows manual invalidation. Drivers: file (default), memcached, redis. View cache is per template+data — data changes invalidate it automatically.
Validation and Forms
Rules in the Controller
public function store()
{
$rules = [
'name' => 'required|min_length[3]|max_length[100]',
'email' => 'required|valid_email|is_unique[users.email]',
'price' => 'required|numeric|greater_than[0]',
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
// Valid data:
$data = $this->request->getPost();
$model->insert($data);
return redirect()->to('/products')
->with('success', 'Created!');
}$this->validate() checks the input against pipe-separated rules. On failure, getErrors() returns messages. redirect()->back()->withInput() preserves the form data. Rules are strings with | the the separator. is_unique checks uniqueness in the DB. Always validate before inserting/updating.
File upload
$file = $this->request->getFile('image');
if ($file->isValid() && !$file->hasMoved()) {
// Random name (safe):
$name = $file->getRandomName();
// Move to a folder:
$file->move(WRITEPATH . '../public/uploads', $name);
// Information:
$original = $file->getClientName();
$ext = $file->getExtension();
$size = $file->getSize(); // bytes
$mime = $file->getMimeType();
}
// Upload validation:
$rules = [
'image' => [
'label' => 'Image',
'rules' => 'uploaded[image]|max_size[image,2048]|is_image[image]|mime_in[image,image/jpg,image/png]',
],
];getFile() gets the uploaded file. isValid() checks upload success. getRandomName() generates a safe name (avoids path traversal). move() transfers it to the destination. Validation with uploaded, max_size (KB), is_image, mime_in. Always validate type and size. Never use the original name directly.
Input sanitization
// Native PHP filters:
$email = filter_var($input, FILTER_SANITIZE_EMAIL);
$url = filter_var($input, FILTER_SANITIZE_URL);
$int = filter_var($input, FILTER_SANITIZE_NUMBER_INT);
// In the CI4 request:
$email = $this->request->getPost('email', FILTER_SANITIZE_EMAIL);
// Clean HTML:
$clean = strip_tags($input);
$safe = htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
// Trim and normalize:
$name = trim($this->request->getPost('name'));
$name = preg_replace('/\s+/', ' ', $name);
// Escape for context:
esc($dado, 'html'); // HTML output
esc($dado, 'url'); // URLs
esc($dado, 'js'); // JavaScript
esc($dado, 'attr'); // attributes
// Rule: validate > sanitize > escapeSanitization cleans input before processing: FILTER_SANITIZE_EMAIL removes invalid characters, strip_tags() removes HTML, trim() removes spaces. esc() escapes on output (not on input). Strategy: validate (reject invalid), sanitize (clean), escape on output. Never trust sanitization the a substitute for validation.
Rules in the Model
class ProdutoModel extends Model
{
protected $validationRules = [
'name' => 'required|min_length[3]|max_length[200]',
'price' => 'required|numeric|greater_than[0]',
'email' => 'permit_empty|valid_email',
];
protected $validationMessages = [
'name' => [
'required' => 'The name field is required',
'min_length' => 'Minimum 3 characters',
],
'price' => [
'required' => 'Indique o price',
'numeric' => 'Deve be numeric',
],
];
}
// Automatic validation on insert/update:
if (!$model->insert($data)) {
$errors = $model->errors();
}Rules in the Model ($validationRules) validate automatically on insert()/update(). $validationMessages customizes messages per field/rule. If validation fails, the operation is aborted and errors() returns the errors. It centralizes validation in the Model — controllers stay cleaner. permit_empty allows empty but validates if filled.
Custom messages
// Messages per field and rule:
$rules = [
'name' => [
'label' => 'Name do Product',
'rules' => 'required|min_length[3]',
'errors' => [
'required' => '{field} is required',
'min_length' => '{field} must have at least {param} characters',
],
],
'email' => [
'label' => 'Email',
'rules' => 'required|valid_email|is_unique[users.email]',
'errors' => [
'is_unique' => 'Este {field} already is registado',
],
],
];
if (!$this->validate($rules)) {
$errors = $this->validator->getErrors();
}
// Placeholders: {field}, {param}, {value}Custom messages use an array with label, rules and errors. Placeholders: {field} (field name), {param} (rule parameter), {value} (submitted value). label replaces the technical name with readable text. Essential for UX — clear messages help the user fix errors.
Conditional validation
// Conditional rules (if/else in the controller):
$rules = ['type' => 'required|in_list[fisica,juridica]'];
if ($this->request->getPost('type') === 'fisica') {
$rules['cpf'] = 'required|exact_length[11]|numeric';
} else {
$rules['cnpj'] = 'required|exact_length[14]|numeric';
}
// required_if (CI 4.4+):
$rules = [
'country' => 'required',
'state' => 'required_if[country,Brasil]',
'nif' => 'required_if[type,juridica]',
];
// required_with / required_without:
$rules = [
'phone' => 'required_with[email]',
'fax' => 'required_without[phone]',
];
// Allow optional fields:
$rules = [
'website' => 'permit_empty|valid_url',
];Conditional validation applies different rules depending on the input. required_if[field,value] requires a field if another has a specific value. required_with/required_without depend on the presence of other fields. permit_empty allows empty but validates if filled. For complex logic, build rules dynamically in the controller with if/else.
Available rules
// Presence: required, permit_empty // Length: min_length[n], max_length[n], exact_length[n] // Type: alpha, alpha_numeric, alpha_dash, alpha_numeric_space numeric, integer, decimal // Format: valid_email, valid_url, valid_ip, valid_json valid_date, valid_cc_num // Comparison: matches[field], differs[field] greater_than[n], less_than[n] greater_than_equal_to[n], less_than_equal_to[n] // DB: is_unique[table.field] is_not_unique[table.field] // Others: in_list[a,b,c], regex_match[/pattern/] uploaded[field], max_size[field,n]
Rules cover presence (required), length (min_length), type (numeric, valid_email), comparison (matches, greater_than) and DB (is_unique). in_list validates against a list of values. regex_match for custom patterns. uploaded/max_size for files. Rules chain with |.
Custom rules
// app/Validation/CustomRules.php
namespace App\Validation;
class CustomRules
{
public function greater_than_zero(
?string $value, string &$error = null
): bool {
if ((float) $value <= 0) {
$error = 'The value must be positive';
return false;
}
return true;
}
public function without_badwords(
?string $value, string &$error = null
): bool {
$banned = ['spam', 'scam'];
foreach ($banned as $p) {
if (str_contains(strtolower($value), $p)) {
$error = "Contains forbidden word: $p";
return false;
}
}
return true;
}
}
// Register in Config/Validation.php:
public array $ruleSets = [
\App\Validation\CustomRules::class,
];
// Usage: 'price' => 'required|greater_than_zero'Custom rules are methods in classes registered in Config/Validation.php. They receive the value and a reference to $error (message). They return true/false. The method name is the rule name. They allow validation of specific business logic. Register in the $ruleSets array. Use like any built-in rule.
Receiving input
// POST:
$name = $this->request->getPost('name');
$all = $this->request->getPost();
// GET / query string:
$q = $this->request->getGet('q');
$page = $this->request->getGet('page');
// Any method (POST or GET):
$value = $this->request->getVar('field');
// JSON body:
$data = $this->request->getJSON(true); // assoc
// With a PHP filter:
$email = $this->request->getPost('email', FILTER_SANITIZE_EMAIL);
$age = $this->request->getPost('age', FILTER_VALIDATE_INT);
// Multiple values (checkboxes):
$colors = $this->request->getPost('colors'); // array
// Check existence:
if ($this->request->getPost('name') !== null) { }getPost() accesses POST data, getGet() the query string, getVar() any method. getJSON(true) parses the JSON body the an associative array. PHP filters (FILTER_SANITIZE_EMAIL) sanitize input. Never trust user input — always validate and escape. getPost() without an argument returns all data.
Array validation
// Validate array fields (dynamic forms):
$rules = [
'items.*.name' => 'required|min_length[2]',
'items.*.quantity' => 'required|integer|greater_than[0]',
'items.*.price' => 'required|numeric',
];
// HTML:
// <input name="items[0][name]">
// <input name="items[0][quantity]">
// <input name="items[1][name]">
// Validate a simple array:
$rules = [
'colors' => 'required',
'colors.*' => 'alpha|max_length[20]',
];
// Messages for arrays:
$messages = [
'items.*.name' => [
'required' => 'Name do item is required',
],
];
// Errors include the index:
// "items.0.name" => "Name do item is required"The * wildcard validates each element of arrays — ideal for dynamic forms (multiple items). items.*.name applies the rule to all elements. Errors include the index (items.0.name). It works with simple arrays (colors.*) and nested ones. Essential for forms with repeated rows (invoices, carts).
Security and Sessions
CSRF Protection
// Enable globally (Config/Filters.php):
public array $globals = [
'before' => ['csrf'],
];
// Configure (Config/Security.php):
public string $tokenName = 'csrf_token';
public string $headerName = 'X-CSRF-TOKEN';
public int $expires = 7200;
public bool $regenerate = true;
// In the form:
<?= csrf_field() ?>
// In AJAX (meta tag in the layout):
<meta name="csrf-token" content="<?= csrf_hash() ?>">
<meta name="csrf-header" content="X-CSRF-TOKEN">
// jQuery:
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});CSRF protection is enabled globally via the before filter. csrf_field() generates a hidden token in forms. For AJAX, use a meta tag + the X-CSRF-TOKEN header. regenerate creates a new token on each request (more secure). expires sets the validity in seconds. Without CSRF, forms are vulnerable to cross-site request forgery.
Simple authentication
// Login controller:
public function login()
{
$email = $this->request->getPost('email');
$pass = $this->request->getPost('password');
$model = new UserModel();
$user = $model->where('email', $email)->first();
if ($user && password_verify($pass, $user['password'])) {
session()->set([
'user_id' => $user['id'],
'name' => $user['name'],
'logged_in' => true,
]);
return redirect()->to('/dashboard');
}
return redirect()->back()
->with('error', 'Credentials invalid');
}
// Logout:
public function logout()
{
session()->destroy();
return redirect()->to('/login');
}
// Check in a filter:
if (!session('logged_in')) {
return redirect()->to('/login');
}Basic authentication: check credentials with password_verify(), store the state in the session. session()->destroy() on logout. The auth filter protects routes. Never store the password in the session. For production, consider packages like codeigniter4/shield (CI4's official authentication) with remember-me, throttle and email verification.
Protecting sensitive data
// Never expose in logs:
log_message('error', 'Login failed for: ' . $email);
// NEVER: log_message('debug', 'Password: ' . $pass);
// .env outside git:
// .gitignore:
.env
*.pem
*.key
// Do not expose in errors:
// production: generic message
// development: stack trace (local only)
// Security headers:
$response->removeHeader('X-Powered-By');
$response->removeHeader('Server');
// Validate access to resources:
public function download($id)
{
$file = $model->find($id);
// Check the owner:
if ($file['user_id'] !== session('user_id')) {
throw PageNotFoundException::forPageNotFound();
}
return $this->response->download($path, null);
}Never log passwords, tokens or sensitive data. Keep .env and keys outside git. In production, errors are generic (no stack trace). Remove headers that reveal technology (X-Powered-By). Validate authorization on every resource access (IDOR — Insecure Direct Object Reference). Always check whether the user owns the resource before returning data.
Sessions
$session = session();
// Set:
$session->set('user_id', 42);
$session->set(['name' => 'Anna', 'role' => 'admin']);
// Read:
$id = $session->get('user_id');
$name = session('name'); // helper
// Check:
if ($session->has('user_id')) { }
// Remove:
$session->remove('temp');
// Destroy everything:
$session->destroy();
// Flash (one read):
$session->setFlashdata('msg', 'Success!');
$msg = $session->getFlashdata('msg');
// Tempdata (expires in N seconds):
$session->setTempdata('code', $code, 300);CI4 sessions are accessed via session() or $this->session. set()/get() for persistent data. setFlashdata() for one-read messages (redirect). setTempdata() expires automatically. Configuration in Config/App.php (driver, cookie, expiration). Default driver: file. Alternatives: database, redis, memcached.
Honeypot and anti-spam
// Enable honeypot (Config/Filters.php):
public array $globals = [
'before' => ['honeypot'],
'after' => ['honeypot'],
];
// Configure (Config/Honeypot.php):
public bool $hidden = true;
public string $label = 'Preencha this field';
public string $name = 'website';
public string $template = '<label>{label}</label><input type="text" name="{name}" value="">';
// How it works:
// 1. Invisible field added to the form
// 2. Bots fill it (they don't see CSS)
// 3. If filled -> request rejected
// Manual rate limiting:
$throttle = \Config\Services::throttler();
if ($throttle->check('login', 5, MINUTE) === false) {
return redirect()->back()
->with('error', 'Tentativas too long. Aguarde.');
}Honeypot adds an invisible field to the form — bots fill it (they don't see CSS), humans don't. If filled, the request is rejected silently. Zero UX impact. Throttler limits attempts per IP/action (rate limiting). Combine honeypot + CSRF + throttle for robust protection. A frictionless alternative to CAPTCHA for users.
Cookie security
// Configure the session (Config/App.php):
public string $sessionCookieName = 'ci_session';
public int $sessionExpiration = 7200;
public string $sessionSavePath = WRITEPATH . 'session';
public bool $sessionMatchIP = false;
public string $sessionTimeToUpdate = 300;
public bool $sessionRegenerateDestroy = true;
// Secure cookie (Config/Cookie.php):
public string $prefix = '';
public int $expires = 0;
public string $path = '/';
public string $domain = '';
public bool $secure = true; // HTTPS only
public bool $httponly = true; // no JS access
public string $samesite = 'Lax'; // anti-CSRF
// Regenerate the session (after login):
session()->regenerate(true);
// Set a cookie manually:
$this->response->setCookie('preference', $value, [
'httponly' => true,
'secure' => true,
]);Session cookies should be secure (HTTPS only), httponly (inaccessible via JavaScript) and samesite = Lax (anti-CSRF). sessionRegenerateDestroy destroys the old session on regeneration. Regenerating the session after login prevents session fixation. sessionMatchIP adds security but may break on mobile networks. A 2h expiration is reasonable.
Encryption and hashing
// Symmetric encryption:
$encrypter = \Config\Services::encrypter();
$cifrada = $encrypter->encrypt('data secretos');
$original = $encrypter->decrypt($cifrada);
// Configure the key (Config/Encryption.php):
public string $key = 'your-32-byte-key-here!';
public string $driver = 'OpenSSL';
// Password hash (NEVER encrypt):
$hash = password_hash($password, PASSWORD_DEFAULT);
// Verify the password:
if (password_verify($input, $hash)) {
// correct password
}
// Check if a rehash is needed:
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$novoHash = password_hash($password, PASSWORD_DEFAULT);
}
// HMAC:
$signature = hash_hmac('sha256', $data, $key);Encryption (encrypter) is reversible — for data you need to read back later. Hashing (password_hash) is irreversible — for passwords. Never encrypt passwords. password_verify() compares the hash without exposing the password. password_needs_rehash() updates the hash when the somethingrithm changes. The encryption key should be in the .env, never in the code.
Content Security Policy
// Enable (Config/App.php):
public bool $CSPEnabled = true;
// Configure (Config/ContentSecurityPolicy.php):
public $defaultSrc = 'self';
public $scriptSrc = ['self', 'cdn.jsdelivr.net'];
public $styleSrc = ['self', 'fonts.googleapis.com'];
public $imgSrc = ['self', 'data:', 'https:'];
public $fontSrc = ['fonts.gstatic.com'];
public $connectSrc = 'self';
public $objectSrc = 'none';
public $frameAncestors = 'none';
// Additional security headers:
// Config/Filters.php -> 'secureheaders'
public array $globals = [
'after' => ['secureheaders'],
];
// Headers added:
// X-Content-Type-Options: nosniff
// X-Frame-Options: SAMEORIGIN
// X-XSS-Protection: 1; mode=blockCSP controls where the browser can load resources from — it prevents XSS and script injection. defaultSrc = 'self' allows only resources from the same domain. Whitelists for CDNs and fonts. secureheaders adds security headers (nosniff, frame options). frameAncestors = 'none' prevents clickjacking. Essential for applications with sensitive data.
Filters (Middleware)
// app/Filters/AuthFilter.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 (!session()->get('logged_in')) {
return redirect()->to('/login')
->with('error', 'Do login first');
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// Post-processing (optional)
}
}
// Register (Config/Filters.php):
public array $aliases = [
'auth' => \App\Filters\AuthFilter::class,
];
// Apply:
$routes->get('admin', 'Admin::index', ['filter' => 'auth']);Filters implement FilterInterface with before() and after(). before() can return a redirect/response to abort. Register them in Config/Filters.php with an alias. Apply per route, group or globally. Ideal for authentication, authorization, logging, rate limiting. $arguments receives filter parameters (filter:auth[admin]).
Permission validation
// Roles filter:
class RoleFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$role = session('role');
if (!in_array($role, $arguments)) {
return redirect()->to('/dashboard')
->with('error', 'Sem permission');
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {}
}
// Register:
'role' => \App\Filters\RoleFilter::class,
// Apply with parameters:
$routes->group('admin', ['filter' => 'role:admin,superadmin'],
function ($routes) {
$routes->get('users', 'Admin\Users::index');
$routes->delete('users/(:num)', 'Admin\Users::delete/$1');
}
);The roles filter checks permissions via arguments (role:admin,superadmin). $arguments receives the list of allowed roles. Applying it per group protects all inner routes. Combine with the auth filter (login first, role after). For full RBAC, consider codeigniter4/shield with groups and permissions. Never rely solely on hiding links — validate on the server.
Advanced Features
Cache
$cache = \Config\Services::cache();
// Save (key, data, seconds):
$cache->save('products_populares', $data, 3600);
// Read:
$value = $cache->get('products_populares');
if ($value === null) {
$value = $model->getPopulares();
$cache->save('products_populares', $value, 3600);
}
// Delete:
$cache->delete('products_populares');
// Clear everything:
$cache->clean();
// Page cache (in the controller):
public function index()
{
$this->cachePage(300); // 5 minutes
// ...
}
// Configure the driver (Config/Cache.php):
public string $handler = 'file';
// Options: file, memcached, redis, predis, wincacheCache reduces repeated queries and response time. save() stores with a TTL (seconds). get() returns null if expired. cachePage() caches the entire response. Drivers: file (default, no config), redis/memcached (production, shared). Invalidate the cache when updating data. Pattern: check cache → on miss, query + save.
$email = \Config\Services::email();
// Configure (Config/Email.php):
// SMTP:
public string $protocol = 'smtp';
public string $SMTPHost = 'smtp.gmail.com';
public string $SMTPUser = 'user@gmail.com';
public string $SMTPPass = 'app-password';
public int $SMTPPort = 587;
public string $SMTPCrypto = 'tls';
// Send:
$email->setFrom('noreply@site.com', 'My App');
$email->setTo('user@mail.com');
$email->setCC('admin@site.com');
$email->setSubject('Confirmation de Registration');
$email->setMessage('<h1>Welcome!</h1>');
// HTML:
$email->setMailType('html');
// Attachment:
$email->attach('/path/to/file.pdf');
if ($email->send()) {
// sent
} else {
log_message('error', $email->printDebugger());
}The email service supports SMTP, sendmail and mail(). Configuration in Config/Email.php or the .env. setMailType('html') for HTML emails. attach() adds attachments. printDebugger() shows send errors. For Gmail, use an App Password (not the account password). In production, consider services like Mailgun/SendGrid via SMTP.
Time and dates
use CodeIgniter\I18n\Time;
// Now:
$now = Time::now();
$now = Time::now('Europe/Lisbon', 'pt_PT');
// Create:
$data = Time::create(2024, 12, 25, 10, 30);
$data = Time::parse('2024-12-25 10:30:00');
// Format:
echo $now->format('d/m/Y H:i'); // 25/12/2024 10:30
echo $now->toDateString(); // 2024-12-25
echo $now->humanize(); // "2 hours ago"
// Manipulate:
$tomorrow = $now->addDays(1);
$yesterday = $now->subMonths(2);
$start = $now->startOfMonth();
// Compare:
if ($now->isAfter($deadline)) { }
$difference = $now->difference($other);
echo $difference->getDays(); // days
// Timezone:
$lisboa = $now->setTimezone('Europe/Lisbon');Time is CI4's date class (wraps DateTime/Carbon-like). Time::now() with timezone and locale. format() formats, humanize() returns readable text ("2 hours ago"). Fluent methods: addDays(), subMonths(), startOfMonth(). difference() computes an interval. Always use an explicit timezone. Configure the default in Config/App.php.
Events and hooks
// app/Config/Events.php
use CodeIgniter\Events\Events;
// Listen to an event:
Events::on('DBQuery', function ($query) {
log_message('debug', (string) $query);
});
Events::on('pre_system', function () {
// Before any controller
});
// Trigger a custom event:
Events::trigger('order_created', $pedidoId, $total);
// Listen to a custom one:
Events::on('order_created', function ($id, $total) {
// Send confirmation email
// Update stock
// Notify admin
});
// Priority (lower = first):
Events::on('order_created', $fn1, 10);
Events::on('order_created', $fn2, 50);
// Remove a listener:
Events::removeListener('order_created', $fn1);Events allow you to decouple logic — trigger without knowing who listens. Events::on() registers a listener, Events::trigger() fires it. Priority controls execution order. Built-in events: pre_system, post_system, DBQuery. Ideal for notifications, logging, analytics without coupling to the controller. Similar to observers/pub-sub.
Services (DI)
// Built-in services:
$request = service('request');
$session = service('session');
$cache = service('cache');
$logger = service('logger');
$db = \Config\Database::connect();
// Custom service (Config/Services.php):
public static function payment(bool $getShared = true)
{
if ($getShared) {
return static::getSharedInstance('payment');
}
return new \App\Libraries\PagamentoService();
}
// Usage:
$payment = service('payment');
$payment->charge(49.99);
// Injection in controllers:
class Store extends BaseController
{
protected $payment;
public function initController($req, $res, $log)
{
parent::initController($req, $res, $log);
$this->payment = service('payment');
}
}Services are singletons managed by CI4's DI container. service('name') gets a shared instance. Custom services in Config/Services.php. $getShared controls singleton vs new instance. Ideal for libraries, payment gateways, external APIs. They ease testing (mocking services). All core services are accessible via service().
HTTP Client (CURLRequest)
$client = \Config\Services::curlrequest();
// GET:
$response = $client->get('https://api.example.com/data', [
'headers' => ['Authorization' => 'Bearer ' . $token],
'timeout' => 10,
]);
// POST JSON:
$response = $client->post('https://api.example.com/users', [
'json' => ['name' => 'Anna', 'email' => 'ana@mail.com'],
'headers' => ['Accept' => 'application/json'],
]);
// Response:
$statusCode = $response->getStatusCode();
$body = $response->getBody();
$data = json_decode($body, true);
// With authentication:
$response = $client->request('GET', $url, [
'auth' => ['user', 'pass'],
]);
// Error handling:
try {
$response = $client->get($url);
} catch (\CodeIgniter\HTTP\Exceptions\HTTPException $e) {
log_message('error', 'API failed: ' . $e->getMessage());
}CURLRequest is CI4's built-in HTTP client (CURL-based). It supports GET, POST, PUT, DELETE with headers, JSON, auth and timeout. getStatusCode() and getBody() to process the response. The json option serializes and sets the Content-Type automatically. Wrap in try/catch for network errors. Ideal for consuming external APIs.
Migrations
// Create a migration:
php spark make:migration CriarProdutos
// app/Database/Migrations/2024-01-01-000000_CriarProdutos.php
public function up()
{
$this->forge->addField([
'id' => ['type' => 'INT', 'auto_increment' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 200],
'price' => ['type' => 'DECIMAL', 'constraint' => '10,2'],
'active' => ['type' => 'TINYINT', 'default' => 1],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('name');
$this->forge->createTable('products');
}
public function down()
{
$this->forge->dropTable('products');
}
// Run:
php spark migrate
php spark migrate:rollbackMigrations version the DB structure — up() creates, down() reverts. forge is the schema builder: addField(), addKey(), createTable(). php spark migrate applies pending ones, migrate:rollback reverts. Essential for teamwork and consistent deployment. Never alter tables manually in production.
Logging
// Log levels:
log_message('emergency', 'Sistema em low');
log_message('alert', 'Action immediate needed');
log_message('critical', 'Error critical');
log_message('error', 'Error na operation');
log_message('warning', 'Algo inesperado');
log_message('notice', 'Event normal significativo');
log_message('info', 'Information general');
log_message('debug', 'Data de debugging');
// Configure (Config/Logger.php):
public $threshold = 4; // 1-9 (4 = error+)
// 9 = everything, 1 = emergency only
// Logs in: writable/logs/log-2024-01-15.log
// Log with context:
log_message('error', 'Failure no payment: {id}', [
'id' => $pedidoId,
]);
// Custom handler (email, Slack, etc):
// Config/Logger.php -> $handlerslog_message() logs events with a severity level (PSR-3). $threshold controls which levels are recorded (9 = everything, 4 = error+). Logs in writable/logs/ with the date in the name. Placeholders ({id}) with context. In production, threshold 4 (errors only). In development, 9 (everything). Custom handlers can send to email, Slack or external services.
Seeders
// Create a seeder:
php spark make:seeder ProdutosSeeder
// app/Database/Seeds/ProdutosSeeder.php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
class ProdutosSeeder extends Seeder
{
public function run()
{
$data = [
['name' => 'TV', 'price' => 500],
['name' => 'Radio', 'price' => 80],
['name' => 'PC', 'price' => 1200],
];
$this->db->table('products')->insertBatch($data);
// Call another seeder:
$this->call('CategoriasSeeder');
}
}
// Run:
php spark db:seed ProdutosSeeder
// With Faker:
$faker = \Faker\Factory::create();
$name = $faker->name;Seeders populate the DB with initial or test data. insertBatch() inserts multiple rows. $this->call() chains seeders. php spark db:seed runs it. Faker (via devkit) generates realistic data. Ideal for reference data (categories, countries) and development. Separate production and development seeders.
Localization (i18n)
// app/Language/pt/App.php
return [
'welcome' => 'Welcome, {0}!',
'items' => '{0, number} items no cart',
'error' => [
'notFound' => 'Page not found',
'noPermission' => 'Access denied',
],
];
// app/Language/en/App.php
return [
'welcome' => 'Welcome, {0}!',
];
// Usage:
echo lang('App.welcome', [$name]);
echo lang('App.error.notFound');
// Configure the locale:
// Config/App.php:
public string $defaultLocale = 'pt';
public bool $negotiateLocale = true;
public array $supportedLocales = ['pt', 'en', 'es'];
// Change at runtime:
service('request')->setLocale('en');Translations live in app/Language/{locale}/ the PHP arrays. lang('App.key') returns the translation. Placeholders ({0}) replace parameters. negotiateLocale detects the browser language. supportedLocales limits the available languages. Organize by file (App, Validation, Errors). Essential for multi-language applications.
API RESTful
RESTful Controller
<?php
namespace App\Controllers;
use CodeIgniter\RESTful\ResourceController;
class ApiProdutos extends ResourceController
{
protected $modelName = 'App\Models\ProdutoModel';
protected $format = 'json';
// GET /api/products
public function index()
{
return $this->respond($this->model->findAll());
}
// GET /api/products/1
public function show($id = null)
{
$product = $this->model->find($id);
if (!$product) {
return $this->failNotFound('Product not found');
}
return $this->respond($product);
}
// POST /api/products
public function create()
{
$data = $this->request->getJSON(true);
$id = $this->model->insert($data);
$product = $this->model->find($id);
return $this->respondCreated($product);
}
// PUT /api/products/1
public function update($id = null)
{
$data = $this->request->getJSON(true);
$this->model->update($id, $data);
return $this->respond($this->model->find($id));
}
// DELETE /api/products/1
public function delete($id = null)
{
$this->model->delete($id);
return $this->respondDeleted(['id' => $id]);
}
}ResourceController provides standardized REST methods (index, show, create, update, delete). respond() returns 200, respondCreated() returns 201, failNotFound() returns 404. $format = 'json' sets the Content-Type automatically. Routes with $routes->resource('api/products') map all HTTP verbs.
API pagination
public function index()
{
$page = $this->request->getGet('page') ?? 1;
$porPagina = $this->request->getGet('per_page') ?? 20;
$products = $this->model
->orderBy('created_at', 'DESC')
->paginate($porPagina, 'default', $page);
$pager = $this->model->pager;
return $this->respond([
'data' => $products,
'meta' => [
'total' => $pager->getTotal(),
'por_page' => $porPagina,
'page' => $pager->getCurrentPage(),
'total_pages' => $pager->getPageCount(),
],
]);
}paginate() limits results and calculates offsets automatically. Parameters: items per page, group and page number. The $pager object provides metadata: getTotal() (total records), getCurrentPage(), getPageCount(). Including metadata in the response lets the client build navigation. Parameters via query string (?page=2&per_page=50).
CORS in API
// Config/Filters.php — alias:
public array $aliases = [
'cors' => \App\Filters\CorsFilter::class,
];
// App/Filters/CorsFilter.php:
public function before(RequestInterface $request, $arguments = null)
{
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
// Respond to the OPTIONS preflight:
if ($request->getMethod() === 'options') {
$response = service('response');
$response->setStatusCode(200);
return $response;
}
}
// Apply to a group:
$routes->group('api', ['filter' => 'cors'], function ($routes) {
$routes->resource('products');
});CORS allows browsers from other domains to access the API. Access-Control-Allow-* headers define allowed origins, methods and headers. OPTIONS requests (preflight) must return an empty 200. In production, replace * with specific domains. Apply the a filter on the api group so it does not affect web routes.
Resource Routes
// Config/Routes.php:
// Maps a full CRUD:
$routes->resource('api/products', [
'controller' => 'ApiProdutos',
]);
// Generates:
// GET /api/products → index()
// GET /api/products/(:num) → show($1)
// POST /api/products → create()
// PUT /api/products/(:num) → update($1)
// DELETE /api/products/(:num) → delete($1)
// Only some methods:
$routes->resource('api/users', [
'only' => ['index', 'show'],
]);
// Exclude methods:
$routes->resource('api/posts', [
'except' => ['delete'],
]);
// Websafe (form instead of PUT/DELETE):
$routes->resource('api/items', [
'websafe' => true,
]);
// PUT → POST with _method=PUT$routes->resource() generates the 5 REST routes automatically. only limits available methods, except excludes specific ones. websafe converts PUT/DELETE into POST with a _method field (for HTML forms that only support GET/POST). Alternative: $routes->presenter() includes new() and edit() for forms.
Filters and search
public function index()
{
$builder = $this->model;
// Text search:
if ($search = $this->request->getGet('q')) {
$builder = $builder->like('name', $search);
}
// Exact filter:
if ($status = $this->request->getGet('status')) {
$builder = $builder->where('status', $status);
}
// Range filter:
if ($min = $this->request->getGet('price_min')) {
$builder = $builder->where('price >=', $min);
}
// Ordering:
$sort = $this->request->getGet('sort') ?? 'created_at';
$direction = $this->request->getGet('dir') ?? 'DESC';
$builder = $builder->orderBy($sort, $direction);
return $this->respond($builder->paginate(20));
}Filters are optional — applied only if the parameter exists in the query string. like() for partial search, where() for exact equality. Chaining conditions on the $builder allows dynamic combinations. Validating column names for ordering (whitelist) prevents SQL injection. RESTful pattern: ?q=term&status=active&sort=name&dir=ASC.
Transformers / Formatting
// Format the API output:
private function formatarProduto(array $product): array
{
return [
'id' => (int) $product['id'],
'name' => $product['name'],
'price' => number_format($product['price'], 2, '.', ''),
'category' => $product['category_name'] ?? null,
'links' => [
'self' => base_url('api/products/' . $product['id']),
],
];
}
public function index()
{
$products = $this->model->findAll();
$data = array_map([$this, 'formatarProduto'], $products);
return $this->respond($data);
}
// With relations:
public function show($id = null)
{
$product = $this->model
->select('products.*, categories.name the category_name')
->join('categories', 'categories.id = products.category_id', 'left')
->find($id);
return $this->respond($this->formatarProduto($product));
}Transformers format data before sending — never expose the internal database structure. Convert types ((int)), format values (number_format) and include HATEOAS links. array_map() applies the transformation to collections. Keep fields consistent between index and show. For larger projects, create dedicated Transformer classes.
JSON responses
// Responses with status codes:
return $this->respond($data); // 200
return $this->respondCreated($data); // 201
return $this->respondDeleted($data); // 200
// Errors:
return $this->fail('Error generic'); // 400
return $this->failUnauthorized('Token invalid'); // 401
return $this->failForbidden('Sem permission'); // 403
return $this->failNotFound('Not found'); // 404
return $this->failValidationError($errors); // 422
return $this->failServerError('Error internal'); // 500
// Manual response:
return $this->response
->setStatusCode(202)
->setJSON(['status' => 'aceite']);
// Response format:
// { "status": 200, "error": null, "messages": [], "data": {...} }The respond*() and fail*() methods standardize the JSON structure with status, error, messages and data. Each method sets the correct HTTP status code automatically. failValidationError() accepts an array of validation errors. For custom responses, use setStatusCode() + setJSON() directly.
Token authentication
// Authentication filter (App/Filters/AuthApi.php):
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class AuthApi implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$token = $request->getHeaderLine('Authorization');
$token = str_replace('Bearer ', '', $token);
if (empty($token)) {
return service('response')
->setStatusCode(401)
->setJSON(['error' => 'Token ausente']);
}
$user = model('UserModel')
->where('api_token', hash('sha256', $token))
->first();
if (!$user) {
return service('response')
->setStatusCode(401)
->setJSON(['error' => 'Token invalid']);
}
// Store the user for later use:
$request->user = $user;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
}
}Filters intercept requests before the controller. The token comes in the Authorization: Bearer xxx header. Store the hash (not the plain token) in the database. Returning a 401 response directly in before() blocks access. Register in Config/Filters.php the an alias and apply to routes: $routes->group('api', ['filter' => 'authapi']).
API validation
public function create()
{
$data = $this->request->getJSON(true);
$rules = [
'name' => 'required|min_length[3]|max_length[100]',
'email' => 'required|valid_email|is_unique[users.email]',
'age' => 'permit_empty|integer|greater_than[0]',
];
if (!$this->validate($rules)) {
return $this->failValidationError(
$this->validator->getErrors()
);
}
$id = $this->model->insert($data);
return $this->respondCreated(
$this->model->find($id),
'Recurso created'
);
}In APIs, $this->request->getJSON(true) converts the JSON body into an associative array. Validation uses the same rules the forms. failValidationError() returns 422 with the detailed errors in JSON. getErrors() returns an array of messages per field. The second parameter of respondCreated() is the success message.
Rate limiting
// Simple rate limit filter (App/Filters/RateLimit.php):
public function before(RequestInterface $request, $arguments = null)
{
$ip = $request->getIPAddress();
$cache = service('cache');
$key = 'rate_' . $ip;
$hits = $cache->get($key) ?? 0;
$limit = 60; // requests per minute
if ($hits >= $limit) {
return service('response')
->setStatusCode(429)
->setHeader('Retry-After', '60')
->setJSON(['error' => 'Limit excedido']);
}
$cache->save($key, $hits + 1, 60);
}
// Apply globally (Config/Filters.php):
public array $globals = [
'before' => ['ratelimit'],
];Rate limiting protects APIs from abuse. It uses cache to count requests per IP with a 60-second TTL. When the limit is exceeded, it returns 429 Too Many Requests with a Retry-After header. Apply the a global filter or per route group. For production, prefer Redis (shared counter across servers). Informative headers: X-RateLimit-Limit, X-RateLimit-Remaining.
CLI and Tools
Spark command
// spark is the CodeIgniter 4 CLI: php spark // Built-in commands: php spark serve // Development server php spark routes // List all routes php spark db:seed // Run seeders php spark migrate // Run migrations php spark migrate:rollback // Revert migrations php spark make:controller Name // Generate controller php spark make:model Name // Generate model php spark make:migration Name // Generate migration php spark make:command Name // Generate command php spark make:filter Name // Generate filter php spark make:seeder Name // Generate seeder php spark cache:clear // Clear cache php spark db:table // List tables
php spark is the CI4 CLI interface (equivalent to Laravel's artisan). serve starts a server on port 8080. routes shows all registered routes with verbs and handlers. make:* generates boilerplate with the correct namespace. migrate and db:seed manage the database. All commands accept --help for documentation.
Seeders and data
// Create: php spark make:seeder ProdutosSeeder
// App/Database/Seeds/ProdutosSeeder.php:
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
class ProdutosSeeder extends Seeder
{
public function run()
{
$data = [
[
'name' => 'Product A',
'price' => 29.90,
'active' => 1,
'created_at' => date('Y-m-d H:i:s'),
],
[
'name' => 'Product B',
'price' => 49.90,
'active' => 1,
'created_at' => date('Y-m-d H:i:s'),
],
];
$this->db->table('products')->insertBatch($data);
// Call another seeder:
$this->call('CategoriasSeeder');
}
}
// Run:
// php spark db:seed ProdutosSeederSeeders populate the database with initial data. insertBatch() inserts multiple rows efficiently. $this->call() chains seeders (execution order). $this->db gives access to the connection. For test data, use Faker (built into CI4). Run with php spark db:seed NomeSeeder. Register seeders in DatabaseSeeder to run them together.
Environment configuration
// .env (project root): # CI_ENVIRONMENT = production | development | testing CI_ENVIRONMENT = development # Base URL: app.baseURL = 'http://localhost:8080/' # Database: database.default.hostname = localhost database.default.database = my_app database.default.username = root database.default.password = '' database.default.DBDriver = MySQLi # Email: email.protocol = smtp email.SMTPHost = smtp.gmail.com email.SMTPUser = user@gmail.com email.SMTPPass = password_app email.SMTPPort = 587 email.SMTPCrypto = tls # In production: # CI_ENVIRONMENT = production # (disables debugbar, shows a generic error page)
The .env file configures the environment without changing code. CI_ENVIRONMENT controls debugging: development shows detailed errors + debugbar, production hides details. Configs use group.property notation. Never commit .env — use .env.example the a template. In production, set baseURL, database and email with real values.
Create custom command
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class GerarRelatorio extends BaseCommand
{
protected $group = 'App';
protected $name = 'report:generate';
protected $description = 'Generates report monthly de sales';
protected $usage = 'report:generate [month] [year]';
protected $arguments = [
'month' => 'Month (1-12)',
'year' => 'Year (4 digits)',
];
protected $options = [
'--formato' => 'Formato: csv, pdf, excel',
];
public function run(array $params)
{
$month = $params[0] ?? date('m');
$year = $params[1] ?? date('Y');
$formato = CLI::getOption('formato') ?? 'csv';
CLI::write("Generating report: {$month}/{$year}");
CLI::write("Formato: {$formato}", 'green');
// Report logic...
$total = model('VendaModel')
->where('MONTH(created_at)', $month)
->where('YEAR(created_at)', $year)
->selectSum('total')
->first();
CLI::write("Total: R$ {$total['total']}", 'yellow');
CLI::newLine();
}
}Custom commands extend BaseCommand in App/Commands/. $name defines how to invoke it (php spark report:generate 06 2024). $arguments are positional, $options use --flag. CLI::write() prints with color (green, yellow, red). CLI::getOption() reads flags. $group organizes the output of php spark.
Faker / Test data
// In Seeders (Faker built in):
use Faker\Factory;
public function run()
{
$faker = Factory::create('pt_BR');
$data = [];
for ($i = 0; $i < 50; $i++) {
$data[] = [
'name' => $faker->name(),
'email' => $faker->unique()->safeEmail(),
'phone' => $faker->phoneNumber(),
'address' => $faker->address(),
'company' => $faker->company(),
'created_at' => $faker->dateTimeThisYear()->format('Y-m-d H:i:s'),
];
}
$this->db->table('clients')->insertBatch($data);
}
// Factories (CI4 built-in):
use CodeIgniter\Test\Fabricator;
use App\Models\UserModel;
$users = fabricate(UserModel::class, 10, [
'active' => 1,
]);Faker generates realistic data for testing and development. The pt_BR locale for Portuguese data. unique() avoids duplicates. insertBatch() for performance with many records. fabricate() is the CI4 helper that combines Factory + Faker + Model. Never use Faker seeders in production — only for development and testing.
Deploy and optimization
// Production checklist:
// 1. Environment:
CI_ENVIRONMENT = production
// 2. Cache configurations:
php spark cache:clear
// 3. Optimize the autoloader (composer):
composer install --no-dev --optimize-autoloader
// 4. Structure on the server:
// /public_html/ ← point the document root here
// index.php
// assets/
// /app/ ← outside the document root
// /vendor/
// /writable/ ← permissions 755
// 5. index.php (point to ../):
// $pathsConfig = FCPATH . '../app/Config/Paths.php';
// 6. .htaccess (Apache):
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php/$1 [L]
// 7. Security:
// - Disable directory listing
// - Protect /writable and /app
// - HTTPS mandatoryIn production, the document root should point to /public/ — everything else stays inaccessible via web. CI_ENVIRONMENT = production disables debugging. composer install --no-dev excludes development dependencies. .htaccess rewrites URLs to the front controller. Protect /writable and /app with access rules. Use HTTPS and security headers.
CLI input/output
use CodeIgniter\CLI\CLI;
// Output with colors:
CLI::write('Success!', 'green');
CLI::write('Attention!', 'yellow');
CLI::write('Error!', 'red');
CLI::error('Failure critical'); // red + stderr
// User input:
$name = CLI::prompt('What is your name?');
$email = CLI::prompt('Email:', null, 'required|valid_email');
// Choice between options:
$type = CLI::prompt('Type:', ['admin', 'user', 'guest']);
// Shows: [0] admin [1] user [2] guest
// Confirmation:
if (CLI::prompt('Continue?', ['y', 'n']) === 'y') {
// proceed
}
// Formatting:
CLI::newLine();
CLI::write(str_repeat('-', 40));
CLI::table($data, ['ID', 'Name', 'Email']);
// Progress:
$progress = new \CodeIgniter\CLI\Progressbar(100);
for ($i = 0; $i <= 100; $i++) {
$progress->update($i);
}The CLI class provides interactive I/O for commands. prompt() accepts inline validation (Validation rules). CLI::table() formats arrays into an ASCII table. Colors: green, yellow, red, blue, magenta, cyan. CLI::error() writes to stderr. Progressbar shows progress in long loops. Input with validation repeats the question until a valid value is entered.
Testing
// tests/unit/CalculadoraTest.php:
namespace Tests\Unit;
use CodeIgniter\Test\CIUnitTestCase;
class CalculadoraTest extends CIUnitTestCase
{
public function testAdd()
{
$result = 2 + 3;
$this->assertEquals(5, $result);
}
public function testDivisaoPorZero()
{
$this->expectException(\DivisionByZeroError::class);
$result = 1 / 0;
}
}
// Feature test (HTTP):
namespace Tests\Feature;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
class ApiTest extends CIUnitTestCase
{
use FeatureTestTrait;
public function testListarProdutos()
{
$result = $this->get('api/products');
$result->assertOK();
$result->assertJSONFragment(['name' => 'Product A']);
}
}
// Run: php spark test
// Or: vendor/bin/phpunitCI4 uses PHPUnit built in. CIUnitTestCase is the base class with framework helpers. FeatureTestTrait allows testing HTTP routes without a server (get(), post()). Assertions: assertOK() (200), assertJSONFragment(), assertStatus(). expectException() for errors. Configure in phpunit.xml. Run with php spark test.
Project structure
// CI4 structure: project/ ├── app/ │ ├── Config/ // Configurations │ │ ├── Routes.php // Routes │ │ ├── Database.php │ │ ├── Validation.php │ │ └── Filters.php │ ├── Controllers/ // Controllers │ ├── Models/ // Models │ ├── Views/ // Templates │ ├── Database/ │ │ ├── Migrations/ // Migrations │ │ └── Seeds/ // Seeders │ ├── Filters/ // Filters (middleware) │ ├── Libraries/ // Helper classes │ └── Helpers/ // Helper functions ├── public/ // Document root │ ├── index.php // Front controller │ └── assets/ // CSS, JS, images ├── writable/ // Logs, cache, uploads ├── tests/ // Tests ├── vendor/ // Composer dependencies ├── spark // CLI └── .env // Environment config
The structure follows MVC with clear separation. app/ contains all the logic. public/ is the only directory accessible via web (front controller index.php). writable/ stores logs, cache and uploads (needs write permission). Filters/ are middleware. spark is the CLI. Configs in app/Config/ — one class per aspect of the system.
Migrations
// Create: php spark make:migration create_products
// App/Database/Migrations/2024-01-15-120000_create_products.php:
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CriarProdutos extends Migration
{
public function up()
{
$this->forge->addField([
'id' => ['type' => 'INT', 'auto_increment' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 200],
'price' => ['type' => 'DECIMAL', 'constraint' => '10,2'],
'active' => ['type' => 'TINYINT', 'default' => 1],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('active');
$this->forge->createTable('products');
}
public function down()
{
$this->forge->dropTable('products', true);
}
}
// Run:
// php spark migrate
// php spark migrate:rollback
// php spark migrate:refresh (rollback + migrate)Migrations version the database schema. $this->forge is the DDL builder. addField() defines columns with type, constraint and default. addKey() creates indexes (second parameter true = primary key). up() applies, down() reverts. Files are ordered by timestamp. migrate:refresh recreates everything from scratch.
Debugging and profiling
// Debugbar (enable in .env):
// CI_ENVIRONMENT = development
// The debug toolbar shows:
// - SQL queries with timing
// - Matched routes
// - Rendered views
// - Request headers
// - Logs and timeline
// Manual logging:
log_message('error', 'Failure processing order #' . $id);
log_message('info', 'User login: ' . $email);
log_message('debug', 'Data: ' . print_r($data, true));
// dd() and d():
dd($variable); // dump + die
d($variable); // dump (continues)
// Benchmark:
$benchmark = service('timer');
$benchmark->start('query');
$data = $model->findAll();
$benchmark->stop('query');
echo $benchmark->getElapsedTime('query');
// See the last SQL query:
echo $model->getLastQuery();In development mode, the Debugbar shows queries, timing, routes and views automatically. log_message() writes to writable/logs/ with levels (error, info, debug). dd() dumps and stops execution. service('timer') measures the performance of code sections. getLastQuery() shows the SQL generated by the Query Builder for debugging.