DevTools

Cheatsheet CodeIgniter

Framework PHP leve e simples

Volver a los lenguajes
CodeIgniter
95 tarjetas encontradas
Categorías:
Versiones:

Instalação e Estrutura


10 cards
Instalación
// Crear proyecto con Composer:
composer create-project codeigniter4/appstarter mi-proyecto
cd mi-proyecto

// Iniciar el servidor de desarrollo:
php spark serve

// Requisitos:
// - PHP 8.1+
// - ext-intl, ext-mbstring
// - Composer

// Alternativa (descarga manual):
// codeigniter.com/download

composer create-project crea el proyecto con todas las dependencias. php spark serve inicia el servidor de desarrollo en el puerto 8080. CodeIgniter 4 requiere PHP 8.1+ con las extensiones intl y mbstring. Es el framework PHP más ligero — sin dependencias obligatorias más allá del core.

Autoloading y Namespaces
// app/Config/Autoload.php
public array $psr4 = [
    APP_NAMESPACE => APPPATH,
    'App' => APPPATH,
];

// Añadir un namespace custom:
public array $psr4 = [
    'App' => APPPATH,
    'Acme' => ROOTPATH . 'acme/src',
];

// Uso en controllers/models:
use App\Models\ProdutoModel;
use Acme\Pagamentos\Gateway;

// Classmap (archivos específicos):
public array $classmap = [
    'MiClase' => APPPATH . 'Libraries/MiClase.php',
];

CodeIgniter 4 usa autoloading PSR-4 — cada namespace mapea a un directorio. APP_NAMESPACE apunta a app/. Los namespaces custom permiten organizar bibliotecas externas. classmap mapea clases individuales. El autoloader es rápido y no requiere composer dump-autoload para clases en 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();
    }
}

// Todos los controllers extienden BaseController
class Producto extends BaseController
{
    // $this->session ya disponible
}

BaseController es el padre de todos los controllers — ideal para cargar helpers, iniciar la sesión y definir datos compartidos. initController() es el "constructor" de CI4 (no usar __construct). $helpers carga helpers automáticamente en todos los controllers. Las propiedades definidas aquí quedan disponibles en toda la aplicación.

Estructura de carpetas
app/
  Config/          // ajustes
  Controllers/     // controladores
  Models/          // modelos
  Views/           // templates
  Database/
    Migrations/    // migraciones
    Seeds/         // seeders
  Filters/         // middleware
  Libraries/       // bibliotecas custom
public/
  index.php        // front controller
  assets/          // CSS, JS, imágenes
writable/
  logs/            // logs da aplicación
  cache/           // cache
  uploads/         // ficheros enviados

La carpeta app/ contiene todo el código de la aplicación (MVC). public/ es el único directorio público (front controller index.php). writable/ almacena logs, cache y uploads (fuera del acceso web). Esta separación protege el código fuente — solo public/ es servido por el web server.

Constantes y paths
// Constantes de ruta (app/Config/Paths.php):
APPPATH      // app/
ROOTPATH     // raiz do proyecto
WRITEPATH    // writable/
PUBLICPATH   // public/
SYSTEMPATH   // vendor/codeigniter4/framework/system/

// Uso:
$logFile = WRITEPATH . 'logs/app.log';
$uploadDir = PUBLICPATH . 'uploads/';
$configFile = APPPATH . 'Config/App.php';

// Constantes de entorno:
ENVIRONMENT  // 'development' ou 'production'
CI_DEBUG     // true em development

// Verificar el entorno:
if (ENVIRONMENT === 'development') {
    // código solo em dev
}

Las constantes de ruta (APPPATH, WRITEPATH, PUBLICPATH) evitan rutas relativas frágiles. ENVIRONMENT indica el modo actual (development/production). CI_DEBUG es true en development. Estas constantes se definen en app/Config/Paths.php y están disponibles globalmente sin import.

Composer y dependencias
// composer.json (dependencias):
{
    "require": {
        "php": "^8.1",
        "codeigniter4/framework": "^4.4"
    },
    "require-dev": {
        "fakerphp/faker": "^1.23",
        "codeigniter4/devkit": "^1.2",
        "phpunit/phpunit": "^10.5"
    }
}

// Instalar dependencias:
composer install

// Añadir un paquete:
composer require dompdf/dompdf

// Actualizar el framework:
composer update codeigniter4/framework

// Scripts:
composer test    // ejecutar pruebas
composer analyze // análisis estática

CodeIgniter 4 se instala vía Composercodeigniter4/framework es el core. codeigniter4/devkit añade herramientas de desarrollo (debugbar, faker). composer require añade paquetes de terceros. El framework es minimalista — la mayoría de las funcionalidades son nativas, sin paquetes extra.

Configuración (.env)
# Copiar la plantilla:
cp env .env

# .env (configuración de entorno):
CI_ENVIRONMENT = development

app.baseURL = "http://localhost:8080/"
app.forceGlobalSecureRequests = false

database.default.hostname = localhost
database.default.database = mi_db
database.default.username = root
database.default.password =
database.default.DBDriver = MySQLi

# Producción:
# CI_ENVIRONMENT = production

El archivo .env sobrescribe la configuración de app/Config/ — nunca hacer commit (está en el .gitignore). CI_ENVIRONMENT controla la debug bar y el error reporting. En producción, definir production desactiva los detalles de error. app.baseURL debe ser la URL raíz del proyecto.

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 contiene la configuración global: baseURL, locale, timezone, sesión y seguridad. defaultLocale define el idioma por defecto. sessionExpiration en segundos (7200 = 2h). CSPEnabled activa Content Security Policy. La mayoría puede sobrescribirse en el .env.

Configuración de BD
// app/Config/Database.php
public array $default = [
    'DSN'      => '',
    'hostname' => 'localhost',
    'username' => 'root',
    'password' => '',
    'database' => 'mi_db',
    'DBDriver' => 'MySQLi',
    'DBPrefix' => '',
    'pConnect' => false,
    'DBDebug'  => true,
    'charset'  => 'utf8mb4',
    'DBCollat' => 'utf8mb4_general_ci',
];

// Drivers soportados:
// MySQLi, Postgre, SQLite3, SQLSRV, OCI8

La configuración de BD está en app/Config/Database.php o en el .env. DBDriver define el driver (MySQLi, Postgre, SQLite3, SQLSRV). charset utf8mb4 soporta emoji y caracteres especiales. DBDebug muestra errores SQL en desarrollo. Soporta múltiples conexiones con grupos diferentes.

Múltiples entornos
# .env (development):
CI_ENVIRONMENT = development
database.default.hostname = localhost
database.default.database = mi_db_dev

# .env.production:
CI_ENVIRONMENT = production
database.default.hostname = db.servidor.com
database.default.database = mi_db_prod

# Comportamiento por entorno:
# development:
#   - Debug toolbar visible
#   - Errores detalhados
#   - CI_DEBUG = true

# production:
#   - Errores genéricos (sem stack trace)
#   - Debug toolbar oculta
#   - CI_DEBUG = false

CI_ENVIRONMENT controla el comportamiento de la aplicación. En development, la debug toolbar muestra queries, tiempo y memoria. En production, los errores son genéricos (sin exponer stack traces). Puedes usar archivos .env diferentes por entorno. CodeIgniter no tiene .env.example — usa el archivo env como plantilla.

Rotas e Controladores


11 cards
Rutas básicas
// app/Config/Routes.php
use CodeIgniter\Router\RouteCollection;

$routes->get('/', 'Home::index');
$routes->get('productos', 'Producto::listar');
$routes->post('productos', 'Producto::crear');
$routes->get('productos/(:num)', 'Producto::ver/$1');
$routes->put('productos/(:num)', 'Producto::actualizar/$1');
$routes->delete('productos/(:num)', 'Producto::eliminar/$1');

// Placeholders:
// (:num)    - apenas numeros
// (:alpha)  - apenas letras
// (:any)    - qualquer caractere
// (:segment) - segmento URI (sem /)
// (:alphanum) - alfanumérico

Las rutas se definen en app/Config/Routes.php con verbos HTTP (get, post, put, delete). Los placeholders como (:num) capturan segmentos de la URI y los pasan como argumentos ($1). El formato es 'Controller::método'. Las rutas se evalúan en el orden en que se definen.

Redireccionamiento
// Redirigir a URL:
return redirect()->to('/productos');

// A una ruta con nombre:
return redirect()->route('nombre_rota');

// Volver atrás:
return redirect()->back();

// Con flash messages:
return redirect()->back()
    ->with('éxito', 'Guardado com éxito!');

// Con input antiguo (re-poblar form):
return redirect()->back()
    ->withInput()
    ->with('error', 'Datos inválidos');

// Código HTTP custom:
return redirect()->to('/login')->with('msg', 'Sesión expirada');

// En la view:
// <?= session()->getFlashdata('éxito') ?>

redirect() retorna una respuesta de redireccionamiento. ->with() define flash messages (disponibles solo en el próximo request). ->withInput() preserva el input del formulario (vía old() en la view). ->route() usa nombres de rutas (más seguro que URLs hardcoded). Los flash messages son ideales para feedback post-acción (patrón PRG).

Request y respuesta
// En el 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');

    // Respuesta custom:
    return $this->response
        ->setStatusCode(201)
        ->setHeader('X-Custom', 'valor')
        ->setBody($contenido);

    // Download:
    return $this->response
        ->download('fichero.pdf', $datos);
}

$this->request proporciona acceso al request HTTP: método, URI, IP, headers, AJAX. $this->response permite una respuesta custom con status code, headers y body. ->download() fuerza la descarga de un archivo. isAJAX() detecta peticiones XMLHttpRequest. Estos objetos se inyectan automáticamente en el controller vía initController().

Resource routes
// Rutas RESTful automáticas:
$routes->resource('productos');
// Genera 7 rutas:
// GET    /productos        -> index
// GET    /productos/new    -> new
// POST   /productos        -> create
// GET    /productos/(:num) -> show
// GET    /productos/(:num)/edit -> edit
// PUT    /productos/(:num) -> update
// DELETE /productos/(:num) -> delete

// Presenter (formularios HTML):
$routes->presenter('productos');

// Limitar métodos:
$routes->resource('productos', [
    'only' => ['index', 'show', 'create']
]);

// Controller diferente:
$routes->resource('productos', [
    'controller' => 'Api\ProdutoController'
]);

$routes->resource() genera las 7 rutas RESTful automáticamente — ideal para un CRUD completo. $routes->presenter() es una variante para formularios HTML (usa new/edit en vez de JSON). only limita los métodos generados. El controller debe extender ResourceController para API o ResourcePresenter para HTML.

Rutas con nombre
// Definir nombre:
$routes->get('productos/(:num)', 'Producto::ver/$1',
    ['as' => 'producto.ver']
);
$routes->get('perfil', 'Perfil::index',
    ['as' => 'perfil']
);

// Usar el nombre en redirecciones:
return redirect()->route('producto.ver', [$id]);

// Generar URL en la view:
<a href="<?= url_to('producto.ver', $p['id']) ?>">
    Ver producto
</a>

<a href="<?= url_to('perfil') ?>">Mi Perfil</a>

// Ventaja: cambiar la URL sin romper links
// (basta alterar la ruta, los nombres se mantienen)

Las rutas con nombre (['as' => 'nombre']) desacoplan las URLs de los links. url_to() genera la URL a partir del nombre y parámetros. redirect()->route() redirige por nombre. Si la URL cambia, solo alteras la definición de la ruta — todos los links se actualizan automáticamente. Esencial para el mantenimiento y refactorización sin romper referencias.

Manejo de errores
// Lanzar 404:
throw \CodeIgniter\Exceptions\PageNotFoundException
    ::forPageNotFound('Producto no encontrado');

// Error custom:
throw new \RuntimeException('Error no pagamento', 500);

// Páginas de error custom:
// app/Views/errors/html/error_404.php
// app/Views/errors/html/error_exception.php
// app/Views/errors/html/production.php

// Handler global (app/Config/Exceptions.php):
// Personalizar la respuesta de error

// Log de errores:
log_message('error', 'Fallo no pagamento: ' . $e->getMessage());

// En producción:
// Los errores muestran página genérica (sin detalles)

PageNotFoundException retorna 404 con página custom (error_404.php). En development, los errores muestran el stack trace completo; en production, página genérica. Las páginas de error están en app/Views/errors/html/. log_message() registra en writable/logs/. Nunca exponer detalles de error en producción (seguridad).

Controller básico
<?php
namespace App\Controllers;

class Producto extends BaseController
{
    public function index()
    {
        $model = new \App\Models\ProdutoModel();
        $data['productos'] = $model->findAll();
        return view('productos/lista', $data);
    }

    public function ver($id)
    {
        $model = new \App\Models\ProdutoModel();
        $data['producto'] = $model->find($id);

        if (!$data['producto']) {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
        }

        return view('productos/detalle', $data);
    }
}

Los controllers extienden BaseController y contienen métodos de acción. return view() renderiza un template con datos. PageNotFoundException retorna 404 automáticamente. El controller orquesta: recibe el request, llama al model, pasa datos a la view. Mantén la lógica de negocio en los models/services, no en el controller.

Filtros de ruta
// Filtro en una ruta individual:
$routes->get('admin', 'Admin::index',
    ['filter' => 'auth']
);

// Múltiples filtros:
$routes->get('admin/relatorios', 'Admin::Relatorios',
    ['filter' => ['auth', 'admin']
);

// Filtro con parámetros:
$routes->get('api/datos', 'Api::datos',
    ['filter' => 'throttle:60']
);

// Filtros globales (Config/Filters.php):
public array $globals = [
    'before' => ['csrf', 'honeypot'],
    'after' => ['toolbar', 'secureheaders'],
];

// Filtro por patrón URI:
public array $filters = [
    'auth' => ['before' => ['admin/*']],
];

Los filtros son el middleware de CI4 — se ejecutan antes (before) y/o después (after) del controller. Pueden aplicarse por ruta, grupo, patrón URI o globalmente. csrf y honeypot son built-in. Los filtros custom implementan FilterInterface. Pueden recibir parámetros (throttle:60). Ideales para autenticación, logging y rate limiting.

Restricciones de ruta
// Restringir por hostname:
$routes->get('docs', 'Docs::index',
    ['hostname' => 'docs.meusite.com']
);

// Restringir por subdominio:
$routes->group('', ['hostname' => 'api.*'],
    function ($routes) {
        $routes->get('users', 'Api\Users::index');
    }
);

// Regex custom en el placeholder:
$routes->get('users/([a-z-]+)', 'Users::ver/$1');

// Prioridad de rutas:
$routes->setPrioritize();
// Rutas más específicas primero

// Verificar la ruta actual:
$route = service('router')->getRouteName();
$controller = service('router')->controllerName();
$method = service('router')->methodName();

Las rutas pueden restringirse por hostname (subdominios, multi-site). El regex custom en los placeholders permite patrones complejos. setPrioritize() activa la prioridad por especificidad. service('router') proporciona información de la ruta actual (útil en layouts para menús activos). Las rutas se evalúan en orden — definir las específicas antes que las genéricas.

Grupos y namespaces
// Agrupar con prefijo:
$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

// Grupo con filtro (middleware):
$routes->group('api',
    ['filter' => 'auth:api'],
    function ($routes) {
        $routes->get('perfil', 'Api\Perfil::show');
    }
);

// Sub-grupos:
$routes->group('admin', function ($routes) {
    $routes->group('users', function ($routes) {
        $routes->get('/', 'Admin\Users::index');
    });
});

$routes->group() agrupa rutas con un prefijo URI, namespace y filtros compartidos. Evita repetir prefijos y aplica middleware a múltiples rutas. Los sub-grupos permiten jerarquía (/admin/users). El namespace del grupo evita calificar cada controller. Los filtros del grupo se aplican a todas las rutas internas.

Auto-routing
// app/Config/Routes.php
// Auto-routing (CI4.5+):
$routes->setAutoRoute(true);

// URL: /productos/listar
// -> App\Controllers\Productos::listar()

// URL: /admin/users/index
// -> App\Controllers\Admin\Users::index()

// Desactivar (recomendado en producción):
$routes->setAutoRoute(false);

// Auto-routing con traducción de namespace:
$routes->setAutoRoute(true);
$routes->setTranslateURIDashes(true);
// /mis-productos -> MeusProdutos controller

// Prioridad: rutas definidas > auto-route

El auto-routing mapea URIs directamente a controllers/métodos sin una definición explícita. Convención: /controller/método/params. setTranslateURIDashes convierte guiones a CamelCase. En producción, se recomienda desactivarlo (setAutoRoute(false)) y definir rutas explícitamente — más seguro y documentado. Las rutas definidas tienen prioridad sobre el auto-routing.

Modelos e BD


12 cards
Crear Model
<?php
namespace App\Models;

use CodeIgniter\Model;

class ProdutoModel extends Model
{
    protected $table = 'productos';
    protected $primaryKey = 'id';
    protected $allowedFields = ['nombre', 'precio', 'categoria'];
    protected $useTimestamps = true;
    protected $createdField = 'created_at';
    protected $updatedField = 'updated_at';
    protected $returnType = 'array';
    protected $useSoftDeletes = false;
}

// Crear vía CLI:
// php spark make:model Producto

El Model de CI4 se configura vía propiedades: $table, $primaryKey, $allowedFields (mass assignment seguro). $useTimestamps gestiona created_at/updated_at automáticamente. $returnType define si retorna array u objeto. php spark make:model genera el archivo automáticamente.

Paginación
// En el controller:
public function index()
{
    $model = new ProdutoModel();
    $data['productos'] = $model
        ->orderBy('nombre', 'ASC')
        ->paginate(10);
    $data['pager'] = $model->pager;
    return view('productos/lista', $data);
}

// En la view:
<?php foreach ($productos as $p): ?>
    <p><?= esc($p['nombre']) ?></p>
<?php endforeach; ?>

<?= $pager->links() ?>

// Links con grupo:
<?= $pager->links('grupo1') ?>

// Simple pagination (anterior/siguiente):
<?= $pager->simpleLinks() ?>

paginate(10) retorna 10 registros y configura el pager automáticamente. $model->pager proporciona el objeto de paginación. $pager->links() renderiza links HTML (numeros, anterior, siguiente). Lee la página desde la query string (?page=2). Soporta múltiples grupos de paginación en la misma página. Se integra con el Query Builder.

Relaciones entre Models
// CI4 no tiene relaciones estilo Eloquent
// Usar joins o queries en el Model:

class PedidoModel extends Model
{
    protected $table = 'pedidos';

    public function comItens(int $pedidoId): array
    {
        return $this->db->table('pedido_ítems')
            ->where('pedido_id', $pedidoId)
            ->get()
            ->getResultArray();
    }

    public function comCliente()
    {
        return $this->select('pedidos.*, clientes.nombre')
            ->join('clientes',
                'clientes.id = pedidos.cliente_id')
            ->findAll();
    }
}

// Alternativa: Entity con métodos:
// $pedido->ítems() retorna los ítems del pedido

CI4 no tiene relaciones estilo Eloquent (hasMany, belongsTo). Las relaciones se implementan con join() o métodos custom en el Model que hacen queries a la tabla relacionada. Las Entities pueden tener métodos que cargan datos relacionados. Es más explícito y performante — sin lazy loading sorpresa. Para relaciones complejas, considerar paquetes como codeigniter4-relations.

CRUD con Model
$model = new ProdutoModel();

// Crear:
$model->insert([
    'nombre' => 'TV Samsung',
    'precio' => 500,
    'categoria' => 'electrónica'
]);
$id = $model->getInsertID();

// Leer:
$producto = $model->find($id);
$todos = $model->findAll();
$primeiros = $model->findAll(10); // limit

// Actualizar:
$model->update($id, ['precio' => 450]);

// Eliminar:
$model->delete($id);

// Verificar errores:
if (!$model->insert($datos)) {
    $errores = $model->errors();
}

Las operaciones CRUD son métodos directos: insert(), find(), findAll(), update(), delete(). getInsertID() retorna el ID generado. errors() muestra errores de validación si falla. $allowedFields protege contra mass assignment — solo los campos listados se insertan/actualizan.

Query directa
$db = \Config\Database::connect();

// Query con binding (seguro):
$query = $db->query(
    "SELECT * FROM productos WHERE precio > ? AND categoria = ?",
    [100, 'electrónica']
);

// Resultados:
$resultados = $query->getResultArray(); // array
$objetos = $query->getResult();         // stdClass

// Una fila:
$linea = $query->getRowArray();

// Named bindings:
$query = $db->query(
    "SELECT * FROM users WHERE email = :email:",
    ['email' => $email]
);

// Query Builder sin Model:
$builder = $db->table('productos');
$builder->where('activo', 1)->get()->getResultArray();

Database::connect() obtiene la conexión. query() ejecuta SQL directo con binding (? o :nombre:) — previene SQL injection. getResultArray() retorna arrays, getResult() objetos. Para queries complejas sin Model, usa $db->table() como builder. Preferir el Query Builder siempre que sea posible.

Transactions
$db = \Config\Database::connect();

// Transaction manual:
$db->transStart();

$db->table('pedidos')->insert($pedido);
$db->table('pedido_ítems')->insertBatch($ítems);
$db->table('productos')
    ->where('id', $produtoId)
    ->set('stock', 'stock - 1', false)
    ->update();

$db->transComplete();

if ($db->transStatus() === false) {
    // Rollback automático
    log_message('error', 'Fallo na transacción');
}

// Transaction con try/catch:
try {
    $db->transBegin();
    // operaciones...
    $db->transCommit();
} catch (\Exception $e) {
    $db->transRollback();
    throw $e;
}

Las transactions garantizan atomicidad — todas las operaciones tienen éxito o ninguna. transStart()/transComplete() es el modo simple (rollback automático en caso de fallo). transBegin()/transCommit()/transRollback() da control manual. Esencial para operaciones multi-tabla (pedido + ítems + stock). set('stock', 'stock - 1', false) evita el escaping.

Query Builder
$model = new ProdutoModel();

// Condiciones encadenadas:
$resultados = $model
    ->where('precio >', 100)
    ->where('categoria', 'electrónica')
    ->where('activo', 1)
    ->orderBy('nombre', 'ASC')
    ->limit(10)
    ->findAll();

// Like:
$model->like('nombre', 'tv')
    ->orLike('descripcion', 'televisión')
    ->findAll();

// Contar:
$total = $model->where('activo', 1)
    ->countAllResults();

// whereIn:
$model->whereIn('id', [1, 2, 3])->findAll();

// whereNotIn:
$model->whereNotIn('categoria', ['removidos'])->findAll();

El Query Builder permite consultas encadenadas sin SQL directo — protege contra SQL injection automáticamente. where(), like(), orderBy(), limit() componen la query. countAllResults() cuenta sin retornar datos. whereIn()/whereNotIn() para listas de valores. Los métodos son chainable y legibles.

Soft deletes
// En el Model:
class ProdutoModel extends Model
{
    protected $useSoftDeletes = true;
    protected $deletedField = 'deleted_at';
}

// Eliminar (soft - marca deleted_at):
$model->delete($id);

// Incluir eliminados:
$model->withDeleted()->findAll();

// Solo eliminados:
$model->onlyDeleted()->findAll();

// Eliminar permanentemente:
$model->delete($id, true);

// Restaurar:
$model->update($id, ['deleted_at' => null]);

// En la migration (campo necesario):
'deleted_at' => [
    'type' => 'DATETIME',
    'null' => true,
]

Los soft deletes ($useSoftDeletes = true) marcan registros con deleted_at en vez de eliminarlos. delete() hace soft delete; delete($id, true) elimina permanentemente. withDeleted() incluye eliminados en las queries. onlyDeleted() muestra solo eliminados. Esencial para auditoría y recuperación de datos. Requiere un campo deleted_at en la tabla.

Batch operations
$model = new ProdutoModel();

// Insert múltiple:
$datos = [
    ['nombre' => 'TV', 'precio' => 500],
    ['nombre' => 'Rádio', 'precio' => 80],
    ['nombre' => 'PC', 'precio' => 1200],
];
$model->insertBatch($datos);

// Update múltiple:
$model->updateBatch($datos, 'nombre');
// Actualiza donde 'nombre' coincide

// Delete múltiple:
$model->whereIn('id', [1, 2, 3])->delete();

// Chunk (procesar en lotes):
$model->chunk(100, function ($row) {
    // Procesar cada fila
    log_message('debug', $row['nombre']);
});

// Contar total:
$total = $model->countAll();

insertBatch() inserta múltiples filas en una query (mucho más rápido que un loop de insert()). updateBatch() actualiza por clave de coincidencia. chunk() procesa registros en lotes sin cargar todo en memoria — esencial para miles de registros. countAll() cuenta sin retornar datos. Las operaciones batch son O(1) queries vs O(n).

Select y Join
// Select específico:
$model->select('id, nombre, precio')
    ->findAll();

// Select con alias:
$model->select('productos.*, categorias.nombre as cat')
    ->join('categorias',
        'categorias.id = productos.categoria_id')
    ->findAll();

// Join con tipo:
$model->select('p.*, c.nombre as cliente')
    ->join('clientes c', 'c.id = p.cliente_id', 'left')
    ->findAll();

// Múltiples joins:
$model->select('p.*, c.nombre as cat, f.nombre as forn')
    ->join('categorias c', 'c.id = p.categoria_id')
    ->join('proveedores f', 'f.id = p.proveedor_id')
    ->findAll();

// groupBy / having:
$model->select('categoria, COUNT(*) as total')
    ->groupBy('categoria')
    ->having('total >', 5)
    ->findAll();

select() limita las columnas retornadas (evitar SELECT * en producción). join() acepta tabla, condición y tipo (inner, left, right). Los múltiples joins se encadenan. groupBy() y having() para agregaciones. Usar un alias (as cat) para evitar conflictos de nombres entre tablas.

Callbacks y eventos
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']['nombre'], '-', true
        );
        return $data;
    }

    protected function formatarPreco(array $data)
    {
        if (isset($data['data']['precio'])) {
            $data['data']['precio_fmt'] =
                number_format($data['data']['precio'], 2);
        }
        return $data;
    }
}

// Callbacks disponibles:
// beforeInsert, afterInsert
// beforeUpdate, afterUpdate
// beforeFind, afterFind
// beforeDelete, afterDelete

Los callbacks se ejecutan automáticamente antes/después de las operaciones del Model. $beforeInsert modifica datos antes de insertar (ej: generar slug). $afterFind formatea resultados (ej: formatear precio). Reciben y retornan $data (array con la clave 'data'). Ideales para lógica transversal sin ensuciar los controllers.

Entities
// app/Entities/Producto.php
namespace App\Entities;

use CodeIgniter\Entity\Entity;

class Producto extends Entity
{
    protected $casts = [
        'precio' => 'float',
        'activo' => 'boolean',
        'tags' => 'json-array',
    ];

    // Mutator (setter):
    public function setName(string $val): self
    {
        $this->attributes['nombre'] = ucfirst($val);
        $this->attributes['slug'] = url_title($val, '-', true);
        return $this;
    }

    // Accessor (getter):
    public function getPrecoFormatado(): string
    {
        return number_format($this->precio, 2) . ' €';
    }
}

// En el Model:
protected $returnType = \App\Entities\Producto::class;

// Uso:
$producto = $model->find(1);
echo $producto->precio_formatado;

Las Entities son objetos ricos que representan registros de la BD. $casts convierte tipos automáticamente (float, boolean, json-array). Los mutators (setCampo) transforman al definir; los accessors (getCampo) al leer. $returnType en el Model hace que find() retorne una Entity en vez de un array. Ideales para lógica de dominio y formateo.

Views e Layouts


11 cards
View simple
// Controller:
public function index()
{
    $data = [
        'título' => 'Mis Productos',
        'productos' => $model->findAll(),
    ];
    return view('productos/lista', $data);
}

// app/Views/productos/lista.php:
<h1><?= esc($título) ?></h1>

<?php if (empty($productos)): ?>
    <p>Sem productos.</p>
<?php else: ?>
    <?php foreach ($productos as $p): ?>
        <div>
            <h3><?= esc($p['nombre']) ?></h3>
            <p><?= esc($p['precio']) ?> €</p>
        </div>
    <?php endforeach; ?>
<?php endif; ?>

view('carpeta/archivo', $data) renderiza un template con los datos extraídos como variables. Usar siempre esc() en el output para prevenir XSS. Los templates son PHP puro con sintaxis alternativa (foreach:/endforeach;). Las views están en app/Views/ organizadas en subdirectorios. No poner lógica de negocio en las views.

esc() y seguridad
// SIEMPRE escapar el output:
<?= esc($nombre) ?>
<?= esc($html, 'html') ?>
<?= esc($url, 'url') ?>
<?= esc($js, 'js') ?>
<?= esc($attr, 'attr') ?>

// Contextos:
// 'html' - contenido HTML (default)
// 'url'  - URLs
// 'js'   - JavaScript
// 'attr' - atributos HTML
// 'css'  - CSS

// CSRF en formularios:
<?= csrf_field() ?>
// Genera: <input type="hidden" name="csrf_token" ...>

// Meta tag (para AJAX):
<meta name="csrf-token" content="<?= csrf_hash() ?>">

// NUNCA:
// <?= $input_do_usuario ?>  ← XSS!

esc() es la defensa contra XSS — escapa el output según el contexto (html, url, js, attr, css). Usar SIEMPRE en datos del usuario. csrf_field() genera un token CSRF en formularios. csrf_hash() para meta tags (AJAX). Sin esc(), la aplicación es vulnerable a inyección de script. Regla: todo output dinámico pasa por esc().

Filtros de output
// app/Config/Filters.php
// Filtro de output (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;
    }
}

// Filtro de seguridad (headers):
$response->setHeader('X-Content-Type-Options', 'nosniff');
$response->setHeader('X-Frame-Options', 'DENY');

Los filtros after procesan la respuesta antes de enviarla al browser — ideales para minificar HTML, añadir headers de seguridad, comprimir el output. FilterInterface tiene before() y after(). Los filtros globales se aplican a todas las rutas. Pueden modificar el body, headers y status code. Útiles para CSP, compresión y logging de respuestas.

Layouts y secciones
// Layout: app/Views/layouts/main.php
<!DOCTYPE html>
<html>
<head>
    <title><?= $this->renderSection('título') ?></title>
</head>
<body>
    <?= $this->include('partials/nav') ?>

    <main>
        <?= $this->renderSection('contenido') ?>
    </main>

    <?= $this->renderSection('scripts') ?>
</body>
</html>

// Página:
<?= $this->extend('layouts/main') ?>

<?= $this->section('título') ?>Productos<?= $this->endSection() ?>

<?= $this->section('contenido') ?>
    <h1>Lista de Productos</h1>
<?= $this->endSection() ?>

<?= $this->section('scripts') ?>
    <script src="/js/productos.js"></script>
<?= $this->endSection() ?>

Template inheritance con extend() y section()/endSection(). El layout usa renderSection() como placeholders. Cada página define el contenido de cada sección. Soporta múltiples secciones (título, contenido, scripts). Elimina la repetición de HTML (header, footer, nav). Similar al Blade de Laravel pero con sintaxis PHP pura.

old() y validación en la view
// Repoblar el formulario tras un error:
<input type="text" name="nombre"
    value="<?= old('nombre') ?>">

<textarea name="descripcion">
    <?= old('descripcion') ?>
</textarea>

<select name="categoria">
    <option value="1"
        <?= old('categoria') == '1' ? 'selected' : '' ?>>
        Electrónica
    </option>
</select>

// Mostrar errores de validación:
<?php if (session('error_nombre')): ?>
    <span class="error"><?= session('error_nombre') ?></span>
<?php endif; ?>

// Todos los errores:
<?php if (session('errores')): ?>
    <ul>
    <?php foreach (session('errores') as $e): ?>
        <li><?= esc($e) ?></li>
    <?php endforeach; ?>
    </ul>
<?php endif; ?>

old('campo') retorna el valor enviado anteriormente (flash) — repuebla formularios tras un error de validación. Los errores se pasan vía session flash. session('error_campo') muestra un error individual. Combinado con redirect()->back()->withInput() en el controller. Esencial para UX — el usuario no pierde datos al enviar.

Data sharing entre views
// Compartir datos con todas las views:
// En el BaseController:
public function initController($request, $response, $logger)
{
    parent::initController($request, $response, $logger);

    // Disponible en todas las views:
    $this->data['user'] = session('user');
    $this->data['app_name'] = 'Mi App';
}

// Renderer global:
$view = \Config\Services::renderer();
$view->setData([
    'site_name' => 'Mi Tienda',
    'anio' => date('Y'),
]);

// En la view:
<footer>
    <?= esc($site_name) ?> © <?= $anio ?>
</footer>

// View composer (datos por view):
$view->setData(['menu' => $this->getMenu()], 'layout');

Los datos compartidos evitan pasar las mismas variables a cada view. setData() en el renderer hace los datos globales. En el BaseController, definir $this->data con info del usuario/sesión. Los view composers permiten datos específicos por template. Reduce la repetición y centraliza los datos comunes (user, menu, configuraciones).

Partials (include)
// Incluir fragmento:
<?= $this->include('partials/header') ?>
<?= $this->include('partials/nav') ?>

<main>
    <?= $contenido ?>
</main>

<?= $this->include('partials/footer') ?>

// Include con datos:
<?= $this->include('partials/card', ['producto' => $p]) ?>

// Include con opciones:
<?= $this->include('partials/alert', [
    'tipo' => 'éxito',
    'msg' => 'Guardado!'
], ['saveData' => false]) ?>

// partials/alert.php:
<div class="alert alert-<?= $tipo ?>">
    <?= esc($msg) ?>
</div>

$this->include() inserta fragmentos de view reutilizables (header, footer, cards, alerts). Acepta datos como segundo argumento. El tercer argumento son opciones (saveData, cache). Los partials están en app/Views/partials/. Combinados con layouts, eliminan toda la repetición de HTML. Más simple que los componentes de los frameworks JS.

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' => 'Inicio'],
            ['url' => '/productos', 'label' => 'Productos'],
        ];
    }
}

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

// Uso en cualquier view:
<?= view_cell('App\Cells\MenuCell') ?>

Las View Cells son mini-controllers para views — encapsulan la lógica y datos de componentes reutilizables. mount() carga datos (como un controller). La view de la cell se renderiza de forma aislada. view_cell() la invoca en cualquier template. Ideales para menús, sidebars, widgets que necesitan datos de la BD. Reemplazan include + query manual.

Assets y URLs
// Estructura recomendada:
// public/assets/css/style.css
// public/assets/js/app.js
// public/assets/img/logo.png

// En la 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">

// Con versionado (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') ?>">

// NUNCA usar rutas relativas:
// <link href="/css/style.css"> ← se rompe en subdirs

Los assets están en public/assets/ (accesibles por el browser). Usar siempre base_url() para generar URLs — funciona en subdirectorios y dominios diferentes. Cache busting con filemtime() o un hash en el nombre del archivo. Nunca rutas relativas (/css/) — se rompen si la app no está en la raíz. Organizar por tipo (css, js, img).

Helpers de URL y Form
// Cargar helpers:
helper('url');
helper(['form', 'html']);

// URL helpers:
base_url()                    // http://localhost:8080/
base_url('productos')          // .../productos
site_url('admin/painel')      // con index.php si está configurado
current_url()                 // URL actual
previous_url()                // URL anterior
uri_string()                  // segmento URI

// Form helpers:
echo form_open('productos/salvar');
echo form_input('nombre', old('nombre'));
echo form_textarea('descripcion');
echo form_dropdown('cat', $opciones, $selected);
echo form_submit('', 'Guardar');
echo form_close();

// anchor:
echo anchor('productos/1', 'Ver', ['class' => 'btn']);

Los helpers son funciones auxiliares cargadas con helper(). base_url() genera URLs absolutas. form_open() crea un <form> con token CSRF automático. form_input(), form_dropdown() generan campos HTML. anchor() crea links con atributos. Los helpers de form se integran con la validación vía old().

Caching de views
// Cache de view (en segundos):
return view('productos/lista', $data, ['cache' => 300]);

// Cache con clave custom:
return view('pagina', $data, [
    'cache' => 600,
    'cache_name' => 'pagina_home'
]);

// Invalidar cache:
$cache = \Config\Services::cache();
$cache->delete('pagina_home');

// Cache de página entera (en el controller):
public function index()
{
    // Cache por 5 minutos:
    $this->cachePage(300);
    return view('home');
}

// Configurar el driver de cache:
// app/Config/Cache.php
public string $handler = 'file';
// Opciones: file, memcached, redis, predis

Las views pueden cachearse con la opción ['cache' => segundos] — evita el re-renderizado. cachePage() cachea la respuesta entera (ideal para páginas estáticas). cache_name permite invalidación manual. Drivers: file (default), memcached, redis. El cache de views es por template+datos — los cambios en los datos lo invalidan automáticamente.

Validação e Formulários


10 cards
Reglas en el Controller
public function store()
{
    $reglas = [
        'nombre' => 'required|min_length[3]|max_length[100]',
        'email' => 'required|valid_email|is_unique[users.email]',
        'precio' => 'required|numeric|greater_than[0]',
    ];

    if (!$this->validate($reglas)) {
        return redirect()->back()
            ->withInput()
            ->with('errores', $this->validator->getErrors());
    }

    // Datos válidos:
    $datos = $this->request->getPost();
    $model->insert($datos);
    return redirect()->to('/productos')
        ->with('éxito', 'Creado!');
}

$this->validate() verifica el input contra reglas separadas por pipe. Si falla, getErrors() retorna los mensajes. redirect()->back()->withInput() preserva los datos del formulario. Las reglas son strings con | como separador. is_unique verifica unicidad en la BD. Validar siempre antes de insertar/actualizar.

Upload de archivos
$fichero = $this->request->getFile('imagen');

if ($fichero->isValid() && !$fichero->hasMoved()) {
    // Nombre aleatorio (seguro):
    $nombre = $fichero->getRandomName();

    // Mover a una carpeta:
    $fichero->move(WRITEPATH . '../public/uploads', $nombre);

    // Información:
    $original = $fichero->getClientName();
    $ext = $fichero->getExtension();
    $size = $fichero->getSize(); // bytes
    $mime = $fichero->getMimeType();
}

// Validación de upload:
$reglas = [
    'imagen' => [
        'label' => 'Imagen',
        'rules' => 'uploaded[imagen]|max_size[imagen,2048]|is_image[imagen]|mime_in[imagen,image/jpg,image/png]',
    ],
];

getFile() obtiene el archivo enviado. isValid() verifica el éxito del upload. getRandomName() genera un nombre seguro (evita path traversal). move() lo transfiere al destino. Validación con uploaded, max_size (KB), is_image, mime_in. Validar siempre el tipo y tamaño. Nunca usar el nombre original directamente.

Sanitización de input
// Filtros PHP nativos:
$email = filter_var($input, FILTER_SANITIZE_EMAIL);
$url = filter_var($input, FILTER_SANITIZE_URL);
$int = filter_var($input, FILTER_SANITIZE_NUMBER_INT);

// En el request de CI4:
$email = $this->request->getPost('email', FILTER_SANITIZE_EMAIL);

// Limpiar HTML:
$limpo = strip_tags($input);
$seguro = htmlspecialchars($input, ENT_QUOTES, 'UTF-8');

// Trim y normalizar:
$nombre = trim($this->request->getPost('nombre'));
$nombre = preg_replace('/\s+/', ' ', $nombre);

// Escapar para el contexto:
esc($dado, 'html');  // output HTML
esc($dado, 'url');   // URLs
esc($dado, 'js');    // JavaScript
esc($dado, 'attr');  // atributos

// Regla: validar > sanitizar > escapar

La sanitización limpia el input antes de procesar: FILTER_SANITIZE_EMAIL elimina caracteres inválidos, strip_tags() elimina HTML, trim() elimina espacios. esc() escapa en el output (no en el input). Estrategia: validar (rechazar inválido), sanitizar (limpiar), escapar en el output. Nunca confiar en la sanitización como sustituto de la validación.

Reglas en el Model
class ProdutoModel extends Model
{
    protected $validationRules = [
        'nombre' => 'required|min_length[3]|max_length[200]',
        'precio' => 'required|numeric|greater_than[0]',
        'email' => 'permit_empty|valid_email',
    ];

    protected $validationMessages = [
        'nombre' => [
            'required' => 'O nombre es obligatorio',
            'min_length' => 'Mínimo 3 caracteres',
        ],
        'precio' => [
            'required' => 'Indique o precio',
            'numeric' => 'Deve ser numérico',
        ],
    ];
}

// Validación automática en insert/update:
if (!$model->insert($datos)) {
    $errores = $model->errors();
}

Las reglas en el Model ($validationRules) validan automáticamente en insert()/update(). $validationMessages personaliza los mensajes por campo/regla. Si la validación falla, la operación se aborta y errors() retorna los errores. Centraliza la validación en el Model — los controllers quedan más limpios. permit_empty permite vacío pero valida si está lleno.

Mensajes custom
// Mensajes por campo y regla:
$reglas = [
    'nombre' => [
        'label' => 'Nombre do Producto',
        'rules' => 'required|min_length[3]',
        'errors' => [
            'required' => '{field} es obligatorio',
            'min_length' => '{field} debe ter pelo menos {param} caracteres',
        ],
    ],
    'email' => [
        'label' => 'Email',
        'rules' => 'required|valid_email|is_unique[users.email]',
        'errors' => [
            'is_unique' => 'Este {field} ya está registado',
        ],
    ],
];

if (!$this->validate($reglas)) {
    $errores = $this->validator->getErrors();
}

// Placeholders: {field}, {param}, {value}

Los mensajes custom usan un array con label, rules y errors. Placeholders: {field} (nombre del campo), {param} (parámetro de la regla), {value} (valor enviado). label reemplaza el nombre técnico con texto legible. Esencial para UX — mensajes claros ayudan al usuario a corregir errores.

Validación condicional
// Reglas condicionales (if/else en el controller):
$reglas = ['tipo' => 'required|in_list[fisica,juridica]'];

if ($this->request->getPost('tipo') === 'fisica') {
    $reglas['cpf'] = 'required|exact_length[11]|numeric';
} else {
    $reglas['cnpj'] = 'required|exact_length[14]|numeric';
}

// required_if (CI 4.4+):
$reglas = [
    'país' => 'required',
    'estado' => 'required_if[país,Brasil]',
    'nif' => 'required_if[tipo,juridica]',
];

// required_with / required_without:
$reglas = [
    'telefono' => 'required_with[email]',
    'fax' => 'required_without[telefono]',
];

// Permitir campos opcionales:
$reglas = [
    'website' => 'permit_empty|valid_url',
];

La validación condicional aplica reglas diferentes según el input. required_if[campo,valor] exige un campo si otro tiene un valor específico. required_with/required_without dependen de la presencia de otros campos. permit_empty permite vacío pero valida si está lleno. Para lógica compleja, construir reglas dinámicamente en el controller con if/else.

Reglas disponibles
// Presencia:
required, permit_empty

// Tamaño:
min_length[n], max_length[n], exact_length[n]

// Tipo:
alpha, alpha_numeric, alpha_dash, alpha_numeric_space
numeric, integer, decimal

// Formato:
valid_email, valid_url, valid_ip, valid_json
valid_date, valid_cc_num

// Comparación:
matches[campo], differs[campo]
greater_than[n], less_than[n]
greater_than_equal_to[n], less_than_equal_to[n]

// BD:
is_unique[tabla.campo]
is_not_unique[tabla.campo]

// Otros:
in_list[a,b,c], regex_match[/pattern/]
uploaded[campo], max_size[campo,n]

Las reglas cubren presencia (required), tamaño (min_length), tipo (numeric, valid_email), comparación (matches, greater_than) y BD (is_unique). in_list valida contra una lista de valores. regex_match para patrones custom. uploaded/max_size para archivos. Las reglas se encadenan con |.

Reglas custom
// app/Validation/RegrasCustom.php
namespace App\Validation;

class RegrasCustom
{
    public function mayor_que_zero(
        ?string $valor, string &$error = null
    ): bool {
        if ((float) $valor <= 0) {
            $error = 'O valor debe ser positivo';
            return false;
        }
        return true;
    }

    public function sem_palavroes(
        ?string $valor, string &$error = null
    ): bool {
        $prohibidas = ['spam', 'scam'];
        foreach ($prohibidas as $p) {
            if (str_contains(strtolower($valor), $p)) {
                $error = "Contiene palabra prohibida: $p";
                return false;
            }
        }
        return true;
    }
}

// Registrar en Config/Validation.php:
public array $ruleSets = [
    \App\Validation\RegrasCustom::class,
];

// Uso: 'precio' => 'required|mayor_que_zero'

Las reglas custom son métodos en clases registradas en Config/Validation.php. Reciben el valor y una referencia a $error (mensaje). Retornan true/false. El nombre del método es el nombre de la regla. Permiten validación de lógica de negocio específica. Registrar en el array $ruleSets. Usar como cualquier regla built-in.

Recibir input
// POST:
$nombre = $this->request->getPost('nombre');
$todos = $this->request->getPost();

// GET / query string:
$q = $this->request->getGet('q');
$pagina = $this->request->getGet('page');

// Cualquier método (POST o GET):
$valor = $this->request->getVar('campo');

// JSON body:
$data = $this->request->getJSON(true); // assoc

// Con un filtro PHP:
$email = $this->request->getPost('email', FILTER_SANITIZE_EMAIL);
$edad = $this->request->getPost('edad', FILTER_VALIDATE_INT);

// Múltiples valores (checkboxes):
$colores = $this->request->getPost('colores'); // array

// Verificar existencia:
if ($this->request->getPost('nombre') !== null) { }

getPost() accede a los datos POST, getGet() a la query string, getVar() a cualquier método. getJSON(true) parsea el body JSON como array asociativo. Los filtros PHP (FILTER_SANITIZE_EMAIL) sanitizan el input. Nunca confiar en el input del usuario — validar y escapar siempre. getPost() sin argumento retorna todos los datos.

Validación de arrays
// Validar campos de array (formularios dinámicos):
$reglas = [
    'ítems.*.nombre' => 'required|min_length[2]',
    'ítems.*.cantidad' => 'required|integer|greater_than[0]',
    'ítems.*.precio' => 'required|numeric',
];

// HTML:
// <input name="ítems[0][nombre]">
// <input name="ítems[0][cantidad]">
// <input name="ítems[1][nombre]">

// Validar array simple:
$reglas = [
    'colores' => 'required',
    'colores.*' => 'alpha|max_length[20]',
];

// Mensajes para arrays:
$mensajes = [
    'ítems.*.nombre' => [
        'required' => 'Nombre do item es obligatorio',
    ],
];

// Los errores incluyen el índice:
// "ítems.0.nombre" => "Nombre do item es obligatorio"

El wildcard * valida cada elemento de los arrays — ideal para formularios dinámicos (múltiples ítems). ítems.*.nombre aplica la regla a todos los elementos. Los errores incluyen el índice (ítems.0.nombre). Funciona con arrays simples (colores.*) y anidados. Esencial para formularios de filas repetidas (facturas, carritos).

Segurança e Sessões


10 cards
CSRF Protection
// Activar globalmente (Config/Filters.php):
public array $globals = [
    'before' => ['csrf'],
];

// Configurar (Config/Security.php):
public string $tokenName = 'csrf_token';
public string $headerName = 'X-CSRF-TOKEN';
public int $expires = 7200;
public bool $regenerate = true;

// En el formulario:
<?= csrf_field() ?>

// En AJAX (meta tag en el 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')
    }
});

La CSRF protection se activa globalmente vía el filtro before. csrf_field() genera un token hidden en los formularios. Para AJAX, usar meta tag + el header X-CSRF-TOKEN. regenerate crea un nuevo token en cada request (más seguro). expires define la validez en segundos. Sin CSRF, los formularios son vulnerables a cross-site request forgery.

Autenticación simple
// Controller de login:
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'],
            'nombre' => $user['nombre'],
            'logged_in' => true,
        ]);
        return redirect()->to('/dashboard');
    }

    return redirect()->back()
        ->with('error', 'Credenciales inválidas');
}

// Logout:
public function logout()
{
    session()->destroy();
    return redirect()->to('/login');
}

// Verificar en un filtro:
if (!session('logged_in')) {
    return redirect()->to('/login');
}

Autenticación básica: verificar credenciales con password_verify(), guardar el estado en la sesión. session()->destroy() en el logout. El filtro auth protege las rutas. Nunca guardar la password en la sesión. Para producción, considerar paquetes como codeigniter4/shield (la autenticación oficial de CI4) con remember-me, throttle y verificación de email.

Protección de datos sensibles
// Nunca exponer en logs:
log_message('error', 'Login falhou para: ' . $email);
// NUNCA: log_message('debug', 'Password: ' . $pass);

// .env fuera de git:
// .gitignore:
.env
*.pem
*.key

// No exponer en errores:
// production: mensaje genérico
// development: stack trace (solo local)

// Headers de seguridad:
$response->removeHeader('X-Powered-By');
$response->removeHeader('Server');

// Validar acceso a recursos:
public function download($id)
{
    $fichero = $model->find($id);
    // Verificar el dueño:
    if ($fichero['user_id'] !== session('user_id')) {
        throw PageNotFoundException::forPageNotFound();
    }
    return $this->response->download($path, null);
}

Nunca loguear passwords, tokens o datos sensibles. Mantener .env y claves fuera de git. En producción, los errores son genéricos (sin stack trace). Eliminar headers que revelan la tecnología (X-Powered-By). Validar la autorización en cada acceso a un recurso (IDOR — Insecure Direct Object Reference). Verificar siempre si el usuario es dueño del recurso antes de retornar datos.

Sesiones
$session = session();

// Definir:
$session->set('user_id', 42);
$session->set(['nombre' => 'Ana', 'role' => 'admin']);

// Leer:
$id = $session->get('user_id');
$nombre = session('nombre'); // helper

// Verificar:
if ($session->has('user_id')) { }

// Eliminar:
$session->remove('temp');

// Destruir todo:
$session->destroy();

// Flash (una lectura):
$session->setFlashdata('msg', 'Éxito!');
$msg = $session->getFlashdata('msg');

// Tempdata (expira en N segundos):
$session->setTempdata('código', $code, 300);

Las sesiones de CI4 se acceden vía session() o $this->session. set()/get() para datos persistentes. setFlashdata() para mensajes de una lectura (redirect). setTempdata() expira automáticamente. Configuración en Config/App.php (driver, cookie, expiración). Driver por defecto: file. Alternativas: database, redis, memcached.

Honeypot y anti-spam
// Activar honeypot (Config/Filters.php):
public array $globals = [
    'before' => ['honeypot'],
    'after' => ['honeypot'],
];

// Configurar (Config/Honeypot.php):
public bool $hidden = true;
public string $label = 'Preencha este campo';
public string $name = 'website';
public string $template = '<label>{label}</label><input type="text" name="{name}" value="">';

// Cómo funciona:
// 1. Campo invisible añadido al form
// 2. Los bots lo llenan (no ven CSS)
// 3. Si se llena -> request rechazado

// Rate limiting manual:
$throttle = \Config\Services::throttler();
if ($throttle->check('login', 5, MINUTE) === false) {
    return redirect()->back()
        ->with('error', 'Tentativas demais. Aguarde.');
}

El honeypot añade un campo invisible al formulario — los bots lo llenan (no ven CSS), los humanos no. Si se llena, el request se rechaza silenciosamente. Cero impacto en UX. Throttler limita los intentos por IP/acción (rate limiting). Combinar honeypot + CSRF + throttle para una protección robusta. Una alternativa sin fricción al CAPTCHA para los usuarios.

Seguridad de cookies
// Configurar la sesión (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;

// Cookie seguro (Config/Cookie.php):
public string $prefix = '';
public int $expires = 0;
public string $path = '/';
public string $domain = '';
public bool $secure = true;    // solo HTTPS
public bool $httponly = true;  // sin acceso JS
public string $samesite = 'Lax'; // anti-CSRF

// Regenerar la sesión (tras login):
session()->regenerate(true);

// Definir cookie manual:
$this->response->setCookie('preferencia', $valor, [
    'httponly' => true,
    'secure' => true,
]);

Las cookies de sesión deben ser secure (solo HTTPS), httponly (inaccesible vía JavaScript) y samesite = Lax (anti-CSRF). sessionRegenerateDestroy destruye la sesión antigua al regenerar. Regenerar la sesión tras login previene la session fixation. sessionMatchIP añade seguridad pero puede romperse en redes móviles. Una expiración de 2h es razonable.

Encriptación y hashing
// Encriptación simétrica:
$encrypter = \Config\Services::encrypter();
$cifrada = $encrypter->encrypt('datos secretos');
$original = $encrypter->decrypt($cifrada);

// Configurar la clave (Config/Encryption.php):
public string $key = 'su-clave-32-bytes-aqui!';
public string $driver = 'OpenSSL';

// Hash de passwords (NUNCA encrypt):
$hash = password_hash($password, PASSWORD_DEFAULT);

// Verificar la password:
if (password_verify($input, $hash)) {
    // password correcta
}

// Verificar si necesita rehash:
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
    $novoHash = password_hash($password, PASSWORD_DEFAULT);
}

// HMAC:
$assinatura = hash_hmac('sha256', $datos, $clave);

La encriptación (encrypter) es reversible — para datos que necesitas leer después. El hash (password_hash) es irreversible — para passwords. Nunca encriptar passwords. password_verify() compara el hash sin exponer la password. password_needs_rehash() actualiza el hash cuando el algoritmo cambia. La clave de encriptación debe estar en el .env, nunca en el código.

Content Security Policy
// Activar (Config/App.php):
public bool $CSPEnabled = true;

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

// Headers de seguridad adicionales:
// Config/Filters.php -> 'secureheaders'
public array $globals = [
    'after' => ['secureheaders'],
];

// Headers añadidos:
// X-Content-Type-Options: nosniff
// X-Frame-Options: SAMEORIGIN
// X-XSS-Protection: 1; mode=block

La CSP controla desde dónde el browser puede cargar recursos — previene XSS e inyección de scripts. defaultSrc = 'self' permite solo recursos del mismo dominio. Whitelists para CDNs y fonts. secureheaders añade headers de seguridad (nosniff, frame options). frameAncestors = 'none' previene el clickjacking. Esencial para aplicaciones con datos sensibles.

Filtros (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', 'Haz login primero');
        }
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
    {
        // Post-procesamiento (opcional)
    }
}

// Registrar (Config/Filters.php):
public array $aliases = [
    'auth' => \App\Filters\AuthFilter::class,
];

// Aplicar:
$routes->get('admin', 'Admin::index', ['filter' => 'auth']);

Los filtros implementan FilterInterface con before() y after(). before() puede retornar un redirect/response para abortar. Registrarlos en Config/Filters.php con un alias. Aplicar por ruta, grupo o globalmente. Ideales para autenticación, autorización, logging, rate limiting. $arguments recibe los parámetros del filtro (filter:auth[admin]).

Validación de permisos
// Filtro de roles:
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 permiso');
        }
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {}
}

// Registrar:
'role' => \App\Filters\RoleFilter::class,

// Aplicar con parámetros:
$routes->group('admin', ['filter' => 'role:admin,superadmin'],
    function ($routes) {
        $routes->get('users', 'Admin\Users::index');
        $routes->delete('users/(:num)', 'Admin\Users::delete/$1');
    }
);

El filtro de roles verifica permisos vía argumentos (role:admin,superadmin). $arguments recibe la lista de roles permitidos. Aplicarlo por grupo protege todas las rutas internas. Combinar con el filtro auth (login primero, role después). Para RBAC completo, considerar codeigniter4/shield con grupos y permisos. Nunca confiar solo en esconder links — validar en el servidor.

Recursos Avançados


10 cards
Cache
$cache = \Config\Services::cache();

// Guardar (clave, datos, segundos):
$cache->save('productos_populares', $datos, 3600);

// Leer:
$valor = $cache->get('productos_populares');
if ($valor === null) {
    $valor = $model->getPopulares();
    $cache->save('productos_populares', $valor, 3600);
}

// Eliminar:
$cache->delete('productos_populares');

// Limpiar todo:
$cache->clean();

// Cache de página (en el controller):
public function index()
{
    $this->cachePage(300); // 5 minutos
    // ...
}

// Configurar el driver (Config/Cache.php):
public string $handler = 'file';
// Opciones: file, memcached, redis, predis, wincache

El cache reduce las queries repetidas y el tiempo de respuesta. save() almacena con un TTL (segundos). get() retorna null si expiró. cachePage() cachea la respuesta entera. Drivers: file (default, sin config), redis/memcached (producción, compartido). Invalidar el cache al actualizar datos. Patrón: verificar cache → en miss, query + save.

Email
$email = \Config\Services::email();

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

// Enviar:
$email->setFrom('noreply@site.com', 'Mi App');
$email->setTo('user@mail.com');
$email->setCC('admin@site.com');
$email->setSubject('Confirmación de Registro');
$email->setMessage('<h1>Bienvenido!</h1>');

// HTML:
$email->setMailType('html');

// Adjunto:
$email->attach('/path/to/fichero.pdf');

if ($email->send()) {
    // enviado
} else {
    log_message('error', $email->printDebugger());
}

El servicio de email soporta SMTP, sendmail y mail(). Configuración en Config/Email.php o el .env. setMailType('html') para emails HTML. attach() añade adjuntos. printDebugger() muestra errores de envío. Para Gmail, usar una App Password (no la password de la cuenta). En producción, considerar servicios como Mailgun/SendGrid vía SMTP.

Time y fechas
use CodeIgniter\I18n\Time;

// Ahora:
$ahora = Time::now();
$ahora = Time::now('Europe/Lisbon', 'pt_PT');

// Crear:
$data = Time::create(2024, 12, 25, 10, 30);
$data = Time::parse('2024-12-25 10:30:00');

// Formatear:
echo $ahora->format('d/m/Y H:i');    // 25/12/2024 10:30
echo $ahora->toDateString();          // 2024-12-25
echo $ahora->humanize();              // "hace 2 horas"

// Manipular:
$mañana = $ahora->addDays(1);
$ayer = $ahora->subMonths(2);
$inicio = $ahora->startOfMonth();

// Comparar:
if ($ahora->isAfter($prazo)) { }
$diferencia = $ahora->difference($outra);
echo $diferencia->getDays(); // días

// Timezone:
$lisboa = $ahora->setTimezone('Europe/Lisbon');

Time es la clase de fechas de CI4 (envuelve DateTime/estilo Carbon). Time::now() con timezone y locale. format() formatea, humanize() retorna texto legible ("hace 2 horas"). Métodos fluent: addDays(), subMonths(), startOfMonth(). difference() calcula un intervalo. Usar siempre un timezone explícito. Configurar el default en Config/App.php.

Eventos y hooks
// app/Config/Events.php
use CodeIgniter\Events\Events;

// Escuchar un evento:
Events::on('DBQuery', function ($query) {
    log_message('debug', (string) $query);
});

Events::on('pre_system', function () {
    // Antes de cualquier controller
});

// Disparar un evento custom:
Events::trigger('pedido_creado', $pedidoId, $total);

// Escuchar uno custom:
Events::on('pedido_creado', function ($id, $total) {
    // Enviar email de confirmación
    // Actualizar stock
    // Notificar admin
});

// Prioridad (menor = primero):
Events::on('pedido_creado', $fn1, 10);
Events::on('pedido_creado', $fn2, 50);

// Eliminar un listener:
Events::removeListener('pedido_creado', $fn1);

Los eventos permiten desacoplar la lógica — disparar sin saber quién escucha. Events::on() registra un listener, Events::trigger() lo dispara. La prioridad controla el orden de ejecución. Eventos built-in: pre_system, post_system, DBQuery. Ideales para notificaciones, logging, analytics sin acoplar al controller. Similar a observers/pub-sub.

Servicios (DI)
// Servicios built-in:
$request = service('request');
$session = service('session');
$cache = service('cache');
$logger = service('logger');
$db = \Config\Database::connect();

// Servicio personalizado (Config/Services.php):
public static function pagamento(bool $getShared = true)
{
    if ($getShared) {
        return static::getSharedInstance('pagamento');
    }
    return new \App\Libraries\PagamentoService();
}

// Uso:
$pagamento = service('pagamento');
$pagamento->cobrar(49.99);

// Inyección en controllers:
class Tienda extends BaseController
{
    protected $pagamento;

    public function initController($req, $res, $log)
    {
        parent::initController($req, $res, $log);
        $this->pagamento = service('pagamento');
    }
}

Los servicios son singletons gestionados por el container DI de CI4. service('nombre') obtiene una instancia compartida. Servicios custom en Config/Services.php. $getShared controla singleton vs nueva instancia. Ideales para bibliotecas, gateways de pagado, APIs externas. Facilitan el testing (mock de servicios). Todos los servicios core son accesibles vía service().

HTTP Client (CURLRequest)
$client = \Config\Services::curlrequest();

// GET:
$response = $client->get('https://api.ejemplo.com/datos', [
    'headers' => ['Authorization' => 'Bearer ' . $token],
    'timeout' => 10,
]);

// POST JSON:
$response = $client->post('https://api.ejemplo.com/users', [
    'json' => ['nombre' => 'Ana', 'email' => 'ana@mail.com'],
    'headers' => ['Accept' => 'application/json'],
]);

// Respuesta:
$statusCode = $response->getStatusCode();
$body = $response->getBody();
$datos = json_decode($body, true);

// Con autenticación:
$response = $client->request('GET', $url, [
    'auth' => ['user', 'pass'],
]);

// Manejo de errores:
try {
    $response = $client->get($url);
} catch (\CodeIgniter\HTTP\Exceptions\HTTPException $e) {
    log_message('error', 'API falhou: ' . $e->getMessage());
}

CURLRequest es el HTTP client built-in de CI4 (basado en CURL). Soporta GET, POST, PUT, DELETE con headers, JSON, auth y timeout. getStatusCode() y getBody() para procesar la respuesta. La opción json serializa y define el Content-Type automáticamente. Envolver en try/catch para errores de red. Ideal para consumir APIs externas.

Migrations
// Crear una 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],
        'nombre' => ['type' => 'VARCHAR', 'constraint' => 200],
        'precio' => ['type' => 'DECIMAL', 'constraint' => '10,2'],
        'activo' => ['type' => 'TINYINT', 'default' => 1],
        'created_at' => ['type' => 'DATETIME', 'null' => true],
        'updated_at' => ['type' => 'DATETIME', 'null' => true],
    ]);
    $this->forge->addKey('id', true);
    $this->forge->addKey('nombre');
    $this->forge->createTable('productos');
}

public function down()
{
    $this->forge->dropTable('productos');
}

// Ejecutar:
php spark migrate
php spark migrate:rollback

Las migrations versionan la estructura de la BD — up() crea, down() revierte. forge es el schema builder: addField(), addKey(), createTable(). php spark migrate aplica las pendientes, migrate:rollback revierte. Esencial para el trabajo en equipo y un deploy consistente. Nunca alterar tablas manualmente en producción.

Logging
// Niveles de log:
log_message('emergency', 'Sistema em baixo');
log_message('alert', 'Acción inmediata necesaria');
log_message('critical', 'Error crítico');
log_message('error', 'Error na operación');
log_message('warning', 'Algo inesperado');
log_message('notice', 'Evento normal significativo');
log_message('info', 'Informação geral');
log_message('debug', 'Datos de depuración');

// Configurar (Config/Logger.php):
public $threshold = 4; // 1-9 (4 = error+)
// 9 = todo, 1 = solo emergency

// Logs en: writable/logs/log-2024-01-15.log

// Log con contexto:
log_message('error', 'Fallo no pagamento: {id}', [
    'id' => $pedidoId,
]);

// Handler custom (email, Slack, etc):
// Config/Logger.php -> $handlers

log_message() registra eventos con un nivel de severidad (PSR-3). $threshold controla qué niveles se graban (9 = todo, 4 = error+). Logs en writable/logs/ con la fecha en el nombre. Placeholders ({id}) con contexto. En producción, threshold 4 (solo errores). En desarrollo, 9 (todo). Los handlers custom pueden enviar a email, Slack o servicios externos.

Seeders
// Crear un 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()
    {
        $datos = [
            ['nombre' => 'TV', 'precio' => 500],
            ['nombre' => 'Rádio', 'precio' => 80],
            ['nombre' => 'PC', 'precio' => 1200],
        ];
        $this->db->table('productos')->insertBatch($datos);

        // Llamar a otro seeder:
        $this->call('CategoriasSeeder');
    }
}

// Ejecutar:
php spark db:seed ProdutosSeeder

// Con Faker:
$faker = \Faker\Factory::create();
$nombre = $faker->name;

Los seeders pueblan la BD con datos iniciales o de prueba. insertBatch() inserta múltiples filas. $this->call() encadena seeders. php spark db:seed lo ejecuta. Faker (vía devkit) genera datos realistas. Ideales para datos de referencia (categorías, países) y desarrollo. Separar los seeders de producción y desarrollo.

Localización (i18n)
// app/Language/pt/App.php
return [
    'bienvenido' => 'Bienvenido, {0}!',
    'ítems' => '{0, number} ítems no carrito',
    'error' => [
        'noEncontrado' => 'Página no encontrada',
        'semPermissao' => 'Acesso negado',
    ],
];

// app/Language/en/App.php
return [
    'bienvenido' => 'Welcome, {0}!',
];

// Uso:
echo lang('App.bienvenido', [$nombre]);
echo lang('App.error.noEncontrado');

// Configurar el locale:
// Config/App.php:
public string $defaultLocale = 'pt';
public bool $negotiateLocale = true;
public array $supportedLocales = ['pt', 'en', 'es'];

// Cambiar en runtime:
service('request')->setLocale('en');

Las traducciones están en app/Language/{locale}/ como arrays PHP. lang('App.clave') retorna la traducción. Los placeholders ({0}) reemplazan parámetros. negotiateLocale detecta el idioma del browser. supportedLocales limita los idiomas disponibles. Organizar por archivo (App, Validation, Errors). Esencial para aplicaciones multi-idioma.

API RESTful


10 cards
Controller RESTful
<?php
namespace App\Controllers;

use CodeIgniter\RESTful\ResourceController;

class ApiProdutos extends ResourceController
{
    protected $modelName = 'App\Models\ProdutoModel';
    protected $format = 'json';

    // GET /api/productos
    public function index()
    {
        return $this->respond($this->model->findAll());
    }

    // GET /api/productos/1
    public function show($id = null)
    {
        $producto = $this->model->find($id);
        if (!$producto) {
            return $this->failNotFound('Producto no encontrado');
        }
        return $this->respond($producto);
    }

    // POST /api/productos
    public function create()
    {
        $datos = $this->request->getJSON(true);
        $id = $this->model->insert($datos);
        $producto = $this->model->find($id);
        return $this->respondCreated($producto);
    }

    // PUT /api/productos/1
    public function update($id = null)
    {
        $datos = $this->request->getJSON(true);
        $this->model->update($id, $datos);
        return $this->respond($this->model->find($id));
    }

    // DELETE /api/productos/1
    public function delete($id = null)
    {
        $this->model->delete($id);
        return $this->respondDeleted(['id' => $id]);
    }
}

ResourceController proporciona métodos REST estandarizados (index, show, create, update, delete). respond() retorna 200, respondCreated() retorna 201, failNotFound() retorna 404. $format = 'json' define el Content-Type automáticamente. Las rutas con $routes->resource('api/productos') mapean todos los verbos HTTP.

Paginación en API
public function index()
{
    $pagina = $this->request->getGet('page') ?? 1;
    $porPagina = $this->request->getGet('per_page') ?? 20;

    $productos = $this->model
        ->orderBy('created_at', 'DESC')
        ->paginate($porPagina, 'default', $pagina);

    $pager = $this->model->pager;

    return $this->respond([
        'data' => $productos,
        'meta' => [
            'total'       => $pager->getTotal(),
            'por_pagina'  => $porPagina,
            'pagina'      => $pager->getCurrentPage(),
            'total_paginas' => $pager->getPageCount(),
        ],
    ]);
}

paginate() limita los resultados y calcula los offsets automáticamente. Parámetros: ítems por página, grupo y numero de página. El objeto $pager proporciona metadatos: getTotal() (total de registros), getCurrentPage(), getPageCount(). Incluir metadatos en la respuesta permite al cliente construir la navegación. Parámetros vía query string (?page=2&per_page=50).

CORS en 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');

    // Responder al preflight OPTIONS:
    if ($request->getMethod() === 'options') {
        $response = service('response');
        $response->setStatusCode(200);
        return $response;
    }
}

// Aplicar a un grupo:
$routes->group('api', ['filter' => 'cors'], function ($routes) {
    $routes->resource('productos');
});

CORS permite que navegadores de otros dominios accedan a la API. Los headers Access-Control-Allow-* definen los orígenes, métodos y headers permitidos. Los requests OPTIONS (preflight) deben retornar un 200 vacío. En producción, sustituir * por dominios específicos. Aplicar como filtro en el grupo api para no afectar las rutas web.

Resource Routes
// Config/Routes.php:

// Mapea un CRUD completo:
$routes->resource('api/productos', [
    'controller' => 'ApiProdutos',
]);
// Genera:
// GET    /api/productos       → index()
// GET    /api/productos/(:num) → show($1)
// POST   /api/productos       → create()
// PUT    /api/productos/(:num) → update($1)
// DELETE /api/productos/(:num) → delete($1)

// Solo algunos métodos:
$routes->resource('api/users', [
    'only' => ['index', 'show'],
]);

// Excluir métodos:
$routes->resource('api/posts', [
    'except' => ['delete'],
]);

// Websafe (form en vez de PUT/DELETE):
$routes->resource('api/ítems', [
    'websafe' => true,
]);
// PUT → POST con _method=PUT

$routes->resource() genera las 5 rutas REST automáticamente. only limita los métodos disponibles, except excluye específicos. websafe convierte PUT/DELETE en POST con un campo _method (para formularios HTML que solo soportan GET/POST). Alternativa: $routes->presenter() incluye new() y edit() para formularios.

Filtros y búsqueda
public function index()
{
    $builder = $this->model;

    // Búsqueda por texto:
    if ($búsqueda = $this->request->getGet('q')) {
        $builder = $builder->like('nombre', $búsqueda);
    }

    // Filtro exacto:
    if ($status = $this->request->getGet('status')) {
        $builder = $builder->where('status', $status);
    }

    // Filtro de rango:
    if ($min = $this->request->getGet('precio_min')) {
        $builder = $builder->where('precio >=', $min);
    }

    // Ordenación:
    $ordenar = $this->request->getGet('sort') ?? 'created_at';
    $dirección = $this->request->getGet('dir') ?? 'DESC';
    $builder = $builder->orderBy($ordenar, $dirección);

    return $this->respond($builder->paginate(20));
}

Los filtros son opcionales — se aplican solo si el parámetro existe en la query string. like() para búsqueda parcial, where() para igualdad exacta. Encadenar condiciones en el $builder permite combinaciones dinámicas. Validar los nombres de columna para ordenación (whitelist) evita SQL injection. Patrón RESTful: ?q=termo&status=activo&sort=nombre&dir=ASC.

Transformers / Formateo
// Formatear la salida de la API:
private function formatarProduto(array $producto): array
{
    return [
        'id'         => (int) $producto['id'],
        'nombre'       => $producto['nombre'],
        'precio'      => number_format($producto['precio'], 2, '.', ''),
        'categoria'  => $producto['categoria_nombre'] ?? null,
        'links'      => [
            'self' => base_url('api/productos/' . $producto['id']),
        ],
    ];
}

public function index()
{
    $productos = $this->model->findAll();
    $datos = array_map([$this, 'formatarProduto'], $productos);
    return $this->respond($datos);
}

// Con relaciones:
public function show($id = null)
{
    $producto = $this->model
        ->select('productos.*, categorias.nombre as categoria_nombre')
        ->join('categorias', 'categorias.id = productos.categoria_id', 'left')
        ->find($id);

    return $this->respond($this->formatarProduto($producto));
}

Los transformers formatean los datos antes de enviarlos — nunca exponer la estructura interna de la base de datos. Convertir tipos ((int)), formatear valores (number_format) e incluir links HATEOAS. array_map() aplica la transformación a colecciones. Mantener los campos consistentes entre index y show. Para proyectos mayores, crear clases Transformer dedicadas.

Respuestas JSON
// Respuestas con status codes:
return $this->respond($datos);           // 200
return $this->respondCreated($datos);    // 201
return $this->respondDeleted($datos);    // 200

// Errores:
return $this->fail('Error genérico');              // 400
return $this->failUnauthorized('Token inválido'); // 401
return $this->failForbidden('Sem permiso');     // 403
return $this->failNotFound('No encontrado');     // 404
return $this->failValidationError($errores);        // 422
return $this->failServerError('Error interno');    // 500

// Respuesta manual:
return $this->response
    ->setStatusCode(202)
    ->setJSON(['status' => 'aceite']);

// Formato de la respuesta:
// { "status": 200, "error": null, "messages": [], "data": {...} }

Los métodos respond*() y fail*() estandarizan la estructura JSON con status, error, messages y data. Cada método define el HTTP status code correcto automáticamente. failValidationError() acepta un array de errores de validación. Para respuestas personalizadas, usar setStatusCode() + setJSON() directamente.

Autenticación con token
// Filtro de autenticación (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 inválido']);
        }

        // Guardar el user para uso posterior:
        $request->user = $user;
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
    {
    }
}

Los filtros interceptan los requests antes del controller. El token viene en el header Authorization: Bearer xxx. Almacenar el hash (no el token plano) en la base de datos. Retornar una respuesta 401 directamente en before() bloquea el acceso. Registrar en Config/Filters.php como alias y aplicar a las rutas: $routes->group('api', ['filter' => 'authapi']).

Validación en API
public function create()
{
    $datos = $this->request->getJSON(true);

    $reglas = [
        'nombre'  => 'required|min_length[3]|max_length[100]',
        'email' => 'required|valid_email|is_unique[users.email]',
        'edad' => 'permit_empty|integer|greater_than[0]',
    ];

    if (!$this->validate($reglas)) {
        return $this->failValidationError(
            $this->validator->getErrors()
        );
    }

    $id = $this->model->insert($datos);
    return $this->respondCreated(
        $this->model->find($id),
        'Recurso creado'
    );
}

En APIs, $this->request->getJSON(true) convierte el body JSON en un array asociativo. La validación usa las mismas reglas que los formularios. failValidationError() retorna 422 con los errores detallados en JSON. getErrors() retorna un array de mensajes por campo. El segundo parámetro de respondCreated() es el mensaje de éxito.

Rate limiting
// Filtro simple de rate limit (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;
    $limite = 60; // requests por minuto

    if ($hits >= $limite) {
        return service('response')
            ->setStatusCode(429)
            ->setHeader('Retry-After', '60')
            ->setJSON(['error' => 'Limite excedido']);
    }

    $cache->save($key, $hits + 1, 60);
}

// Aplicar globalmente (Config/Filters.php):
public array $globals = [
    'before' => ['ratelimit'],
];

El rate limiting protege las APIs contra el abuso. Usa cache para contar requests por IP con un TTL de 60 segundos. Al exceder el límite, retorna 429 Too Many Requests con un header Retry-After. Aplicar como filtro global o por grupo de rutas. Para producción, preferir Redis (contador compartido entre servidores). Headers informativos: X-RateLimit-Limit, X-RateLimit-Remaining.

CLI e Ferramentas


11 cards
Comando Spark
// spark es la CLI de CodeIgniter 4:
php spark

// Comandos built-in:
php spark serve          // Servidor de desarrollo
php spark routes         // Listar todas las rutas
php spark db:seed        // Ejecutar seeders
php spark migrate        // Ejecutar migraciones
php spark migrate:rollback // Revertir migraciones
php spark make:controller Nombre  // Generar controller
php spark make:model Nombre       // Generar model
php spark make:migration Nombre   // Generar migración
php spark make:command Nombre     // Generar comando
php spark make:filter Nombre      // Generar filtro
php spark make:seeder Nombre      // Generar seeder
php spark cache:clear    // Limpiar cache
php spark db:table       // Listar tablas

php spark es la interfaz CLI de CI4 (equivalente al artisan de Laravel). serve inicia un servidor en el puerto 8080. routes muestra todas las rutas registradas con verbos y handlers. make:* genera boilerplate con el namespace correcto. migrate y db:seed gestionan la base de datos. Todos los comandos aceptan --help para documentación.

Seeders y datos
// Crear: 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()
    {
        $datos = [
            [
                'nombre' => 'Producto A',
                'precio' => 29.90,
                'activo' => 1,
                'created_at' => date('Y-m-d H:i:s'),
            ],
            [
                'nombre' => 'Producto B',
                'precio' => 49.90,
                'activo' => 1,
                'created_at' => date('Y-m-d H:i:s'),
            ],
        ];

        $this->db->table('productos')->insertBatch($datos);

        // Llamar a otro seeder:
        $this->call('CategoriasSeeder');
    }
}

// Ejecutar:
// php spark db:seed ProdutosSeeder

Los seeders pueblan la base de datos con datos iniciales. insertBatch() inserta múltiples filas eficientemente. $this->call() encadena seeders (orden de ejecución). $this->db da acceso a la conexión. Para datos de prueba, usar Faker (integrado en CI4). Ejecutar con php spark db:seed NomeSeeder. Registrar los seeders en DatabaseSeeder para ejecutarlos en conjunto.

Configuración de entornos
// .env (raíz del proyecto):
# CI_ENVIRONMENT = production | development | testing
CI_ENVIRONMENT = development

# Base URL:
app.baseURL = 'http://localhost:8080/'

# Database:
database.default.hostname = localhost
database.default.database = mi_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 = contrasena_app
email.SMTPPort = 587
email.SMTPCrypto = tls

# En producción:
# CI_ENVIRONMENT = production
# (desactiva la debugbar, muestra una página de error genérica)

El fichero .env configura el entorno sin alterar el código. CI_ENVIRONMENT controla el debug: development muestra errores detallados + debugbar, production oculta los detalles. Las configs usan la notación grupo.propiedad. Nunca commitear .env — usar .env.example como plantilla. En producción, definir baseURL, database y email con valores reales.

Crear comando custom
<?php
namespace App\Commands;

use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;

class GerarRelatorio extends BaseCommand
{
    protected $group = 'App';
    protected $name = 'informe:generar';
    protected $description = 'Genera informe mensual de ventas';
    protected $usage = 'informe:generar [mes] [anio]';
    protected $arguments = [
        'mes' => 'Mes (1-12)',
        'anio' => 'Anio (4 dígitos)',
    ];
    protected $options = [
        '--formato' => 'Formato: csv, pdf, excel',
    ];

    public function run(array $params)
    {
        $mes = $params[0] ?? date('m');
        $anio = $params[1] ?? date('Y');
        $formato = CLI::getOption('formato') ?? 'csv';

        CLI::write("Generando informe: {$mes}/{$anio}");
        CLI::write("Formato: {$formato}", 'green');

        // Lógica del reporte...
        $total = model('VendaModel')
            ->where('MONTH(created_at)', $mes)
            ->where('YEAR(created_at)', $anio)
            ->selectSum('total')
            ->first();

        CLI::write("Total: R$ {$total['total']}", 'yellow');
        CLI::newLine();
    }
}

Los comandos custom extienden BaseCommand en App/Commands/. $name define cómo invocarlo (php spark informe:generar 06 2024). $arguments son posicionales, $options usan --flag. CLI::write() imprime con color (green, yellow, red). CLI::getOption() lee flags. $group organiza la salida de php spark.

Faker / Datos de prueba
// En Seeders (Faker integrado):
use Faker\Factory;

public function run()
{
    $faker = Factory::create('pt_BR');

    $datos = [];
    for ($i = 0; $i < 50; $i++) {
        $datos[] = [
            'nombre'  => $faker->name(),
            'email' => $faker->unique()->safeEmail(),
            'telefono' => $faker->phoneNumber(),
            'direccion' => $faker->address(),
            'empresa' => $faker->company(),
            'created_at' => $faker->dateTimeThisYear()->format('Y-m-d H:i:s'),
        ];
    }

    $this->db->table('clientes')->insertBatch($datos);
}

// Factories (CI4 built-in):
use CodeIgniter\Test\Fabricator;
use App\Models\UserModel;

$users = fabricate(UserModel::class, 10, [
    'activo' => 1,
]);

Faker genera datos realistas para pruebas y desarrollo. El locale pt_BR para datos en portugués. unique() evita duplicados. insertBatch() para rendimiento con muchos registros. fabricate() es el helper de CI4 que combina Factory + Faker + Model. Nunca usar seeders con Faker en producción — solo para desarrollo y pruebas.

Deploy y optimización
// Checklist de producción:

// 1. Entorno:
CI_ENVIRONMENT = production

// 2. Cache de configuraciones:
php spark cache:clear

// 3. Optimizar el autoloader (composer):
composer install --no-dev --optimize-autoloader

// 4. Estructura en el servidor:
// /public_html/  ← apuntar el document root aquí
//   index.php
//   assets/
// /app/          ← fuera del document root
// /vendor/
// /writable/     ← permisos 755

// 5. index.php (apuntar a ../):
// $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. Seguridad:
// - Desactivar el listado de directorios
// - Proteger /writable y /app
// - HTTPS obligatorio

En producción, el document root debe apuntar a /public/ — el resto queda inaccesible vía web. CI_ENVIRONMENT = production desactiva el debugging. composer install --no-dev excluye las dependencias de desarrollo. .htaccess reescribe las URLs hacia el front controller. Proteger /writable y /app con reglas de acceso. Usar HTTPS y headers de seguridad.

CLI input/output
use CodeIgniter\CLI\CLI;

// Output con colores:
CLI::write('Éxito!', 'green');
CLI::write('Atención!', 'yellow');
CLI::write('Error!', 'red');
CLI::error('Fallo crítica');  // rojo + stderr

// Input del usuario:
$nombre = CLI::prompt('Qual o su nombre?');
$email = CLI::prompt('Email:', null, 'required|valid_email');

// Elección entre opciones:
$tipo = CLI::prompt('Tipo:', ['admin', 'user', 'guest']);
// Muestra: [0] admin [1] user [2] guest

// Confirmación:
if (CLI::prompt('Continuar?', ['y', 'n']) === 'y') {
    // proseguir
}

// Formateo:
CLI::newLine();
CLI::write(str_repeat('-', 40));
CLI::table($datos, ['ID', 'Nombre', 'Email']);

// Progreso:
$progress = new \CodeIgniter\CLI\Progressbar(100);
for ($i = 0; $i <= 100; $i++) {
    $progress->update($i);
}

La clase CLI proporciona I/O interactivo para comandos. prompt() acepta validación inline (reglas de Validation). CLI::table() formatea arrays en una tabla ASCII. Colores: green, yellow, red, blue, magenta, cyan. CLI::error() escribe en stderr. Progressbar muestra el progreso en bucles anchos. El input con validación repite la pregunta hasta un valor válido.

Testing
// tests/unit/CalculadoraTest.php:
namespace Tests\Unit;

use CodeIgniter\Test\CIUnitTestCase;

class CalculadoraTest extends CIUnitTestCase
{
    public function testSomar()
    {
        $resultado = 2 + 3;
        $this->assertEquals(5, $resultado);
    }

    public function testDivisaoPorZero()
    {
        $this->expectException(\DivisionByZeroError::class);
        $resultado = 1 / 0;
    }
}

// Test de feature (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/productos');
        $result->assertOK();
        $result->assertJSONFragment(['nombre' => 'Producto A']);
    }
}

// Ejecutar: php spark test
// O: vendor/bin/phpunit

CI4 usa PHPUnit integrado. CIUnitTestCase es la clase base con helpers del framework. FeatureTestTrait permite testear rutas HTTP sin servidor (get(), post()). Assertions: assertOK() (200), assertJSONFragment(), assertStatus(). expectException() para errores. Configurar en phpunit.xml. Ejecutar con php spark test.

Estructura del proyecto
// Estructura CI4:
project/
├── app/
│   ├── Config/         // Configuraciones
│   │   ├── Routes.php  // Rutas
│   │   ├── Database.php
│   │   ├── Validation.php
│   │   └── Filters.php
│   ├── Controllers/    // Controllers
│   ├── Models/         // Models
│   ├── Views/          // Templates
│   ├── Database/
│   │   ├── Migrations/ // Migraciones
│   │   └── Seeds/      // Seeders
│   ├── Filters/        // Filtros (middleware)
│   ├── Libraries/      // Clases auxiliares
│   └── Helpers/        // Funciones helper
├── public/             // Document root
│   ├── index.php       // Front controller
│   └── assets/         // CSS, JS, imágenes
├── writable/           // Logs, cache, uploads
├── tests/              // Tests
├── vendor/             // Dependencias Composer
├── spark               // CLI
└── .env                // Config de entorno

La estructura sigue MVC con separación clara. app/ contiene toda la lógica. public/ es el único directorio accesible vía web (front controller index.php). writable/ almacena logs, cache y uploads (necesita permiso de escritura). Filters/ son middleware. spark es la CLI. Las configs en app/Config/ — una clase por aspecto del sistema.

Migraciones
// Crear: php spark make:migration crear_productos
// App/Database/Migrations/2024-01-15-120000_crear_productos.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],
            'nombre' => ['type' => 'VARCHAR', 'constraint' => 200],
            'precio' => ['type' => 'DECIMAL', 'constraint' => '10,2'],
            'activo' => ['type' => 'TINYINT', 'default' => 1],
            'created_at' => ['type' => 'DATETIME', 'null' => true],
            'updated_at' => ['type' => 'DATETIME', 'null' => true],
        ]);
        $this->forge->addKey('id', true);
        $this->forge->addKey('activo');
        $this->forge->createTable('productos');
    }

    public function down()
    {
        $this->forge->dropTable('productos', true);
    }
}

// Ejecutar:
// php spark migrate
// php spark migrate:rollback
// php spark migrate:refresh (rollback + migrate)

Las migraciones versionan el schema de la base de datos. $this->forge es el builder de DDL. addField() define columnas con tipo, constraint y default. addKey() crea índices (segundo parámetro true = primary key). up() aplica, down() revierte. Los ficheros se ordenan por timestamp. migrate:refresh recrea todo desde cero.

Debugging y profiling
// Debugbar (activar en .env):
// CI_ENVIRONMENT = development

// La toolbar de debug muestra:
// - Queries SQL con tiempo
// - Rutas correspondidas
// - Views renderizadas
// - Headers del request
// - Logs y timeline

// Logging manual:
log_message('error', 'Fallo ao procesar pedido #' . $id);
log_message('info', 'User login: ' . $email);
log_message('debug', 'Datos: ' . print_r($datos, true));

// dd() y d():
dd($variable);  // dump + die
d($variable);   // dump (continúa)

// Benchmark:
$benchmark = service('timer');
$benchmark->start('query');
$datos = $model->findAll();
$benchmark->stop('query');
echo $benchmark->getElapsedTime('query');

// Ver la última query SQL:
echo $model->getLastQuery();

En modo development, la Debugbar muestra queries, tiempo, rutas y views automáticamente. log_message() graba en writable/logs/ con niveles (error, info, debug). dd() hace dump y detiene la ejecución. service('timer') mide el rendimiento de fragmentos de código. getLastQuery() muestra el SQL generado por el Query Builder para debugging.