Índice del curso

CodeIgniter · el framework ligero

Cuarta vuelta al dominio Pedidos: lo esencial, sin ceremonias — rutas declarativas, vistas PHP nativas con esc(), modelos directos sobre MariaDB, filtros, API REST con token propio y PHPUnit, con el eco permanente del manual Laravel que acabas de cerrar.

44 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil CodeIgniter 4.7 · PHP 8.2+
44
Capítulos
150+
Ejemplos de código
3
Niveles: básico a experto
3
Prerrequisitos: PHP · MVC · Eloquent
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada pieza del framework tiene un gemelo en los manuales PHP, MVC, Eloquent y Laravel que ya dominas. Aquí aprenderás el mismo patrón con menos capas: el framework más directo del ecosistema.

1 · Por qué CodeIgniter y qué resuelve

Básico ~14 min

Cuarta vuelta al dominio Pedidos. Lo construimos a mano en MVC, le pusimos ORM standalone en Eloquent y lo reconstruimos completo en Laravel. Ahora la misma app en CodeIgniter 4 — el framework que php_02_mvc ya nos adelantó como «el primo cercano del artesanal». Si Laravel fue «todo viene de fábrica», CI4 es «lo esencial, sin ceremonias»: menos capas, cero magia y la curva más amable del ecosistema PHP.

  • Ubicar CI4 frente a lo que ya sabemos: artesanal, Eloquent y Laravel.
  • Ver la filosofía «sin ceremonias» con un mismo endpoint lado a lado.
  • Fijar la traducción de conceptos: cada pieza tiene nombre nuevo aquí.
  • Saber CUÁNDO elegir CI4 sobre Laravel (y viceversa).

El inventario honesto, ahora con tres columnas

Todo lo que Laravel trae de fábrica existe aquí también — con otro nombre y menos capas intermedias:

PiezaLaravel (manual anterior)CodeIgniter 4
Rutasroutes/web.php fluidoapp/Config/Routes.php con $routes->get()
CLIartisanspark
VistasBlade ({{ }}, @extends, x-componentes)PHP nativo + layout() + esc()
ORMEloquent (relaciones automáticas)Model + Query Builder explícitos
Peticiones HTTPmiddleware por capasfiltros before/after
Validaciónvalidate() + Form Requestsservicio $validation
MigracionesSchema::createForge + Fields
Datos de pruebafactories + PestFabricator + PHPUnit
Servidor devartisan servephp spark serve (:8080)

Un mismo endpoint, tres mundos

En Laravel declaramos así la lista de pedidos:

// routes/web.php (Laravel, manual anterior)
Route::get('/pedidos', [PedidoControlador::class, 'index']);

En CI4 la declaración vive en app/Config/Routes.php — un objeto $routes, placeholders entre paréntesis y el destino como cadena «Controlador::metodo»:

<?php
// app/Config/Routes.php (CodeIgniter 4)
$routes->get('/pedidos',             'PedidoControlador::index');
$routes->get('/pedidos/(:num)',      'PedidoControlador::show/$1');   // $1 = placeholder
$routes->post('/pedidos',            'PedidoControlador::create');

Nada de contenedor oculto ni resolución automática: una línea por ruta, igual que nuestro rutas.php del MVC pero declarativa. Esa es la promesa completa del framework: el patrón que ya dominas, sin ceremonias de por medio.

¿Cuándo CI4 y cuándo Laravel?

La regla de decisión del cap 32 del MVC sigue intacta — ahora la puedes SENTIR:

# Regla de decision rapida: # - Hosting humilde o equipo junior ....... CI4 (este manual) # - Producto con ecosistema grande ......... Laravel # - Y el patron es EL MISMO ................ siempre tuyo

CI4 corre feliz donde Laravel se ahoga: PHP compartido modesto, memoria justa, sin procesos de Node ni extensiones exóticas. A cambio renuncias a Blade, Eloquent y el universo de paquetes. Ninguna elección es definitiva: dominar el patrón te permite cambiar de ropa cuando el proyecto lo pida.

El eco permanente de este manual: cada concepto nuevo lo presentaremos con su equivalencia — «en Laravel era X, aquí es Y». Al terminar hablarás los dos dialectos sin confundirlos.

Puntos clave

  • Cuarta vuelta Pedidos: artesanal → Eloquent → Laravel → CI4.
  • CI4 = el esencial sin ceremonias: menos capas, cero magia.
  • Traducción inmediata: artisan→spark, Blade→esc(), middleware→filtros.
  • Routes.php declarativo: una línea por ruta, placeholders (:num).
  • Elección por contexto: hosting humilde/equipo junior favorece CI4.

2 · Requisitos e instalación

Básico ~15 min

La regla de la casa desde Laravel: nada de «instala y reza». Verificamos versiones y extensiones ANTES de composer, con nombres exactos. La buena noticia: si tu equipo corrió el manual anterior, ya tienes casi todo — CI4 pide menos que Laravel.

  • Verificar PHP 8.2+ y las extensiones OBLIGATORIAS intl y mbstring.
  • Instalar el esqueleto appstarter con composer create-project.
  • Configurar .env: baseURL, entorno development, permisos writable/.
  • Levantar php spark serve y ver la bienvenida en :8080.

Requisitos EXPLÍCITOS

RequisitoVersión / valorNotas
PHP8.2 o superiorPHP 8.5 exige CI ≥ 4.7.0; 8.4 exige ≥ 4.6.0
ext-intlOBLIGATORIAinternacionalización; sin ella CI4 ni arranca
ext-mbstringOBLIGATORIAcadenas multibyte
Composer≥ 2.0.14composer --version
MariaDBla de siemprevía ext-mysqli/mysqlnd, para caps 14+

Opcionales según uso: curl (peticiones salientes), gd o imagick (manejo de imágenes), simplexml (XML), json (ya viene activo en PHP moderno). Verificación antes de instalar:

# versiones y extensiones, como en el cap 2 de Laravel:
php -v
php -m | grep -E "(intl|mbstring|mysqli)"
composer --version

Si falta intl: sudo apt install php8.3-intl (Debian/Ubuntu) o actívala en php.ini (Windows/XAMPP: quita el ; de extension=intl). Mismo ritual que aprendimos en Laravel.

Instalación del esqueleto

# en tu carpeta de proyectos:
composer create-project codeigniter4/appstarter pedidos
cd pedidos
ls
app/ public/ tests/ writable/ vendor/ env spark composer.json

Cinco carpetas y tres archivos — más flaco que el esqueleto de Laravel. El archivo env (sin punto) es la plantilla de configuración; spark es nuestra CLI gemela de artisan.

Configuración inicial

# 1. la plantilla env se convierte en .env real:
cp env .env

# 2. edita .env — las dos líneas minimas:
CI_ENVIRONMENT = development
app.baseURL = 'http://localhost:8080/'
Slash final SIEMPRE en app.baseURL — la doc lo subraya: sin él la toolbar de depuración no carga bien y las páginas tardan notablemente más. Es el equivalente del APP_URL de Laravel.

Y permisos para writable/ — aquí viven caché, logs y subidas, el gemelo de storage/ + bootstrap/cache/ que ya conoces:

sudo chown -R www-data:www-data writable
sudo chmod -R u+rwx,g+rx writable

Primer arranque

php spark serve

# CodeIgniter v4.7.x dev server iniciado...
# http://localhost:8080

Abre esa URL: verás la página de bienvenida con una barra de herramientas abajo (la Debug Toolbar integrada — la exploraremos a fondo en el cap 41). Opciones útiles del servidor:

php spark serve --port 8081        # otro puerto
php spark serve --host pedidos.test # otro host (via /etc/hosts)

Novedad útil desde 4.5 — auditoría de tu php.ini contra los valores recomendados:

php spark phpini:check
Para producción la doc insiste: composer install --no-dev recorta el vendor de paquetes de desarrollo. Lo usaremos en el capítulo de despliegue; en desarrollo NO — ahí quieres las herramientas.

Puntos clave

  • PHP 8.2+, intl y mbstring obligatorias: verificar con php -m ANTES.
  • create-project codeigniter4/appstarter = esqueleto mínimo oficial.
  • cp env .env; baseURL con slash final; CI_ENVIRONMENT=development.
  • writable/ necesita permisos del usuario web (gemelo de storage/).
  • php spark serve levanta todo en :8080; phpini:check audita tu ini.

3 · Anatomía del proyecto

Básico ~13 min

Cinco carpetas. Eso es todo el esqueleto — y entender QUIÉN vive en cada una te ahorra semanas de confusión. La regla de oro ya la conoces del manual anterior: tú solo escribes en app/ y writable/; system/ (en vendor/) es intocable.

  • Recorrer las 5 carpetas y su papel exacto.
  • Mapear cada subcarpeta de app/ a su equivalente de Laravel.
  • Entender public/ como ÚNICO directorio expuesto al navegador.
  • Saber qué es el namespace App y dónde se cambia.

El mapa completo

pedidos/
|-- app/ TU codigo (namespace App)
|-- public/ LO UNICO que ve el navegador (webroot)
|-- writable/ lo que la app ESCRIBE: cache, logs, uploads
|-- tests/ pruebas PHPUnit (NO viaja a produccion)
|-- vendor/ composer: aqui vive system/ del framework
-- env · spark · composer.json
Carpeta CI4Equivalente LaravelPapel
app/app/ + routes/ + database/todo tu código, namespace App
public/public/webroot: index.php, .htaccess, assets
writable/storage/ + bootstrap/cache/logs, caché, sesiones, uploads
tests/tests/_support/ trae mocks y utilidades
vendor/…/systemvendor/laravel/frameworkel framework, namespace CodeIgniter

Dentro de app/: casa por casa

app/
  Config/        configuracion en CLASES PHP (Routes, Database, Filters, App)
  Controllers/   determinan el flujo (tu BaseController vive aqui)
  Database/      migraciones y seeds (gemelo de database/)
  Filters/       clases before/after (gemelo de Http/Middleware)
  Helpers/       colecciones de funciones sueltas
  Language/      cadenas de idioma
  Libraries/     clases que no encajan en otra parte
  Models/        entidades de negocio hacia la BD
  ThirdParty/    librerias ajenas manuales
  Views/         el HTML que se muestra

Dos contrastes con Laravel que salta a la vista: aquí la configuración es clases PHP (Config/Routes.php en vez de routes/web.php) y las carpetas están planas — sin anidación Http/ ni Console/. Menos ceremonia.

Namespaces: dos mundos separados

NamespaceCarpetaRegla
Appapp/TUYO: modifica, renombra, amplía libremente
CodeIgnitervendor/…/system/DEL FRAMEWORK: extiende, NUNCA edites

El namespace App se define en app/Config/Constants.php; si reubicaras carpetas principales, los ajustes van en app/Config/Paths.php. En el 99% de los proyectos no tocas ninguno de los dos — saber que existe es suficiente hoy.

public/: la única puerta

Todo lo demás debe quedar FUERA del alcance del navegador. El web server apunta a public/ — allí viven index.php (front controller), .htaccess y tus assets. Si configuras un VirtualHost (como hicimos para Laravel), el DocumentRoot apunta a la carpeta public/ del proyecto:

<VirtualHost *:80>
    DocumentRoot "/var/www/pedidos/public"
    ServerName   pedidos.local
    <Directory "/var/www/pedidos/public">
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>
La prueba de fuego: si puedes navegar a /writable/logs o ver tu .env desde el navegador, el webroot está mal apuntado. Debe apuntar SIEMPRE a public/, jamás a la raíz del proyecto.

Puntos clave

  • 5 carpetas: app (tuyo), public (webroot), writable (escrituras), tests, vendor.
  • Config en clases PHP: Config/Routes.php ≙ routes/web.php.
  • Filters ≙ middleware; Database/ ≙ migraciones+seeders de Laravel.
  • App tuyo / CodeIgniter intocable — igual que App / Illuminate.
  • DocumentRoot SIEMPRE a public/: todo lo demás queda blindado.

4 · Spark y el ciclo de vida

Intermedio ~14 min

spark es nuestro artisan: la CLI que genera, migra, sirve y diagnostica. La gramática que aprendiste en el cap 1 de Laravel (argumentos vs opciones, --help, list) aplica tal cual — solo cambia el verbo. Y detrás de cada petición web hay un ciclo corto que conviene VER una vez para siempre.

  • Dominar la gramática común de spark: list, ayuda, opciones.
  • Seguir el ciclo de vida: .htaccess → index.php → Routes → controlador.
  • Conocer los primeros comandos del día a día (gemelos de artisan).
  • Mención honesta del Worker Mode experimental con FrankenPHP.

La gramática de spark

php spark list        # todos los comandos disponibles
php spark serve --port 8081    # comando + opcion
php spark make:controller PedidoControlador  # argumento
php spark migrate --help   # ayuda de UN comando
ConceptoLaravel (artisan)CodeIgniter (spark)
Listar comandosphp artisan listphp spark list
Servidor devartisan serve (:8000)php spark serve (:8080)
Generadoresmake:model, make:controller…spark make:controller, make:model…
Ayuda por comandoartisan help X / X --helpspark X --help
Verbosidad-v/-vv/-vvv-v/-vv/-vvv

El ciclo de vida de una petición

Es el MISMO front controller que construimos a mano en php_01 y php_02 — ahora con piezas oficiales:

navegador
   |  GET /pedidos/5
   v
public/.htaccess      reescribe todo hacia public/index.php
   v
public/index.php      front controller: carga autoloader + bootstrap
   v
Config/Routes.php     busca la ruta: pedidos/(:num) -> show/$1
   v
PedidoControlador::show($id)   TU codigo consulta al modelo
   v
vista renderizada  ->  Response HTTP  ->  navegador

Tres detalles que ya viviste en versión casera: el .htaccess oculta index.php de las URIs (mod_rewrite), el front controller es el ÚNICO punto de entrada (por eso public/ es el webroot), y las rutas deciden qué método de qué controlador atiende cada URI. Nada nuevo bajo el sol — solo nombres nuevos.

Primeras rutas propias, primer controlador

php spark make:controller HolaControlador
<?php
// app/Controllers/HolaControlador.php (recortado)
namespace App\Controllers;

class HolaControlador extends BaseController
{
    public function index(): string
    {
        return 'Hola desde CI4 — cuarta vuelta a Pedidos.';
    }
}
<?php
// app/Config/Routes.php — añade:
$routes->get('/hola', 'HolaControlador::index');

Visita /hola en :8080. Acabas de tocar las TRES capas del ciclo: ruta declarada, controlador generado por spark y respuesta directa. En el cap 9 esa cadena devolverá una vista con layout.

Worker Mode: la apuesta experimental

Desde 4.7 existe un modo worker experimental con FrankenPHP: la app vive residente en memoria sirviendo muchas peticiones sin reiniciar PHP. Se instala con php spark worker:install. Para aprender, ignóralo — el clásico servidor web sigue siendo el camino canónico; lo retomaremos si algún día despliegas con Frank.

No confundas servidores: php spark serve es SOLO desarrollo (como artisan serve). Producción = Apache/nginx apuntando a public/. El capítulo de despliegue cierra ese círculo.

Puntos clave

  • spark ≙ artisan: misma gramática, otro verbo (list, serve, make:*).
  • Ciclo: .htaccess → index.php → Routes.php → controlador → vista.
  • Ruta = $routes->get('/uri', 'Controlador::metodo'); placeholders (:num).
  • make:controller genera la clase; BaseController es tu padre.
  • serve solo en dev; Worker Mode/FrankenPHP: experimental, no hoy.

5 · Configuración: clases PHP y .env

Básico ~13 min

Laravel configura con arreglos PHP; CodeIgniter lo hace con clases cuyas propiedades públicas SON los valores. Misma separación de siempre: lo genérico vive en app/Config/, lo sensible o cambiante por entorno vive en .env — y hay una regla nueva e importante sobre qué puede tocar el .env.

  • Leer valores con config('Clase')->propiedad.
  • Sobreescribir propiedades desde .env con notación punto.
  • Entender la restricción: el .env SOLO reemplaza escalares existentes.
  • Auditar la configuración real con spark config:check.

Clases de configuración

Cada clase del sistema tiene su gemela en app/Config/ — App, Database, Filters, Routes, Session… Todas extienden BaseConfig y exponen propiedades públicas que conviene tratar como de solo lectura:

<?php
// fragmento de app/Config/App.php
namespace Config;

use CodeIgniter\Config\BaseConfig;

class App extends BaseConfig
{
    public string $baseURL   = 'http://localhost:8080/';
    public string $indexPage = 'index.php';
    public string $appTimezone = 'UTC';
    public string $charset   = 'UTF-8';
}

Dos formas de leerlas — la función global config() devuelve una instancia compartida:

$app    = config('App');            // instancia compartida
$pagina = $app->indexPage;           // 'index.php'

$pager = new \Config\Pager();       // o instancia manual con new

El puente con .env

Ya copiamos env.env en el cap 2. La magia ocurre al instanciar cada clase: si un prefijo coincide con ella, el valor del .env reemplaza a la propiedad. Prefijo corto = nombre de clase en minúscula:

# .env — prefijo corto + propiedad EXACTA:
app.baseURL = 'http://pedidos.test/'
app.appTimezone = 'America/Lima'

# equivalente con guion bajo (util en Docker):
app_baseURL = 'http://pedidos.test/'
La regla que nadie te cuenta: el .env SOLO reemplaza valores ESCALARES ya existentes en la clase. No puedes INVENTAR propiedades ni convertir un escalar en arreglo — para eso se edita la clase Config. Y las credenciales van al .env SIEMPRE (fuera del control de versiones, como aprendiste en Laravel).

Extras útiles del formato: variables anidadas con ${OTRA}, y las variables ya presentes en el entorno real nunca se sobreescriben:

# .env — anidar variables:
BASE_DIR = /var/www/pedidos
CACHE_DIR = ${BASE_DIR}/writable/cache

Auditar la configuración real

Novedad desde 4.5 — volcado de los valores EFECTIVOS (clase + .env + caché aplicados), el detector definitivo de «credenciales fantasma»:

php spark config:check App

# Config\App#6 (12) (
# public 'baseURL' -> "http://localhost:8080/"
# public 'indexPage' -> "index.php"
# ...
NecesidadLaravelCodeIgniter 4
Archivo(s)config/*.php (arreglos)app/Config/*.php (clases)
Leer valorconfig('app.name')config('App')->name
Entorno.env → env().env → getenv()/$_ENV
Puentecarga directa del arregloprefijo punto reemplaza propiedad
Inspecciónphp artisan aboutphp spark config:check Clase

Puntos clave

  • Config = clases con propiedades públicas; trátalas como readonly.
  • config('App')->baseURL: lectura compartida y directa.
  • .env reemplaza SOLO escalares existentes: app.baseURL, database.default.*…
  • Nada de inventar claves en .env — eso va en la clase.
  • spark config:check muestra el valor efectivo final.

6 · Helpers: los del framework y los tuyos

Básico ~14 min

Mismo título que el cap 6 de Laravel porque es la MISMA lección con otra mecánica de carga. Aquí un helper es un archivo plano de funciones procedurales — sin clases ni namespaces — y NO se cargan solos: hay que pedirlos, salvo uno.

  • Cargar helpers con helper() y conocer el orden de búsqueda.
  • Tour de los integrados que usaremos todo el manual.
  • Crear app/Helpers/pedidos_helper.php y portar moneda().
  • Auto-cargar helpers globales vía Autoload.php o $helpers.

La mecánica: cargar para usar

Un helper vive en system/Helpers/ (los oficiales) o app/Helpers/ (los tuyos), con nombre en minúsculas terminado en _helper.php. Solo el de URL se auto-carga siempre; los demás:

<?php
helper('form');                    // carga form_helper.php
helper(['text', 'date']);          // varios a la vez

// desde un namespace concreto (modulos):
helper('Example\Blog\blog');

Orden de búsqueda cuando el nombre coincide en varios sitios: primero app/Helpers, luego los namespaces de módulos, y al final system/Helpers. Eso significa dos cosas poderosas: tus archivos pueden AÑADIR funciones y hasta REEMPLAZAR las nativas creando un archivo con el mismo nombre («extender» helpers). Ojo: los tuyos van SIN namespace — nada de namespace App\Helpers; dentro del archivo.

Los que usaremos todo el manual

HelperFunciones estrellaUso típico aquí
url (auto)url_to(), site_url(), anchor()rutas inversas cap 7, links en vistas
formform_open(), csrf_field()formularios + CSRF caps 12–13
textword_limiter(), character_limiter()resúmenes en listados
numbernumber_to_currency()dinero en vistas (alternativa a moneda())

Y aparte de los helpers por archivo, CI4 trae funciones globales siempre disponibles (sin cargar nada): esc() — nuestro escape anti-XSS, gemelo del e() artesanal —, view(), redirect(), session(), config(). Ya usaste dos en el cap 3.

Nuestro primer helper propio

Portamos moneda() del cap 6 de Laravel — misma función, otra casa:

<?php
// app/Helpers/pedidos_helper.php   SIN namespace!
if (! function_exists('moneda')) {
    /**
     * Formato S/ para montos DECIMAL de la BD.
     */
    function moneda(float|string $monto): string
    {
        return 'S/ ' . number_format((float) $monto, 2);
    }
}
# probarla ya mismo: ruta temporal en Routes.php
# $routes->get('/demo-moneda', static function () {
# helper('pedidos');
# return moneda(321.05); // S/ 321.05
# });

El guard function_exists evita colisiones si otro paquete definiera moneda() — idéntico al patrón del manual anterior. Para no llamar helper() en cada controlador, hay dos formas de carga automática:

// Opcion A (desde 4.3): app/Config/Autoload.php
public array $helpers = ['pedidos'];

// Opcion B: propiedad del controlador (cap 8)
protected $helpers = ['pedidos', 'form'];
Regla de la casa intacta: helper para detalles de presentación (formatos, etiquetas); lógica de negocio → clase propia en app/Libraries/ o modelo. Los mismos criterios de Laravel, palabra por palabra.

Puntos clave

  • helper('x') carga x_helper.php; solo url viene de fábrica.
  • Búsqueda: app/Helpers primero — puedes extender/reemplazar nativos.
  • Tus helpers van SIN namespace y con guard function_exists.
  • esc()/view()/redirect()/session(): globales, sin cargar nada.
  • Autoload global ($helpers en Autoload.php) o por controlador.

7 · Rutas a fondo en Routes.php

Intermedio ~15 min

El cap 4 te dio la primera ruta; este capítulo te da el idioma completo. Buena noticia: es MÁS simple que web.php de Laravel — sin closures por defecto, sin grupos anidados obligatorios, y desde 4.2 el auto-routing viene apagado: solo existe lo que declaras.

  • Dominar verbos, placeholders y parámetros hacia el controlador.
  • Nombrar rutas y generar URLs inversas con url_to().
  • Agrupar con group() aplicando filtros y namespaces.
  • Inspeccionar la tabla de rutas con spark routes.

Verbos y parámetros

<?php
// app/Config/Routes.php
$routes->get('/pedidos',              'PedidoControlador::index');
$routes->get('/pedidos/(:num)',       'PedidoControlador::show/$1');
$routes->post('/pedidos',             'PedidoControlador::create');
$routes->put('/pedidos/(:num)',       'PedidoControlador::update/$1');
$routes->delete('/pedidos/(:num)',    'PedidoControlador::delete/$1');

// varios verbos en una linea:
$routes->match(['GET', 'POST'], '/contacto', 'Contacto::form');

Cada $N pasa el placeholder N como argumento — idéntico al /{$id} del router artesanal. Evita $routes->add() (acepta cualquier verbo): si una URI responde a GET, la protección CSRF no aplica.

Placeholders: los seis oficiales

PlaceholderCoincide con
(:num)entero positivo
(:segment)cualquier cosa MENOS / (un solo segmento)
(:any)todos los segmentos restantes ¡incluidas barras!
(:alpha) · (:alphanum)solo letras · letras+números
(:hash)= :segment (convención para ids hasheados)
La trampa clásica: (:any) se traga TODO lo que sigue — pedidos/12/detalles también caería en pedidos/(:any). Para un solo trozo usa (:segment). Y nunca pongas un placeholder DESPUÉS de (:any).

¿Patrón propio? Se registra antes de usarlo:

$routes->addPlaceholder('uuid', '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}');
$routes->get('facturas/(:uuid)', 'FacturaControlador::show/$1');

Rutas nombradas e inversión

Aquí CI4 resuelve lo mismo que Route::name(): la opción 'as' nombra la ruta y url_to() genera la URL al revés — si mañana cambia la URI, tus vistas siguen funcionando:

// definicion:
$routes->get('pedidos/(:num)/detalle', 'PedidoControlador::detalle/$1',
             ['as' => 'pedido.detalle']);

// en cualquier vista o controlador:
<a href="<?= url_to('pedido.detalle', 15) ?>">Ver detalle</a>
// -> /pedidos/15/detalle   SIEMPRE actual

Grupos: el admin de Pedidos

$routes->group('admin', ['filter' => 'session'], static function ($routes) {
    $routes->get('panel',     'Admin\Panel::index');       // admin/panel
    $routes->get('pedidos',   'Admin\Pedidos::index');     // admin/pedidos
});

// utilidades rapidas sin controlador:
$routes->view('/aviso', 'pages/mantenimiento');         // vista pura (GET)
$routes->addRedirect('/antiguo', '/nuevo');             // redireccion 302
$routes->environment('development', static function ($routes) {
    $routes->get('/sandbox', 'Herramientas::sandbox');  // SOLO en dev
});

Inspección: spark routes

php spark routes

# +--------+-------------------+--------------------------------+
# | Method | Route | Handler |
# +--------+-------------------+--------------------------------+
# | GET | / | \App\Controllers\Home::index |
# | GET | pedidos/(:num) | \App\Controllers\PedidoControlador::show
# ...   (gemelo exacto de route:list)

Config global relevante en app/Config/Routing.php: $autoRoute = false (default desde 4.2 — solo rutas definidas, nuestra regla de casa), $defaultMethod = 'index' y $override404 para tu propia página 404 con controlador.

Puntos clave

  • Método por verbo ($routes->get/post/...); add() prohibido por CSRF.
  • (:segment) para UN trozo; (:any) devora todo lo que sigue.
  • 'as' => nombre + url_to() = rutas que sobreviven refactors.
  • group() comparte prefijo/filtro/namespace entre rutas hermanas.
  • spark routes ≙ route:list; $autoRoute=false ya viene por defecto.

8 · Controladores y BaseController

Intermedio ~14 min

Abre la Parte II con el corazón del flujo. Ya generaste un controlador en el cap 4; ahora abrimos BaseController — el padre que CI4 prepara para todos los tuyos — y fijamos las reglas de retorno, helpers y visibilidad que usaremos en los 36 capítulos restantes.

  • Entender initController() y las propiedades request/response/logger.
  • Declarar helpers por controlador con $helpers.
  • Dominar los tres tipos de retorno: string, Response y redirect().
  • Saber qué métodos son inalcanzables por URL (¡buena noticia!).

La anatomía del padre

Todo controlador extiende BaseController. Tras ejecutarse tu constructor PHP, el framework llama a initController(), que deja listas tres propiedades para toda la clase:

<?php
// app/Controllers/BaseController.php (recortado)
abstract class BaseController extends Controller
{
    protected $request;
    protected $response;
    protected $logger;

    public function initController(
        RequestInterface  $request,
        ResponseInterface $response,
        LoggerInterface   $logger
    ) {
        $this->request  = $request;
        $this->response = $response;
        $this->logger   = $logger;
    }
}

Si lo sobreescribes en tus controladores (para cargar modelos comunes, por ejemplo), la PRIMERA línea debe ser parent::initController($request, $response, $logger); — o pierdes las tres propiedades.

Helpers por controlador

La propiedad de clase que vimos nacer en el cap 6:

<?php
// app/Controllers/PedidoControlador.php
namespace App\Controllers;

class PedidoControlador extends BaseController
{
    protected $helpers = ['pedidos', 'form'];

    public function index(): string
    {
        return moneda(258.20);   // disponible sin helper() manual
    }
}

Los tres retornos posibles

RetornoCuándoEjemplo
stringvista renderizada o HTML/texto planoreturn view('pedidos/lista');
Responsecontrol fino de headers/status (API caps 26+)return $this->response->setJSON($datos);
RedirectResponsetras POST exitoso o denegadoreturn redirect()->to('/pedidos');

Con named routes del cap 7, redirige por nombre: redirect()->route('pedido.detalle', [15]).

Regla dura de PHP: el constructor NO puede hacer return. Un return redirect() ahí dentro muere en silencio — si necesitas cortar el flujo según condiciones iniciales, hazlo en initController() o en un filtro (cap 11).

Visibilidad = superficie de ataque

Métodos public: alcanzables por URL (según rutas). Métodos protected/private: invisibles para el router y para el auto-routing. La convención de la casa: helpers internos del controlador (como armarConsultas()) van protected — gratis y por diseño.

Esqueleto real del PedidoControlador

Lo que iremos rellenando desde el cap 20:

<?php
namespace App\Controllers;

use App\Models\PedidoModelo;

class PedidoControlador extends BaseController
{
    protected $helpers = ['pedidos'];
    private PedidoModelo $pedidos;

    public function initController($request, $response, $logger)
    {
        parent::initController($request, $response, $logger);
        $this->pedidos = new PedidoModelo();     // cap 17: modelos CI4
    }

    public function index(): string              // GET /pedidos
    {
        return view('pedidos/lista', ['pedidos' => $this->pedidos->findAll()]);
    }

    public function show(int $id): string        // GET /pedidos/(:num)
    {
        return view('pedidos/detalle', ['pedido' => $this->pedidos->find($id)]);
    }
}

Fíjate en view('vista', ['clave' => valor]) — EXACTAMENTE el segundo argumento que aprendiste a pasar en Laravel y en el artesanal. El patrón no cambia: solo la sintaxis de la ruta que llega hasta aquí.

Puntos clave

  • initController() tras __construct: request/response/logger listos.
  • $helpers = [...] carga helpers una sola vez por controlador.
  • Retornos válidos: string | Response | redirect(); NADA en el constructor.
  • protected/private = inalcanzables por URL: diseño seguro gratis.
  • view(dato, arreglo): el mismo puente controlador→vista de siempre.

9 · Vistas I: view(), esc() y layouts

Básico ~15 min

Aquí está la diferencia filosófica grande frente a Blade: las vistas de CI4 son PHP puro — sin llaves mágicas ni directivas @. El escape anti-XSS es TU responsabilidad en cada echo, con esc(). Si el manual MVC te enseñó disciplina, aquí rinde al máximo.

  • Devolver view('ruta', datos) desde el controlador.
  • Escribir plantillas PHP con esc() SIEMPRE y sintaxis alternativa.
  • Crear un layout maestro con extend/section/renderSection.
  • Conocer las opciones de view(): cache y la trampa saveData.

Del controlador a la vista

<?php
// app/Controllers/PedidoControlador.php
public function index(): string
{
    return view('pedidos/lista', [
        'pedidos' => $this->pedidos->findAll(),
        'titulo'  => 'Pedidos registrados',
    ]);
}

'pedidos/lista' apunta a app/Views/pedidos/lista.php (barras para carpetas, extensión .php asumida). Las claves del arreglo se vuelven variables DENTRO de la vista — idéntico al extract() artesanal y al segundo argumento de view() en Laravel.

La plantilla: PHP honesto

<!-- app/Views/pedidos/lista.php -->
<h1><?= esc($titulo) ?></h1>

<?php if (empty($pedidos)): ?>
    <p>Sin pedidos todavía.</p>
<?php else: ?>
    <table>
    <?php foreach ($pedidos as $p): ?>
        <tr>
            <td>#<?= (int) $p['id'] ?></td>
            <td><?= esc($p['cliente']) ?></td>
            <td><?= moneda($p['total']) ?></td>
        </tr>
    <?php endforeach ?>
    </table>
<?php endif ?>
NecesidadBlade (Laravel)CI4 nativo
Imprimir escapado{{ $x }}<?= esc($x) ?>
Sin escapar (HTML confiable){!! $x !!}<?= $x ?> (¡consciente!)
Condicional@if / @endif<?php if: endif ?>
Bucle@foreach / @endforeach<?php foreach: endforeach ?>
Vacío por defecto@forelse/@emptyif (empty(...)) a mano

Nota: moneda() del cap 6 ya está disponible si cargaste el helper — dentro de una vista también funciona helper('pedidos').

Layouts con secciones

El mecanismo oficial: un layout que DECLARA huecos con renderSection(), y cada vista hija que los RELLENA con extend()/section():

<!-- app/Views/layout_pedidos.php -->
<!DOCTYPE html>
<html lang="es">
<head>
    <title><?= $this->renderSection('titulo') ?? 'Pedidos' ?></title>
</head>
<body>
    <nav>...menú común...</nav>
    <main>
        <?= $this->renderSection('contenido') ?>
    </main>
</body>
</html>
<!-- app/Views/pedidos/lista.php (ahora extiende) -->
<?= $this->extend('layout_pedidos') ?>

<?= $this->section('titulo') ?>Pedidos<?= $this->endSection() ?>

<?= $this->section('contenido') ?>
    ...la tabla de arriba...
<?= $this->endSection() ?>

El controlador NO cambia: sigue devolviendo view('pedidos/lista', ...) y el renderer aplica el layout automáticamente. endSection() no lleva nombre — cierra la sección abierta.

Opciones de view(): dos joyas

// vista cacheada 60 segundos (gemelo de view caching):
return view('panel/metricas', $datos, ['cache' => 60,
                                       'cache_name' => 'panel-metricas']);
La trampa saveData: por defecto CI4 MANTIENE los datos de una llamada view() en la siguiente — útil para header+content+footer, pero fuente de bugs sutiles al reutilizar nombres de variables. Con ['saveData' => false] cada llamada parte limpia; o cambia el default global en app/Config/Views.php.

Puntos clave

  • Vistas = PHP puro en app/Views/: esc() en CADA echo dinámico.
  • view('carpeta/archivo', datos): mismas claves→variables de siempre.
  • Layout: extend()+section() en la hija; renderSection() en el padre.
  • cache => segundos cachea la vista completa; cache_name la identifica.
  • saveData=true por defecto: cuidado al encadenar llamadas view().

10 · Vistas II: parciales y View Cells

Intermedio ~14 min

Blade tenía componentes <x-alerta>; CI4 tiene dos escalones: parciales con $this->include() para trozos estáticos, y View Cells cuando el trozo necesita LÓGICA propia (una consulta, un cálculo). La frontera entre ambos es la misma que aprendiste entre include() y componentes con props.

  • Incluir parciales reutilizables con $this->include().
  • Crear Simple Cells: clase+método que devuelven HTML.
  • Crear Controlled Cells con spark make:cell, props y mount().
  • Cache cells por segundos con TTL e id propio.

Parciales: trozos sin lógica

<!-- app/Views/parciales/tabla_pedidos.php -->
<table>
<?php foreach ($pedidos as $p): ?>
    <tr><td>#<?= (int) $p['id'] ?></td><td><?= esc($p['cliente']) ?></td></tr>
<?php endforeach ?>
</table>
<!-- dentro de cualquier vista o layout -->
<?= $this->include('parciales/tabla_pedidos', ['pedidos' => $pedidos]) ?>

<!-- tambien funciona concatenando view(): -->
<?= view('parciales/tabla_pedidos', ['pedidos' => $pedidos]) ?>

include() acepta las mismas opciones que view() — incluida cache. La convención de la casa: carpeta parciales/ para dejar claro que no se renderizan solos.

Simple Cells: lógica mínima encapsulada

Un cell es cualquier clase::metodo que devuelve string. Desde la vista lo invocas sin importar nada:

<?php
// app/Cells/Blog.php  (namespace App\Cells asumido)
namespace App\Cells;

class Blog
{
    public static function recientes(int $limit = 5): string
    {
        // consulta ligera + HTML construido
        return '<ul>...' . $limit . ' posts...</ul>';
    }
}
<?= view_cell('App\Cells\Blog::recientes', 'limit=5', 300, 'cell-blog') ?>

Tercer parámetro = TTL de caché en segundos; cuarto = id de caché propio. El panel del cap 25 usará esto para métricas costosas.

Controlled Cells: el primo de x-componentes

Desde 4.3 existen clases dedicadas que extienden Cell, con propiedades públicas expuestas a una vista convencional:

php spark make:cell AlertaEstadoCell
# crea app/Cells/AlertaEstadoCell.php + app/Views/cells/alerta_estado.php
<?php
// app/Cells/AlertaEstadoCell.php (recortado)
namespace App\Views\Cells;

use CodeIgniter\View\Cells\Cell;

class AlertaEstadoCell extends Cell
{
    public string $estado = 'REGISTRADO';

    // logica inicial: recibe los parametros por NOMBRE
    public function mount(string $estado = 'REGISTRADO'): void
    {
        $this->estado = $estado;
    }

    // propiedad computada disponible como $color en la vista
    public function getColorProperty(): string
    {
        return match ($this->estado) {
            'PAGADO'     => 'success',
            'ANULADO'    => 'danger',
            default      => 'warning',
        };
    }
}
<!-- app/Views/cells/alerta_estado.php -->
<span class="badge text-bg-<?= $color ?>"><?= esc($estado) ?></span>

<!-- uso desde cualquier vista: -->
<?= view_cell('App\Views\Cells\AlertaEstadoCell', ['estado' => 'PAGADO']) ?>

¿Parcial, Simple Cell o Controlled Cell?

NecesidadHerramientaEco Laravel
Solo HTML repetido$this->include()@include
HTML + consulta/cálculo simpleSimple Cellcomponente con lógica
Componente reutilizable con propsControlled Cell<x-componente :prop="">
No sobre-estructures: si un parcial basta, parcial. Los cells brillan cuando el mismo bloque aparece en 3+ vistas Y trae su propia consulta — como las alertas de stock bajo que construiremos en el dashboard.

Puntos clave

  • include() para parciales; carpeta parciales/ como convención.
  • view_cell(Clase::metodo, params, ttl, cacheId): mini-vistas con lógica.
  • Controlled Cells: make:cell, extends Cell, mount(), getXxxProperty().
  • Vista del cell por convención: snake_case sin sufijo (alerta_estado).
  • Misma regla que Blade: elige la herramienta más SIMPLE que resuelve.

11 · Filtros: el gemelo del middleware

Intermedio ~15 min

Todo lo que sabes de middleware (cap 11 de Laravel) se traduce aquí: un filtro corre before() o after() del controlador. Las diferencias: la clase es más pequeña, el registro central vive en app/Config/Filters.php y hay filtros de fábrica ya conectados.

  • Entender FilterInterface: qué puede cortar before() y qué no after().
  • Registrar alias en $aliases y aplicar por ruta, grupo, método o global.
  • Conocer los filtros integrados que CI4 trae activos.
  • Escribir FiltroAdmin para el panel del cap 25.

La interfaz: dos métodos, reglas claras

<?php
// app/Filters/FiltroAdmin.php — esqueleto canonico
namespace App\Filters;

use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;

class FiltroAdmin implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        // devolver Response = CORTA la ejecucion aqui mismo
        if (! session('es_admin')) {
            return redirect()->to('/login')
                ->with('error', 'Zona restringida.');
        }
        // arguments = ['super', 'auditor'] si registraste 'filtro-admin:super,auditor'
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
    {
        // SOLO puede modificar $response; jamas detiene nada
    }
}

El registro central

<?php
// app/Config/Filters.php (recortado)
class Filters extends BaseFilters
{
    public array $aliases = [
        'csrf'         => CSRF::class,
        'session'      => Session::class,       // ejemplo: el nuestro, cap 31
        'filtro-admin' => FiltroAdmin::class,
    ];

    public array $required = [
        'before' => ['forcehttps', 'pagecache'],
        'after'  => ['pagecache', 'performance', 'toolbar'],
    ];

    public array $globals = [
        'before' => ['csrf'],                    // TODO post/put/delete
        'after'  => [],
    ];

    public array $methods = [
        'POST' => ['invalidchars'],              // solo peticiones POST
    ];

    public array $filters = [                     // por patron URI
        'session' => ['before' => ['panel/*', 'admin/*']],
    ];
}

Los cuatro niveles se SUMAN: required (siempre, desde 4.5) → globals → methods → filters → lo que declare la propia ruta. Orden inverso en after().

Aplicación puntual en rutas

Lo que ya usaste en el cap 7 sin saberlo:

$routes->get('admin/panel', 'Admin\Panel::index',
              ['filter' => 'filtro-admin']);

// con argumentos (llegan como arreglo en $arguments):
$routes->post('pedidos/anular/(:num)', 'PedidoControlador::anular/$1',
              ['filter' => 'filtro-admin:super,auditor']);

Los integrados que ya trabajan para ti

AliasHaceCuando te toca
csrfvalida el token en POST/PUT/PATCH/DELETEcap 12 — ya viene global
toolbarinyecta la Debug Toolbar en desarrolloya activo ($required)
forcehttpsmanda todo a HTTPSproducción
pagecache / performance / secureheaderscaché de página, cabecera de tiempos, cabeceras segurasdespliegue cap 43
honeypot / invalidchars / corsanti-bots, caracteres inválidos, CORSseguridad caps 32–33

¿Dudas sobre qué corrió en una URI? Inspección directa:

php spark filter:check get admin/panel

# Filtros before: forcehttps, csrf, filtro-admin
# Filtros after: pagecache, toolbar, performance
Traducción mental final: middleware de Laravel ≙ filtro; bootstrap/app.php con aliases ≙ Config/Filters.php; route middleware ≙ opción filter de la ruta. El concepto sobrevive, cambia el nombre del archivo.

Puntos clave

  • before() puede CORTAR devolviendo Response; after() solo ajusta.
  • $aliases obligatorio; los cuatro niveles se suman en orden.
  • 'alias:arg1,arg2' pasa argumentos al filtro.
  • csrf y toolbar vienen de fábrica conectados.
  • spark filter:check audita qué corrió por URI.

12 · Formularios, CSRF y validación

Intermedio ~16 min

El trío clásico de la Parte II se cierra con el flujo completo: formulario que envía token CSRF (validado por el filtro del cap 11), servicio de validación con reglas declarativas, y repoblado con old() si algo falla. Si conoces validate() + old() + @error de Laravel, ya sabes el 80%.

  • Enviar formularios con form_open() y entender el token CSRF.
  • Validar con $this->validateData() y reglas pipe o is_unique.
  • Mostrar errores por campo y repoblar con old().
  • Definir grupos de reglas reutilizables en Config/Validation.php.

El formulario

<!-- app/Views/pedidos/nuevo.php -->
<?= $this->extend('layout_pedidos') ?>
<?= $this->section('contenido') ?>

<form action="<?= site_url('pedidos') ?>" method="post">
    <?= csrf_field() ?>   <!-- campo oculto con el token -->

    <label>Cliente</label>
    <input type="text" name="cliente"
           value="<?= old('cliente') ?>">
    <span><?= validation_show_error('cliente') ?></span>

    <label>Total</label>
    <input type="text" name="total" value="<?= old('total') ?>">
    <span><?= validation_show_error('total') ?></span>

    <button>Registrar pedido</button>
</form>

<?= $this->endSection() ?>

csrf_field() imprime el hidden con nombre dinámico (csrf_token()) y hash fresco (csrf_hash()). El filtro csrf — global desde el cap 11 — valida TODO post/put/delete y, al fallar, en producción te devuelve al referer con flash 'error'. La regeneración por envío viene activada en app/Config/Security.php: no caches la página del formulario.

Validación en el controlador

<?php
public function guardar(): RedirectResponse   // POST /pedidos
{
    $reglas = [
        'cliente' => 'required|min_length[3]|max_length[120]',
        'total'   => 'required|decimal|greater[0]',
    ];

    if (! $this->validateData($this->request->getPost(), $reglas)) {
        return redirect()->back()->withInput()
            ->with('errors', $this->validator->getErrors());
    }

    $id = $this->pedidos->insert($this->request->getPost(), true);
    return redirect()->to("/pedidos/$id")
        ->with('success', "Pedido #$id registrado.");
}
NecesidadRegla exacta CI4
Obligatorio / opcional vacíorequired / permit_empty
Largo mínimo/máximomin_length[3] · max_length[120]
Email válidovalid_email
Número decimal / enterodecimal · integer · numeric
Único en tabla (¡sin espacios!)is_unique[clientes.email]
Igual a otro campomatches[campo]
Dentro de lista / regex propiain_list[PAGADO,ANULADO] · regex_match[/.../]

Los errores llegan a la vista vía flashdata — los helpers validation_show_error('campo') y old() los leen solos tras withInput(). Es la bolsa de errores del cap 19 del MVC, ahora oficial. Para mensajes propios:

$reglas = [
    'cliente' => [
        'rules'  => 'required|min_length[3]',
        'errors' => ['min_length' => 'El cliente necesita al menos 3 letras.'],
    ],
];

Grupos de reglas reutilizables

Cuando el mismo conjunto sirve en crear Y editar, vive como propiedad pública en app/Config/Validation.php:

// app/Config/Validation.php
class Validation extends BaseValidation
{
    public array $pedido = [
        'cliente' => 'required|min_length[3]',
        'total'   => 'required|decimal|greater[0]',
    ];
}

// uso:
$validation = service('validation');
if (! $validation->run($this->request->getPost(), 'pedido')) {
    $errores = $validation->getErrors();
}
Eco Laravel: Form Request ≙ grupo de Config/Validation.php; $errors bag ≙ flashdata errors; @error ≙ validation_show_error(). Y la misma regla de la casa: validar SIEMPRE del lado servidor aunque haya JS delante.

Puntos clave

  • csrf_field() + filtro csrf global: protección sin configurar nada.
  • validateData() + validator->getErrors(): flujo estándar de guardado.
  • redirect()->back()->withInput(): old() repuebla, show_error() pinta.
  • is_unique[tabla.campo] SIN espacios dentro del string.
  • Grupos en Validation.php = reglas compartidas entre acciones.

13 · Sesiones, flashdata y redirects

Intermedio ~14 min

Cierra la Parte II la pieza que ya usamos sin mirar dentro: cada ->with('success', ...) de los caps 11–12 era FLASHDATA. Aquí abrimos la librería completa — y de paso preparamos el terreno del login artesanal del cap 31.

  • Manejar la sesión con el helper session(): get/set/push/remove.
  • Dominar flashdata (una petición exacta) y tempdata (TTL en segundos).
  • Elegir handler: file por defecto o database con tabla ci_sessions.
  • Conocer los ajustes de seguridad por defecto que CI4 trae activados.

La API mínima diaria

<?php
$session = session();                    // instancia compartida

$session->set('usuario_id', 7);
$session->set(['es_admin' => true]);     // arreglo a granel
$id      = $session->get('usuario_id');  // null si no existe
$admin   = session('es_admin');          // atajo lector (cap 11 lo usó)
$session->push('carrito', [$productoId]);// agrega a un valor-arreglo
$session->remove('usuario_id');
$session->destroy();                     // logout del cap 31

Flashdata y tempdata: vidas distintas

TipoViveUso canónico
setFlashdata / getFlashdataEXACTAMENTE una petición siguientemensajes tras redirect (with())
keepFlashdata('clave')prolonga otra petición másredirect encadenado
setTempdata(clave, val, ttl)N segundos (default 300)tokens de reset, códigos OTP

El patrón completo que ya ejecutaste sin saberlo:

// controlador:
return redirect()->to("/pedidos/$id")->with('success', "Pedido #$id listo.");

// app/Views/layout_pedidos.php:
<?php if ($msg = session()->getFlashdata('success')): ?>
    <div class="alert alert-success"><?= esc($msg) ?></div>
<?php endif ?>

->with() sobre un redirect ES setFlashdata. Una sola petición: si recargas (F5), desaparece — exactamente como el flash de Laravel.

Handlers: dónde VIVEN tus sesiones

HandlerGuardarCuándo usarlo
files (default)writable/session/dev y apps pequeñas — «el más seguro» según la doc
databasetabla ci_sessionsservers múltiples (nuestro cap 31+)
redis / memcachedservidor dedicadoalto tráfico
arraynada (memoria)solo tests

Cambio a database en dos pasos — configuración y tabla oficial:

# 1. app/Config/Session.php:
# public string $driver = 'database';
# public string $savePath = 'ci_sessions';

# 2. migracion oficial generada para ti:
php spark make:migration --session
php spark migrate

La migración --session crea ci_sessions con las columnas EXACTAS que el handler espera (id, ip_address, timestamp, data) y respeta tu $matchIP — no inventes la tabla a mano.

Seguridad ya incluida

# defaults de Config\Session.php:
# expiration = 7200 s  (0 = hasta cerrar navegador)
# timeToUpdate = 300 s   (regeneracion periodica del ID de sesion)
# matchIP = false   (validar IP al leer la cookie)
# HttpOnly SIEMPRE activado en la cookie; sameSite Lax por defecto

La regeneración periódica del ID es el gemelo automático del session_regenerate_id() que llamabas a mano tras el login en el manual MVC — aquí el framework lo hace solo cada timeToUpdate segundos.

Eco Laravel: session()->put ≙ set(); session flash ≙ flashdata; driver file/database ≙ SESSION_DRIVER. La diferencia: CI4 regenera el ID automáticamente; en Laravel lo pedías explícito tras autenticar.

Puntos clave

  • session(): get/set/push/remove/destroy — API corta y directa.
  • with() en redirects = setFlashdata: vive UNA petición.
  • tempdata = flashdata con reloj (default 300 s).
  • make:migration --session genera la tabla ci_sessions oficial.
  • ID de sesión se regenera solo (timeToUpdate): fixation cubierta.

14 · Conexión MariaDB

Básico ~13 min

Abrimos la Parte III con el ritual de siempre: crear la base, apuntar credenciales en .env y verificar la conexión. La diferencia de Laravel es de NOMBRES, no de concepto — mismo grupo default, mismas llaves casi idénticas.

  • Crear la BD pedidos con charset utf8mb4 y usuario dedicado.
  • Configurar database.default.* en .env (claves EXACTAS).
  • Conectar con Config\Database::connect() y verificar con una query.
  • Entender $defaultGroup, DBPrefix y strictOn.

La base de datos primero

mysql -u root -p

CREATE DATABASE pedidos
CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
CREATE USER 'pedidos_app'@'localhost' IDENTIFIED BY 'ClaveSegura2026!';
GRANT ALL PRIVILEGES ON `pedidos`.* TO 'pedidos_app'@'localhost';
FLUSH PRIVILEGES;

Mismo criterio del manual Laravel: la app NO usa root; un usuario por proyecto con privilegios SOLO sobre su base.

Las credenciales en .env

# .env  (prefijo database.default + propiedad EXACTA)
database.default.hostname = localhost
database.default.database = pedidos
database.default.username = pedidos_app
database.default.password = ClaveSegura2026!
database.default.DBDriver = MySQLi
database.default.port     = 3306

El resto ya viene bien en app/Config/Database.php — la clase que el .env reemplaza propiedad por propiedad:

<?php
// app/Config/Database.php (recortado)
public array $default = [
    'hostname' => 'localhost',
    'username' => '',
    'password' => '',
    'database' => '',
    'DBDriver' => 'MySQLi',
    'DBPrefix' => '',            // prefijo comun de tablas
    'charset'  => 'utf8mb4',     // coincide con nuestra BD
    'DBCollat' => 'utf8mb4_general_ci',
    'strictOn' => false,
    'port'     => 3306,
];
Laravel (.env)CI4 (.env)
DB_CONNECTION=mysqldatabase.default.DBDriver = MySQLi
DB_DATABASE=… · DB_USERNAME=… · DB_PASSWORD=…database.default.database/username/password
DB_HOST · DB_PORTdatabase.default.hostname/port
DB_PREFIXdatabase.default.DBPrefix

Otros drivers válidos si algún día migras: Postgre, SQLite3, SQLSRV, OCI8 — el nombre va con mayúscula inicial exacta.

Conectar y verificar

Dos puertas a la misma conexión compartida:

<?php
$db    = \Config\Database::connect();       // conexion del grupo default
$forge = \Config\Database::forge();         // gemela para DDL (cap 15)

// prueba de fuego en una ruta temporal /db-check:
$version = $db->query('SELECT VERSION() AS v')->getRow()->v;
return "MariaDB {$version} — conexión OK";

Si ves el número de versión, las cuatro capas funcionan: .env → clase Config → conexión → servidor. El error más común sigue siendo el de siempre: credenciales fantasma — si cambias .env y no ves el efecto, recuerda que los valores se leen al instanciar; reinicia spark serve.

Tres propiedades que conviene entender hoy

  • $defaultGroup = 'default': puede haber MÁS grupos (reportes, testing) y elegir cuál usar por contexto.
  • strictOn: activa el modo estricto de MariaDB — con dinero DECIMAL en juego, la regla de la casa es true en producción.
  • DBPrefix: prefijo para TODAS las tablas (wp_ style). Lo dejamos vacío — nuestros nombres canónicos no necesitan disfraz.

Puntos clave

  • BD utf8mb4 + usuario dedicado sin root: ritual intacto.
  • .env: prefijo database.default + propiedad exacta de la clase.
  • Database::connect() comparte instancia; forge() para DDL.
  • Drivers: MySQLi | Postgre | SQLite3 | SQLSRV | OCI8.
  • strictOn=true cuando hay DECIMAL: integridad antes que silencio.

15 · Migraciones I: Forge y Fields

Intermedio ~16 min

La regla de la casa sigue en pie: el esquema vive versionado en migraciones, nunca a mano en el cliente SQL. Aquí no hay Schema:: ni Blueprint: hay un solo objeto — $this->forge — y un arreglo de Fields por tabla. Es más literal que Laravel, casi el CREATE TABLE escrito en PHP.

  • Generar migraciones con spark make:migration (timestamp incluido).
  • Definir tablas con addField() arreglo completo y atajos.
  • Recrear clientes y productos ESPEJANDO tienda_orm decisión por decisión.
  • Correr migrate y leer migrate:status.

Anatomía de una migración

php spark make:migration CreateClientes

# crea app/Database/Migrations/2026-08-24-091500_CreateClientes.php
<?php
namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class CreateClientes extends Migration
{
    public function up(): void    { /* crear */ }

    public function down(): void  { /* deshacer */ }
}

El timestamp del nombre define el ORDEN de ejecución; la clase debe ser única. up() construye, down() deshace — sin down() honesto no hay rollback honesto.

Fields: el vocabulario completo

Clave del arregloSignifica
'type' · 'constraint'INT · VARCHAR(120) — constraint = largo o enum de valores
'unsigned' · 'auto_increment'solo positivos · autonumérico
'null' => truepermite NULL (SIN esta clave la columna es NOT NULL)
'default' => valorvalor inicial; con RawSql acepta CURRENT_TIMESTAMP
'unique' => trueíndice único sobre la columna

Llaves: $this->forge->addKey('id', true) marca PRIMARY; addUniqueKey('email') índice único. Y createTable('clientes', true) crea con IF NOT EXISTS heredando charset utf8mb4 de tu conexión.

Clientes: primera tabla espejo

<?php
public function up(): void
{
    $this->forge->addField([
        'id'         => ['type' => 'INT', 'constraint' => 11,
                         'unsigned' => true, 'auto_increment' => true],
        'nombre'     => ['type' => 'VARCHAR', 'constraint' => 120],
        'email'      => ['type' => 'VARCHAR', 'constraint' => 150,
                         'unique' => true],
        'created_at' => ['type' => 'DATETIME', 'null' => true],
        'updated_at' => ['type' => 'DATETIME', 'null' => true],
    ]);
    $this->forge->addKey('id', true);
    $this->forge->createTable('clientes', true);
}

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

Productos: DECIMAL y stock unsigned

Las decisiones canónicas de tienda_orm se replican tal cual — dinero SIEMPRE DECIMAL, stock jamás negativo:

$this->forge->addField([
    'id'       => ['type' => 'INT', 'constraint' => 11,
                   'unsigned' => true, 'auto_increment' => true],
    'nombre'   => ['type' => 'VARCHAR', 'constraint' => 150],
    'precio'   => ['type' => 'DECIMAL', 'constraint' => '10,2'],
    'stock'    => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true,
                   'default' => 0],
    'activo'   => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1],
    ...created_at / updated_at...
]);
$this->forge->addKey('id', true);
$this->forge->createTable('productos', true);

Ejecutar e inspeccionar

php spark migrate

# Running migration... (App) CreateClientes
# Running migration... (App) CreateProductos
# Migraciones completadas!

php spark migrate:status

# Namespace | Version | Filename | Group | Migrated On | Batch
# App | ... | CreateClientes.php | default | 2026-08-24 | 1
Eco Eloquent/Laravel: addField ≙ $table->id()/string(); addKey(id,true) ≙ $table->id(); unique ≙ ->unique(). La diferencia real: aquí TODO es explícito — ningún tipo se deduce del nombre de método. Más teclado, cero magia.

Puntos clave

  • make:migration antepone timestamp = orden garantizado de ejecución.
  • Sin 'null'=true la columna nace NOT NULL: decisión visible.
  • Dinero DECIMAL(10,2), stock unsigned, activo TINYINT — canon intacto.
  • createTable(tabla, true): IF NOT EXISTS + charset de la conexión.
  • migrate:status muestra batch y fecha: tu bitácora del esquema.

16 · Migraciones II: llaves foráneas y alteraciones

Intermedio ~15 min

Completamos el esquema canónico de Pedidos — pedidos y pedido_detalles con sus FK RESTRICT y el precio congelado — y aprendemos a MODIFICAR tablas ya migradas. Spoiler del contraste: aquí no existe migrate:fresh; la familia es más corta y más prudente.

  • Declarar FK con addForeignKey y entender sus acciones por defecto.
  • Migrar pedidos + pedido_detalles espejando tienda_orm.
  • Alterar una tabla existente (addColumn) en una migración nueva.
  • Dominar la familia migrate: rollback -b, refresh, status.

Pedidos: ENUM, DECIMAL y la FK

<?php
// ...CreatePedidos.php  (up)
$this->forge->addField([
    'id'           => ['type' => 'INT', 'constraint' => 11,
                       'unsigned' => true, 'auto_increment' => true],
    'cliente_id'   => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
    'estado'       => ['type' => 'ENUM',
                       'constraint' => ['REGISTRADO', 'PAGADO', 'ANULADO'],
                       'default' => 'REGISTRADO'],
    'total'        => ['type' => 'DECIMAL', 'constraint' => '10,2',
                       'default' => 0.00],
    'fecha_pedido' => ['type' => 'DATETIME'],
    ...created_at / updated_at...
]);
$this->forge->addKey('id', true);
$this->forge->addKey('cliente_id');                 // indice para el JOIN

// firma: addForeignKey($campo, $tablaRef, $campoRef, $onUpdate, $onDelete, $nombre)
$this->forge->addForeignKey('cliente_id', 'clientes', 'id');
$this->forge->createTable('pedidos', true);

Omitir onUpdate/onDelete = RESTRICT en MariaDB: nadie borra un cliente con pedidos vivos — exactamente la decisión de tienda_orm que Laravel escribía restrictOnDelete(). Si algún día quieres CASCADE, se escribe EXPLÍCITO: addForeignKey(..., 'CASCADE', 'CASCADE').

Pedido_detalles: el precio congelado

$this->forge->addField([
    'id'             => [...id canonico...],
    'pedido_id'      => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
    'producto_id'    => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
    'cantidad'       => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
    'precio_unitario'=> ['type' => 'DECIMAL', 'constraint' => '10,2'], // CONGELADO
]);
$this->forge->addKey('id', true);
$this->forge->addKey('pedido_id');
$this->forge->addForeignKey('pedido_id',   'pedidos',   'id');
$this->forge->addForeignKey('producto_id', 'productos', 'id');
$this->forge->createTable('pedido_detalles', true);

Alterar lo ya migrado

Regla inviolable: migración aplicada NO se edita. ¿Necesitas una columna nueva? Nueva migración — el eco directo del cap 16 de Laravel, donde la foto nullable de productos llegó así:

php spark make:migration AddFotoAProductos
<?php
public function up(): void
{
    $this->forge->addColumn('productos', [
        'foto' => ['type' => 'VARCHAR', 'constraint' => 255,
                   'null' => true, 'after' => 'nombre'],
    ]);
}

public function down(): void
{
    $this->forge->dropColumn('productos', 'foto');
}

Hermanas útiles: modifyColumn() cambia tipo/largo (con clave 'name' renombra), dropColumn() elimina, processIndexes() añade índices a tabla existente.

La familia migrate completa

ComandoHaceEco Laravel
php spark migrateaplica pendientesmigrate
migrate:rollback -b2deshace UN lote (-b elige cuál)rollback --step
migrate:refreshrollback TODO + reaplicafresh/refresh
migrate:statusqué corrió, en qué batch, cuándomigrate:status

Cada ejecución queda agrupada en un batch registrado en la tabla migrations: rollback -b1 desharía hasta el primer lote. Y las preferencias viven en app/Config/Migrations.php ($enabled, $table).

No hay migrate:fresh (borrar todo y reconstruir): si necesitas empezar de cero, DROP DATABASE + CREATE + migrate — o refresh. En producción NINGUNA de las dos sin backup previo y ventana de mantenimiento: misma regla del cap 43 de Laravel.

Puntos clave

  • addForeignKey sin acciones = RESTRICT: integridad por defecto.
  • ENUM constraint = arreglo de valores: EstadosPedido canon.
  • precio_unitario congelado en detalles: la regla que nunca negocia.
  • Cambio de esquema = migración NUEVA; la vieja es historia sagrada.
  • Batches en tabla migrations: rollback -b es quirúrgico.

17 · Modelos CI4: CRUD integrado

Intermedio ~16 min

El modelo de CI4 es el puente perfecto entre tus dos mundos: como tu repositorio artesanal es explícito, y como Eloquent trae CRUD de fábrica con timestamps y protección contra mass assignment. Lo que NO trae son relaciones automáticas — eso, a propósito, lo escribiremos a mano en el cap 19.

  • Configurar un modelo: $table, $allowedFields, $useTimestamps.
  • Usar find/findAll/insert/update/delete/save y encadenar wheres.
  • Añadir callbacks con $beforeInsert y entender el contrato $data.
  • Proteger estados con $validationRules (canon EstadosPedido).

El esqueleto del PedidoModelo

php spark make:model PedidoModelo
# crea app/Models/PedidoModelo.php
# opciones utiles: --return entity|object|array · --table · --dbgroup
<?php
namespace App\Models;

use CodeIgniter\Model;

class PedidoModelo extends Model
{
    protected $table         = 'pedidos';
    protected $primaryKey    = 'id';
    protected $returnType    = 'array';       // u objeto/clase entidad
    protected $allowedFields = ['cliente_id', 'estado', 'total',
                                'fecha_pedido'];
    protected $useTimestamps = true;          // rellena created_at/updated_at
}

Tres traducciones inmediatas desde Eloquent:

ConceptoEloquent (Laravel)CI4 Model
Asignación masiva$fillable / $guarded$allowedFields (la PK jamás va)
TimestampsPUBLIC $timestamps = true$useTimestamps (mismos campos created_at/updated_at)
Formato de retornoobjetos siempre$returnType: array | object | Clase::class

CRUD de fábrica

<?php
$pedidos = new \App\Models\PedidoModelo();

// CREATE — retorna el ID nuevo por defecto:
$id = $pedidos->insert(['cliente_id' => 1, 'estado' => 'REGISTRADO',
                        'total' => 258.20, 'fecha_pedido' => date('Y-m-d H:i:s')]);

// READ:
$pedido   = $pedidos->find($id);            // fila o null
$pagados  = $pedidos->where('estado', 'PAGADO')->findAll();
$ultimos  = $pedidos->orderBy('fecha_pedido', 'DESC')->findAll(10);
$primero  = $pedidos->where('estado', 'ANULADO')->first();

// UPDATE y DELETE:
$pedidos->update($id, ['estado' => 'PAGADO']);
$pedidos->delete($id);

// save(): upsert segun si el arreglo lleva la primaryKey:
$pedidos->save(['id' => $id, 'estado' => 'PAGADO']);   // update
$pedidos->save(['cliente_id' => 2, ...]);              // insert

Regla fina del encadenamiento: los métodos del BUILDER (where, orderBy...) van primero y el método del MODELO (findAll/find/insert) SIEMPRE al final — así los callbacks del modelo sí se disparan.

Callbacks: eventos antes/después

Se declaran como arreglos de nombres de método. Cada callback recibe un arreglo con la clave 'data' y DEBE devolverlo:

<?php
protected $beforeInsert = ['ponerFechaPedido'];

protected function ponerFechaPedido(array $data): array
{
    if (! isset($data['data']['fecha_pedido'])) {
        $data['data']['fecha_pedido'] = date('Y-m-d H:i:s');
    }
    return $data;
}

Familia completa: $beforeInsert/$afterInsert, $beforeUpdate/$afterUpdate, $beforeFind/$afterFind, $beforeDelete/$afterDelete (+ variantes Batch). Es el gemelo de los eventos de Eloquent — creating/created/retrieved — pero declarado en propiedades.

Validación dentro del modelo

<?php
protected $validationRules = [
    'estado' => 'required|in_list[REGISTRADO,PAGADO,ANULADO]',
    'total'  => 'decimal|greater_equal[0]',
];

Si falla, insert/update/save retornan false y errors() explica por qué. El ENUM de la migración ya protege en MariaDB; esta capa da mensajes humanos ANTES de tocar la BD — doble defensa, misma filosofía del manual MVC.

Lo que deliberadamente NO hay: relaciones belongsTo ni casts. Para transformar filas existen clases Entity ($returnType = Entidad::class) que veremos con la API en el cap 27; las relaciones van a mano en el siguiente capítulo — decisión pedagógica del manual.

Puntos clave

  • $allowedFields ≙ $fillable: mass assignment cubierto.
  • insert() devuelve el ID; save() decide insert/update por la PK.
  • Builder primero, método del modelo AL FINAL (callbacks activos).
  • Callbacks reciben ['data' => ...] y lo devuelven: contrato fijo.
  • $validationRules: segunda muralla tras el ENUM de la migración.

18 · Datos de prueba: seeders y Fabricator

Intermedio ~15 min

Mismo dilema del cap 18 de Laravel: los datos canónicos (los 4 clientes y productos con nombre propio) deben ser DETERMINISTAS para que cada capítulo cuente la misma historia; el volumen extra puede ser aleatorio. Aquí resolvemos ambos con dos herramientas: seeders y Fabricator.

  • Crear seeders con make:seeder y ejecutarlos con db:seed.
  • Sembrar el dataset canónico determinista de Pedidos.
  • Generar filas de volumen con Fabricator::create().
  • Anidar seeders maestros con call().

Seeders: datos que cuentan la historia

php spark make:seeder ClientesSeeder
# crea app/Database/Seeds/ClientesSeeder.php
<?php
namespace App\Database\Seeds;

use CodeIgniter\Database\Seeder;

class ClientesSeeder extends Seeder
{
    public function run(): void
    {
        // dataset canonico — DETERMINISTA, como en Laravel:
        $this->db->table('clientes')->insertBatch([
            ['nombre' => 'Ana Quispe',    'email' => 'ana@example.com'],
            ['nombre' => 'Luis Ramos',    'email' => 'luis@example.com'],
            ['nombre' => 'Carmen Rojas',  'email' => 'carmen@example.com'],
            ['nombre' => 'Diego Salazar', 'email' => 'diego@example.com'],
        ]);
    }
}
php spark db:seed ClientesSeeder  # CON el nombre completo de la clase

insertBatch() hace un multi-insert real. Y el seeder maestro anida a los demás:

<?php
// app/Database/Seeds/DatabaseSeeder.php — orquestador
class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call('ClientesSeeder');
        $this->call('ProductosSeeder');
        $this->call('PedidosSeeder');
    }
}

// una sola orden siembra TODO:
// php spark migrate && php spark db:seed DatabaseSeeder

PedidosSeeder: coherencia canónica

Los pedidos iniciales replican el estado conocido del manual anterior — estados mezclados, detalles con precio congelado:

<?php
public function run(): void
{
    $this->db->table('pedidos')->insertBatch([
        ['cliente_id' => 1, 'estado' => 'PAGADO',     'total' => 258.20,
         'fecha_pedido' => '2026-08-20 10:00:00'],
        ['cliente_id' => 2, 'estado' => 'PAGADO',     'total' =>  62.85,
         'fecha_pedido' => '2026-08-21 11:30:00'],
        ['cliente_id' => 3, 'estado' => 'ANULADO',    'total' => 120.00,
         'fecha_pedido' => '2026-08-22 15:45:00'],
        ['cliente_id' => 1, 'estado' => 'REGISTRADO', 'total' =>  89.90,
         'fecha_pedido' => '2026-08-23 09:10:00'],
    ]);

    $this->db->table('pedido_detalles')->insertBatch([
        ['pedido_id' => 1, 'producto_id' => 1, 'cantidad' => 1,
         'precio_unitario' => 258.20],   // congelado al vender
    ]);
}

Fabricator: volumen cuando lo necesites

Fabricator genera filas usando formatters de Faker (incluido) e INSERTA vía tu modelo — respeta allowedFields, timestamps y callbacks:

<?php
use CodeIgniter\Test\Fabricator;

$fabrica = new Fabricator(\App\Models\ProductoModelo::class);
$fabrica->setOverrides(['activo' => 1]);
$productos = $fabrica->create(28);   // INSERTA y devuelve las filas

// solo generar SIN tocar BD (para tests):
$borrador = $fabrica->make(5);

Puedes definir la plantilla de campos en el PROPIO modelo con un método fake(Faker\Generator &$faker); sin ella, Fabricator adivina por nombre de columna (email → email válido, etc.). El helper corto es fake($modelo) — lo usaremos en los tests del cap 40.

La decisión de la casa, otra vez: seeders explícitos para lo CANÓNICO (números que citamos capítulo a capítulo), Fabricator para VOLUMEN anónimo. Mezclarlos al revés rompe la continuidad de ejemplos entre tandas.

Puntos clave

  • make:seeder crea ClaseSeeder.php; db:seed pide el nombre COMPLETO.
  • Determinista lo canónico, aleatorio lo voluminoso.
  • call() compone un DatabaseSeeder orquestador.
  • Fabricator::create() inserta via modelo (callbacks incluidos); make() no toca BD.
  • fake() helper = atajo de Fabricator para tests.

19 · Relaciones sin magia: joins a mano

Intermedio ~15 min

Aquí está la diferencia pedagógica grande con Eloquent. No hay belongsTo() ni hasMany(): las relaciones se escriben como métodos que construyen JOINS explícitos sobre el Query Builder. Más teclado — pero cada consulta queda visible, medible y sin N+1 sorpresivos.

  • Encadenar el Query Builder dentro de métodos de modelo.
  • Escribir conCliente(), dePedido() y conteos con GROUP BY.
  • Comparar contra with()/belongsTo de Eloquent pieza por pieza.
  • Mantener el presupuesto de queries como regla viva.

Query Builder: la caja de herramientas

Se abre con $db->table('pedidos') o, dentro de un modelo, directamente con $this->. Encadenas condiciones y cierras con un método final:

Builder CI4HacePrimo Eloquent
->select('a, b') / selectCount('x')/selectSum('total')columnas/agregadosselect()/withCount()
->join('t', 'cond', 'left')JOIN (inner/left/right...)join() o belongsTo
->where/orWhere/whereIn/likecondiciones (valores escapados)mismos nombres
->groupBy · orderBy('col','DESC') · limit(10)orden y cortegroupBy/latest/take
->get() -> getResult()/getRow()ejecuta y devuelve filasget()->rows
->countAllResults()cuenta lo filtradocount()

belongsTo, escrito a mano

<?php
// app/Models/PedidoModelo.php — metodo de relacion
public function conCliente(): array
{
    return $this->select('pedidos.*, clientes.nombre AS cliente_nombre')
                ->join('clientes', 'clientes.id = pedidos.cliente_id')
                ->orderBy('pedidos.fecha_pedido', 'DESC')
                ->findAll();
}

// uso en el controlador (cap 20):
$filas = $this->pedidos->conCliente();
// cada fila trae cliente_nombre listo para esc() en la vista

En Laravel esto era Pedido::with('cliente') — lazy/eager loading automático vía FK convención. Aquí declaras el JOIN tú: una sola query SIEMPRE. El N+1 clásico no existe si sigues este patrón.

hasMany y el precio congelado lado a lado

<?php
// app/Models/DetalleModelo.php
class DetalleModelo extends Model
{
    protected $table = 'pedido_detalles';
    protected $allowedFields = ['pedido_id', 'producto_id',
                                'cantidad', 'precio_unitario'];

    /** Detalles del pedido + nombre del producto vendido. */
    public function dePedido(int $pedidoId): array
    {
        return $this->select('pedido_detalles.*, productos.nombre AS producto')
                    ->join('productos', 'productos.id = pedido_detalles.producto_id')
                    ->where('pedido_detalles.pedido_id', $pedidoId)
                    ->findAll();
    }
}

Y el agregado con GROUP BY — el equivalente artesanal de withCount():

<?php
/** Clientes con su numero de pedidos. */
public function clientesConPedidos(): array
{
    return $this->db->table('clientes')
        ->select('clientes.*, COUNT(pedidos.id) AS total_pedidos', false)
        ->join('pedidos', 'pedidos.cliente_id = clientes.id', 'left')
        ->groupBy('clientes.id')
        ->orderBy('total_pedidos', 'DESC')
        ->get()->getResultArray();
}
El presupuesto de queries sigue mandando: cada método de relación = EXACTAMENTE una consulta. Si una vista necesita dos cosas distintas, son dos llamadas claras — no un eager load oculto que dispara tres queries inesperadas. Medible, predecible, tuyo.

Puntos clave

  • No hay relaciones automáticas: joins explícitos en métodos.
  • select con alias (AS cliente_nombre) prepara datos para la vista.
  • LEFT JOIN + GROUP BY = withCount() hecho a mano.
  • Valores where() siempre escapados por el builder: SQLi cubierta.
  • Cada método de relación = una query: presupuesto transparente.

20 · Listado y detalle de Pedidos

Intermedio ~15 min

Abrimos la Parte IV: el CRUD web que en Laravel construimos con resource controllers, aquí se arma pieza a pieza. Empezamos por el READ: listado con cliente embebido y detalle que muestra el precio congelado junto al precio actual — la historia completa de un pedido.

  • Implementar index() y show() sobre los modelos de los caps 17–19.
  • Manejar id inexistente con PageNotFoundException (404 real).
  • Pintar listas con esc(), moneda() y el cell AlertaEstadoCell del cap 10.
  • Navegar con url_to() sobre rutas nombradas.

Rutas y controlador

<?php
// app/Config/Routes.php — nombres canonicos para url_to():
$routes->get('/pedidos', 'PedidoControlador::index',
             ['as' => 'pedidos.index']);
$routes->get('pedidos/(:num)', 'PedidoControlador::show/$1',
             ['as' => 'pedidos.show']);
<?php
// app/Controllers/PedidoControlador.php (recortado)
namespace App\Controllers;

use App\Models\PedidoModelo;
use App\Models\DetalleModelo;
use CodeIgniter\Exceptions\PageNotFoundException;

class PedidoControlador extends BaseController
{
    protected $helpers = ['pedidos'];
    private PedidoModelo $pedidos;
    private DetalleModelo $detalles;

    public function initController($request, $response, $logger)
    {
        parent::initController($request, $response, $logger);
        $this->pedidos  = new PedidoModelo();
        $this->detalles = new DetalleModelo();
    }

    public function index(): string
    {
        return view('pedidos/lista', [
            'filas' => $this->pedidos->conCliente(),
        ]);
    }

    public function show(int $id): string
    {
        $pedido = $this->pedidos->find($id);
        if ($pedido === null) {
            throw PageNotFoundException::forPageNotFound(
                "Pedido #$id no existe.");
        }
        return view('pedidos/detalle', [
            'pedido'   => $pedido,
            'detalles' => $this->detalles->dePedido($id),
        ]);
    }
}

Dos consultas exactas para el detalle: una para el pedido, una para sus detalles. Presupuesto visible y cumplido — como prometió el cap 19.

La lista: tabla + badges

<!-- app/Views/pedidos/lista.php -->
<?= $this->extend('layout_pedidos') ?>
<?= $this->section('contenido') ?>

<table class="table">
  <thead><tr><th>#</th><th>Cliente</th><th>Estado</th><th>Total</th></tr></thead>
  <tbody>
  <?php foreach ($filas as $f): ?>
    <tr>
      <td><a href="<?= url_to('pedidos.show', $f['id']) ?>">
            #<?= (int) $f['id'] ?></a></td>
      <td><?= esc($f['cliente_nombre']) ?></td>
      <td><?= view_cell('App\Views\Cells\AlertaEstadoCell',
                        ['estado' => $f['estado']]) ?></td>
      <td><?= moneda($f['total']) ?></td>
    </tr>
  <?php endforeach ?>
  </tbody>
</table>

<?= $this->endSection() ?>

Tres payoffs en una vista: url_to() del cap 7 genera el enlace sin hardcodear URIs; view_cell() del cap 10 pinta el badge con color por estado; moneda() del cap 6 formatea el DECIMAL.

El detalle: precio vendido vs precio hoy

<!-- app/Views/pedidos/detalle.php (recortado) -->
<p>Cliente: <b><?= esc($pedido['cliente_id']) ?></b> —
   <?= view_cell('App\Views\Cells\AlertaEstadoCell',
                 ['estado' => $pedido['estado']]) ?></p>

<table class="table">
<?php foreach ($detalles as $d): ?>
  <tr>
    <td><?= esc($d['producto']) ?></td>
    <td>x<?= (int) $d['cantidad'] ?></td>
    <td>vendido: <?= moneda($d['precio_unitario']) ?></td>
  </tr>
<?php endforeach ?>
</table>
<p class="fs-4 fw-bold">Total: <?= moneda($pedido['total']) ?></p>
Regla de oro intacta: TODO dato dinámico pasa por esc() o (int). En Blade {{ }} lo hacía el framework; aquí es TU disciplina — y por eso el manual MVC te entrenó así desde el día uno.

Puntos clave

  • Rutas nombradas pedidos.index/show + url_to(): refactor-proof.
  • find() null → PageNotFoundException: 404 honesto, no error feo.
  • index = 1 query (join), show = 2 queries claras.
  • view_cell + helper propio: las piezas pequeñas ya trabajan juntas.
  • esc()/(int) en CADA echo: XSS imposible por descuido.

21 · Alta con transacción: pedido y detalles

Intermedio ~16 min

El caso de uso que justifica las transacciones desde el manual MVC: un pedido NO existe sin sus detalles ni sin su stock actualizado. Si la tercera consulta falla, las dos primeras deben deshacerse — todo o nada, como en el cap 21 de Laravel pero con la API de CI4.

  • Escribir guardar() con transStart/transComplete sobre la conexión del modelo.
  • Congelar precio_unitario y recalcular total del lado servidor.
  • Detectar fallo con transStatus() y conocer transException().
  • Recordar por qué InnoDB es requisito (MyISAM no negocia).

El formulario: cliente más líneas de detalle

<!-- app/Views/pedidos/nuevo.php (recortado) -->
<form action="<?= site_url('pedidos') ?>" method="post">
    <?= csrf_field() ?>
    <select name="cliente_id">
      <?php foreach ($clientes as $c): ?>
        <option value="<?= (int) $c['id'] ?>"><?= esc($c['nombre']) ?></option>
      <?php endforeach ?>
    </select>

    <input type="number" name="items[0][producto_id]" min="1" required>
    <input type="number" name="items[0][cantidad]"   min="1" value="1">

    <button>Registrar</button>
</form>

La notación items[][producto_id] produce un arreglo anidado en PHP — el gemelo de los inputs array que usabas en el artesanal.

guardar(): todo o nada

<?php
public function guardar(): RedirectResponse   // POST /pedidos
{
    $post = $this->request->getPost();

    // validacion basica del encabezado:
    if (! $this->validateData($post, ['cliente_id' => 'required|is_natural_no_zero'])) {
        return redirect()->back()->withInput()
            ->with('errors', $this->validator->getErrors());
    }

    $items = array_filter((array) ($post['items'] ?? []));

    $db = $this->pedidos->db;            // LA conexion del modelo
    $db->transStart();
    try {
        $total = 0.00; $lineas = [];

        foreach ($items as $item) {
            $prod = $this->productos->find((int) $item['producto_id']);
            if ($prod === null || $prod['stock'] < (int) $item['cantidad']) {
                throw new \RuntimeException('Producto invalido o sin stock.');
            }
            $congelado = (float) $prod['precio'];       // PRECIO CONGELADO
            $total += $congelado * (int) $item['cantidad'];

            $lineas[] = ['pedido_id' => null, 'producto_id' => $prod['id'],
                         'cantidad' => (int) $item['cantidad'],
                         'precio_unitario' => $congelado];
            $this->productos->update($prod['id'],
                         ['stock' => $prod['stock'] - (int) $item['cantidad']]);
        }

        $pedidoId = $this->pedidos->insert(['cliente_id' => (int) $post['cliente_id'],
                                            'estado' => 'REGISTRADO',
                                            'total' => $total,
                                            'fecha_pedido' => date('Y-m-d H:i:s')]);

        foreach ($lineas as &$l) { $l['pedido_id'] = $pedidoId; }
        $this->detalles->insertBatch($lineas);
    } catch (\Throwable $e) {
        // dentro de transStart NO lanza excepciones: marca el fallo...
        $db->transComplete();
        return redirect()->back()->withInput()
            ->with('error', $e->getMessage());
    }
    $db->transComplete();                 // ...y aqui decide commit o rollback

    return redirect()->to("/pedidos/$pedidoId")
        ->with('success', "Pedido #$pedidoId registrado.");
}

Las reglas del juego

  • Desde 4.3 las consultas fallidas NO lanzan excepción durante una transacción — marcan el estado. Por eso el patrón canónico pregunta después:
<?php
$db->transStart();
...consultas...
$db->transComplete();

if ($db->transStatus() === false) {
    log_message('error', 'Alta de pedido fallida: rollback aplicado.');
}

Si prefieres estilo excepcional (como DB::transaction de Laravel): requiere DBDebug true y se pide explícito:

<?php
use CodeIgniter\Database\Exceptions\DatabaseException;

try {
    $db->transException(true)->transStart();
    ...
    $db->transComplete();
} catch (DatabaseException $e) {
    // ya hizo rollback automaticamente
}
InnoDB o nada: MyISAM ignora transacciones y aceptaría un pedido sin detalles. Nuestras migraciones crean tablas InnoDB (cap 15–16) — por eso esta garantía funciona. Y el modo estricto de CI4 viene activado: si una transacción falla, las siguientes también ruedan hacia atrás.

Puntos clave

  • $modelo->db te da la conexión compartida para transStart.
  • Precio congelado y total recalculados SIEMPRE del lado servidor.
  • Sin excepciones por defecto: preguntar transStatus() tras Complete.
  • transException(true) = estilo Laravel, requiere DBDebug.
  • Stock descontado DENTRO de la misma transacción: nada queda huérfano.

22 · Edición y borrado con reglas de negocio

Intermedio ~15 min

Completamos el CRUD. La novedad aquí no es técnica — es la regla canónica del dominio que arrastramos desde el MVC: los estados terminales no se tocan. Un pedido PAGADO o ANULADO solo puede anularse pasando a ANULADO; jamás editarse ni borrarse.

  • Implementar edit()/actualizar() reutilizando el grupo de validación.
  • Aplicar la regla de estados terminales en el controlador.
  • Borrar SOLO por POST (CSRF) y solo lo permitido.
  • Conocer $useSoftDeletes como alternativa documentada.

La regla en código, no en comentarios

<?php
// app/Models/PedidoModelo.php — el modelo guarda las reglas del dominio
public const EDITABLES = ['REGISTRADO'];

public function esEditable(int $id): bool
{
    $pedido = $this->find($id);
    return $pedido !== null && in_array($pedido['estado'], self::EDITABLES, true);
}

Edición

<?php
public function editar(int $id): string
{
    if (! $this->pedidos->esEditable($id)) {
        throw PageNotFoundException::forPageNotFound('Pedido no editable.');
    }
    return view('pedidos/editar', [
        'pedido' => $this->pedidos->find($id),
    ]);
}

public function actualizar(int $id): RedirectResponse   // POST
{
    if (! $this->pedidos->esEditable($id)) {
        return redirect()->back()->with('error', 'Estado terminal: inmutable.');
    }

    // grupo 'pedido' definido en Config/Validation.php (cap 12)
    $datos = $this->request->getPost();
    $validacion = service('validation');
    if (! $validacion->run($datos, 'pedido')) {
        return redirect()->back()->withInput()
            ->with('errors', $validacion->getErrors());
    }

    $this->pedidos->update($id, [
        'cliente_id' => (int) $datos['cliente_id'],
        'total'      => (float) $datos['total'],
    ]);
    return redirect()->to(url_to('pedidos.show', $id))
        ->with('success', "Pedido #$id actualizado.");
}

El formulario editar.php es el gemelo de nuevo.php pero con value="": old() primero (si hubo errores) y valor actual como respaldo — el patrón de repoblado del cap 12 completado.

Borrado quirúrgico

Doble candado: método POST (el filtro csrf global lo protege) y estado REGISTRADO:

<form action="<?= site_url("pedidos/{$pedido['id']}/eliminar") ?>"
      method="post"
      onsubmit="return confirm('¿Eliminar este pedido?');">
    <?= csrf_field() ?>
    <button class="btn btn-danger btn-sm">Eliminar</button>
</form>
<?php
// routes: $routes->post('pedidos/(:num)/eliminar', 'PedidoControlador::eliminar/$1');

public function eliminar(int $id): RedirectResponse
{
    $pedido = $this->pedidos->find($id);

    if ($pedido === null || $pedido['estado'] !== 'REGISTRADO') {
        return redirect()->back()
            ->with('error', 'Solo los pedidos REGISTRADO se eliminan.');
    }

    // detalles primero: FK RESTRICT exige orden correcto
    $this->detalles->where('pedido_id', $id)->delete();
    $this->pedidos->delete($id);

    return redirect()->to(url_to('pedidos.index'))
        ->with('success', "Pedido #$id eliminado.");
}

¿Y anular un PAGADO? No es borrar: es transición de estado — endpoint propio que llegará con la API (cap 29). Borrado físico para ventas históricas jamás.

Soft deletes: la alternativa oficial

Si tu dominio necesitara papelera, el modelo lo trae:

PiezaRequisitoEfecto
$useSoftDeletes = truecolumna deleted_at nullabledelete() marca fecha en vez de borrar
$deletedField = 'deleted_at'(default)find/findAll excluyen borrados automáticamente
->withDeleted() / ->onlyDeleted()en consultasincluyen / muestran SOLO borrados
delete($id, true)purgaborrado permanente explícito

Nuestro esquema canónico NO tiene deleted_at — decisión tomada en tienda_orm. Si algún día la necesitas, es una migración addColumn + una propiedad.

Puntos clave

  • Estados terminales inmutables: la regla vive en constantes del modelo.
  • old('campo', valorActual): errores primero, dato real de respaldo.
  • Borrar solo por POST + CSRF + estado REGISTRADO.
  • FK RESTRICT dicta orden: detalles antes que pedido.
  • Soft deletes disponibles pero no necesarias: canon intacto.

23 · Query Builder profundo: filtros y subconsultas

Intermedio ~15 min

En Laravel llamábamos scopes a los métodos de consulta reutilizables (Pedido::estado('PAGADO')). Aquí la idea idéntica se escribe como métodos normales del modelo que devuelven el builder — y cuando la lógica lo pide, subconsultas documentadas y todo.

  • Escribir métodos-filtro reutilizables (los scopes de la casa).
  • Componerlos encadenados sin repetir SQL.
  • Usar una subconsulta dentro de where() con la sintaxis oficial.
  • Agregar con selectSum/selectCount para el dashboard.

Filtros reutilizables en el modelo

<?php
// app/Models/PedidoModelo.php
public function porEstado(string $estado)
{
    return $this->where('estado', $estado);
}

public function buscar(string $termino)
{
    return $this->select('pedidos.*, clientes.nombre AS cliente_nombre')
                ->join('clientes', 'clientes.id = pedidos.cliente_id')
                ->like('clientes.nombre', $termino);   // %termino% both sides
}

public function entreFechas(string $desde, string $hasta)
{
    return $this->where('fecha_pedido >=', $desde . ' 00:00:00')
                ->where('fecha_pedido <=', $hasta . ' 23:59:59');
}

Cada método devuelve el builder, no resultados — por eso se componen:

$resultado = $this->pedidos->buscar('ana')
                          ->porEstado('PAGADO')
                          ->orderBy('fecha_pedido', 'DESC')
                          ->findAll(20);

El eco exacto de Pedido::buscar('ana')->estado('PAGADO'): el patrón sobrevive, solo cambia quién construye el WHERE.

Subconsulta oficial en where()

¿Pedidos por encima del promedio? La doc acepta un closure que recibe otro builder, o un builder directo:

<?php
use CodeIgniter\Database\BaseBuilder;

public function sobrePromedio(): array
{
    return $this->where('total >', static function (BaseBuilder $b) {
        $b->select('AVG(total)', false)->from('pedidos');
    })->findAll();
}

El segundo argumento false del select() evita escapar AVG como columna. También existen whereIn/closure, selectSubquery() para columnas calculadas y fromSubquery() para tablas derivadas.

Agregados: el resumen por estado

Este método alimentará al dashboard del cap 25 — UNA query para todo:

<?php
/** [ ['estado'=>'PAGADO','cuantos'=>'2','importe'=>'321.05'], ... ] */
public function resumenPorEstado(): array
{
    return $this->select('estado')
                ->selectCount('id', 'cuantos')
                ->selectSum('total', 'importe')
                ->groupBy('estado')
                ->get()->getResultArray();
}

Firma de los agregados: primer parámetro la columna, segundo opcional el alias (selectSum('total', 'importe') → SUM(total) AS importe).

Dónde vive cada cosa

NecesidadDóndePor qué
Filtro reutilizable simplemétodo del modelocompone con otros y cierra en findAll/paginate
Consulta con JOIN + aliasmétodo del modelola vista recibe datos listos
Reporte one-off complejo$db->table() directono ensuciar el modelo de usos únicos
Subconsulta puntualclosure en where()sintaxis oficial, legible
Regla de la casa: si un WHERE aparece dos veces en el código, merece método. Si aparece en dos modelos, merece clase propia en app/Libraries/ — igual que decidimos en Eloquent.

Puntos clave

  • Métodos-filtro devuelven $this: composición libre.
  • like() busca %término% por defecto (both sides).
  • Subquery = closure BaseBuilder o builder directo en where().
  • selectSum/Count(col, alias): agregados con nombre para vistas.
  • resumenPorEstado(): 1 query alimenta todo el dashboard.

24 · Paginación con Pager

Intermedio ~14 min

Tercera implementación de paginar(): la artesanal (cap 20 del MVC) contaba filas con COUNT y calculaba OFFSET a mano; la de Laravel era paginate(15). La de CI4 es la más corta de las tres — el método vive EN el modelo y los links se pintan solos.

  • Usar paginate($porPagina) y exponer $pager a la vista.
  • Renderizar links() / simpleLinks() con sus plantillas oficiales.
  • Conservar filtros en cada página con only().
  • Componer paginación con los métodos-filtro del cap 23.

El mínimo absoluto

<?php
// controlador:
public function index(): string
{
    return view('pedidos/lista', [
        'filas' => $this->pedidos->paginate(20),   // pagina actual automatica
        'pager' => $this->pedidos->pager,           // objeto para links()
    ]);
}

paginate() es método del MODEL (usa su builder interno): lee la página desde la query string ?page=2, aplica LIMIT/OFFSET y corre el COUNT en paralelo. La firma completa: paginate($perPage, $group, $page, $segment) — grupo para varios paginadores por vista, segment para URLs tipo /pedidos/3 en vez de ?page=3.

<!-- app/Views/pedidos/lista.php — al final de la tabla: -->
<?= $pager->links() ?>

Eso pinta la plantilla default_full: Primera/Anterior/numeradas/ Siguiente/Última, con Bootstrap-friendly markup. Variante minimalista: $pager->simpleLinks() (solo «Anterior/Siguiente»).

Filtros + paginación juntos

Los métodos-filtro devuelven el builder, así que paginate() encadena al final:

<?php
public function index(): string
{
    $termino = $this->request->getGet('q') ?? '';

    $consulta = $termino !== ''
        ? $this->pedidos->buscar($termino)
        : $this->pedidos;

    return view('pedidos/lista', [
        'filas'  => $consulta->orderBy('fecha_pedido', 'DESC')->paginate(20),
        'pager'  => $this->pedidos->pager,
        'q'      => $termino,
    ]);
}

Peligro inmediato: al pasar de página 2 con filtro activo, los links perderían ?q=ana. Para eso existe only():

<?= $pager->only(['q'])->links() ?>
<!-- genera ?q=ana&page=2 — conserva SOLO lo listado -->

Plantillas y control fino

HerramientaHace
$pager->links('grupo', 'plantilla')renderiza; plantillas registradas en Config\Pager ($templates)
$pager->setSurroundCount(2)cuántos números a cada lado del actual
$pager->hasPrevious()/hasNext(), getFirst()/getLast()para armar TU navegación a mano
$pager->makeLinks($page, $perPage, $total)paginación manual total (cuando no usas paginate())

Las tres paginaciones, lado a lado

ManualCódigoVista
MVC artesanalpaginado() con COUNT+LIMIT+OFFSET manualeslinks escritos a dedo
Laravel->paginate(15)->withQueryString(){{ $x->links() }}
CI4->paginate(20)<?= $pager->only([...])->links() ?>
Regla de la casa intacta: per-page SIEMPRE explícito. paginate() sin argumento usa un default que nadie memorizó — escribe paginate(20). Y paginate() solo funciona sobre Model/builder, jamás sobre una $db->query() cruda.

Puntos clave

  • paginate(N) en el modelo: lee ?page=, cuenta, corta.
  • $model->pager a la vista → links() o simpleLinks().
  • only(['q']) conserva filtros entre páginas.
  • Se compone con métodos-filtro: buscar()->paginate().
  • makeLinks() para casos fuera del modelo.

25 · Dashboard integrador

Avanzado ~16 min

Cierre de la Parte IV: el panel que junta TODO lo aprendido — agregados en una query, filtros del cap 23, el cell cacheado del cap 10, helpers y presupuesto de queries contabilizado. El mismo panel del cap 25 de Laravel, ahora con otra ropa.

  • Construir PanelControlador con 4 consultas exactas.
  • Alimentar métricas desde resumenPorEstado() (cap 23).
  • Cachear el bloque costoso con view_cell + TTL.
  • Auditar el presupuesto de queries como práctica permanente.

El controlador: cada dato, su consulta

<?php
// app/Controllers/PanelControlador.php (recortado)
public function index(): string
{
    $resumen = $this->pedidos->resumenPorEstado();     // Q1: GROUP BY estado

    // indexar por estado para lectura directa:
    $porEstado = [];
    foreach ($resumen as $fila) {
        $porEstado[$fila['estado']] = $fila;
    }

    return view('panel/index', [
        'porEstado'   => $porEstado,                          // Q1 reutilizada
        'ingresos'    => $porEstado['PAGADO']['importe'] ?? 0,
        'stockBajo'   => $this->productos->where('activo', 1)
                                       ->where('stock <=', 5)
                                       ->countAllResults(),   // Q2
        'topClientes' => $this->clientes->clientesConPedidos(),// Q3
    ]);
}

El total del pedido PAGADO ya vive dentro del resumen agrupado — no hace falta una query extra de SUM. Cuatro bloques, cuatro consultas, cero duplicación:

QueryAlimentaCosto
Q1 resumenPorEstado()tarjetas por estado + ingresos totalesGROUP BY único
Q2 stock bajo activosalerta de reposiciónCOUNT filtrado
Q3 clientesConPedidos()ranking LEFT JOIN+GROUP BY1 join
— lista reciente —últimos pedidos paginate(5)LIMIT 5 + count

El cell cacheado: métricas caras fuera del camino

El ranking de clientes no necesita frescura al segundo. Lo encapsulamos en un Simple Cell con TTL de 300 segundos — la misma idea que view(['cache' => 300]) del cap 9, pero solo para el BLOQUE:

<?php
// app/Cells/Metricas.php
namespace App\Cells;

class Metricas
{
    public static function rankingClientes(int $limit = 5): string
    {
        $clientes = model(\App\Models\ClienteModelo::class)
            ->clientesConPedidos();

        $html = '<ol class="list-group">';
        foreach ($clientes as $c) {
            $html .= '<li>' . esc($c['nombre']) . ' — '
                   . (int) $c['total_pedidos'] . ' pedidos</li>';
        }
        return $html . '</ol>';
    }
}
<!-- app/Views/panel/index.php (recortado) -->
<div class="row g-3">
  <?php foreach (['REGISTRADO','PAGADO','ANULADO'] as $estado): ?>
    <div class="col-md-4">
      <div class="p-3 border rounded-3">
        <?= view_cell('App\Views\Cells\AlertaEstadoCell',
                      ['estado' => $estado]) ?>
        <p class="display-6 mb-0">
          <?= (int) ($porEstado[$estado]['cuantos'] ?? 0) ?></p>
      </div>
    </div>
  <?php endforeach ?>
</div>

<!-- bloque cacheado: TTL 300 s + id propio -->
<h3>Top clientes</h3>
<?= view_cell('App\Cells\Metricas::rankingClientes', '', 300, 'top-clientes') ?>

El presupuesto, siempre a la vista

# primer render: Q1 + Q2 + Q3 + lista = 4 queries
# render cacheado: Q1 + Q2 + [cache hit top-clientes] + lista = 3 queries
# objetivo cumplido: dashboard completo <= 5 queries
Eco del dashboard Laravel: whereHas se volvió JOIN explícito, withCount() se volvió GROUP BY a mano, Cache::remember() se volvió view_cell con TTL. Distintas herramientas, idéntico diseño: medir antes de optimizar y nunca consultar lo que ya tienes.

Puntos clave

  • Indexar resultados agrupados evita queries repetidas por estado.
  • model(Clase::class) helper: instancia compartida del modelo.
  • view_cell(ttl, id) cachea SOLO el bloque costoso.
  • Dashboard completo en ≤5 queries: presupuesto documentado.
  • Mismo diseño que Laravel, vocabulario CI4: patrón transferido.

26 · API REST: resource() y respuestas JSON

Avanzado ~16 min

Abrimos la Parte V con la misma promesa del cap 26 de Laravel: exponer el dominio Pedidos por HTTP sin sesiones ni vistas — solo verbos, JSON y códigos de estado honestos. CI4 lo resuelve con resource() para las rutas y un trait de respuestas que ya trae el ResourceController.

  • Generar el CRUD completo de rutas con resource() y sus opciones.
  • Entender el mapeo verbo→método del ResourceController.
  • Dominar los métodos respond*/fail* y sus códigos HTTP.
  • Negociar formato con el header Accept.

Una línea, siete rutas

<?php
// app/Config/Routes.php
$routes->group('api', ['namespace' => 'App\Controllers\Api'],
    static function ($routes) {
        $routes->resource('pedidos', [
            'controller'  => 'PedidoControlador',
            'placeholder' => '(:num)',      // ids numericos, no (:segment)
            'except'      => 'new,edit',    // una API no tiene formularios HTML
            'filter'      => 'api-token',   // cap 28
        ]);
    });
VerboURIMétodo
GET/api/pedidosindex()
GET/api/pedidos/5show($id)
POST/api/pedidoscreate()
PUT/PATCH/api/pedidos/5update($id)
DELETE/api/pedidos/5delete($id)
except new,edit elimina las dos rutas de formularios

ResourceController: la base API

<?php
// app/Controllers/Api/PedidoControlador.php
namespace App\Controllers\Api;

use CodeIgniter\RESTful\ResourceController;

class PedidoControlador extends ResourceController
{
    protected $modelName = 'App\Models\PedidoModelo';
    protected $format    = 'json';

    public function index()
    {
        return $this->respond($this->model->findAll());   // $model ya instanciado
    }
}

El padre trae $this->model construido desde $modelName y el trait de respuestas activo. El hermano ResourcePresenter es para vistas/formularios — nuestra API no lo necesita.

Las respuestas canónicas

Método del traitHTTPCuándo
respond($data, 200)200éxito genérico
respondCreated($data)201recurso creado
respondDeleted($data)200borrado exitoso
respondNoContent()204ok sin cuerpo
failUnauthorized()401token ausente o inválido (reintenta con credenciales)
failForbidden()403autenticado pero prohibido (no insistas)
failNotFound()404recurso inexistente
failValidationErrors($errors)400reglas incumplidas
failResourceExists/Gone/TooManyRequests/failServerError409/410/400+/500casos especiales

fail() devuelve SIEMPRE la misma estructura de tres llaves — tu cliente JS sabe dónde buscar:

{
    "status": 400,
    "code": null,
    "messages": {"cliente_id": "The cliente_id field is required."}
}

Negociación de contenido

$format='json' fija el formato; si fuera null, CI4 negocia con el header Accept del cliente contra app/Config/Format.php (json y xml de fábrica; si no hay coincidencia gana el primero de la lista). Puedes forzarlo por respuesta también:

return $this->setResponseFormat('json')->respond($datos);
# prueba de humo con curl:
curl -H "Accept: application/json" http://localhost:8080/api/pedidos

# [{"id":1,"cliente_id":1,"estado":"PAGADO",...}, ...]
Eco Laravel: Route::apiResource ≙ resource(… except new, edit); JsonResource ≙ transformers (cap 27); response()->json() ≙ respond(). Y la misma regla de casa: la API es OTRO cliente del mismo dominio — nada de lógica nueva duplicada.

Puntos clave

  • resource() = 7 rutas; placeholder (:num) + except new,edit.
  • ResourceController trae $this->model y las respuestas API.
  • respondCreated/NoContent/failUnauthorized: semántica HTTP exacta.
  • fail() estructura fija: status/code/messages.
  • Accept negocia json/xml vía Config\Format.

27 · Entidades y Transformers

Avanzado ~16 min

Devolver la fila cruda funciona, pero exponer columnas internas es mala higiene de API. CI4 4.7 trae la pareja oficial: Entidades (la fila como objeto con casts y lógica) y Transformers (el molde exacto de tu JSON) — el eco directo de Eloquent casts + JsonResource.

  • Crear PedidoEntidad con $casts (DECIMAL string → float).
  • Usar $returnType entidad en el modelo.
  • Moldear el JSON con make:transformer y toArray().
  • Incluir detalles con ?include=detalles y paginar con data/meta/links.

La entidad: la fila con tipos honrados

<?php
// app/Entities/PedidoEntidad.php
namespace App\Entities;

use CodeIgniter\Entity\Entity;

class PedidoEntidad extends Entity
{
    protected $casts = [
        'total' => 'float',        // DECIMAL llega como STRING desde MariaDB
    ];
}
<?php
// app/Models/PedidoModelo.php — dos lineas nuevas:
protected $returnType = \App\Entities\PedidoEntidad::class;
protected $useTimestamps = true;

Ahora find() devuelve un objeto: $pedido->estado, $pedido->total ya es float. La entidad soporta mucho más — getters/setters mágicos para lógica (setXxx/getXxx), datamap para renombrar columnas, más casts: boolean, datetime, array, json, csv, enum[Clase] (novedad 4.7), nullable con ?tipo. Lo justo cuando lo necesites; hoy basta el float.

El Transformer: tu JSON, tus reglas

php spark make:transformer Pedido --suffix
# crea app/Transformers/PedidoTransformer.php
<?php
namespace App\Transformers;

use CodeIgniter\API\BaseTransformer;

class PedidoTransformer extends BaseTransformer
{
    public function toArray(mixed $resource): array
    {
        return [
            'id'           => (int) $resource['id'],
            'cliente_id'   => (int) $resource['cliente_id'],
            'estado'       => $resource['estado'],
            'total'        => (float) $resource['total'],
            'fecha_pedido' => $resource['fecha_pedido'],
        ];
        // ni created_at ni updated_at: no son asunto del cliente
    }

    protected function getAllowedFields(): ?array
    {
        return ['id', 'estado', 'total'];   // whitelist del ?fields=
    }
}

Uso inmediato — transform() para uno, transformMany() para colecciones:

<?php
$pedido     = $this->model->find($id);
return $this->respond((new \App\Transformers\PedidoTransformer())
                      ->transform($pedido));

Includes: relaciones bajo demanda

El cliente pide ?include=detalles y el transformer ejecuta su método includeXxx() — acceso a la fila actual vía $this->resource:

<?php
protected function getAllowedIncludes(): ?array
{
    return ['detalles'];   // sin esto, ApiException al pedirlo
}

protected function includeDetalles(): array
{
    $filas = model(\App\Models\DetalleModelo::class)
        ->dePedido((int) $this->resource['id']);

    return array_map(fn ($d) => [
        'producto'        => $d['producto'],
        'cantidad'        => (int) $d['cantidad'],
        'precio_unitario' => (float) $d['precio_unitario'],
    ], $filas);
}

Una query extra SOLO si el cliente la pidió — presupuesto de queries respetado hasta en la API. Y los includes inválidos lanzan ApiException con mensaje claro: «Missing include method for: X».

Paginación API oficial: data/meta/links

El trait de respuestas incluye un paginate() propio que envuelve todo en el formato estándar — acepta modelo O builder y hasta otro transformer:

// GET /api/pedidos?page=2  →  $this->paginate($this->model, 20,
//                                        PedidoTransformer::class):
{
    "data": [ {"id": 21, ...}, ... ],
    "meta": {"page": 2, "perPage": 20, "total": 34, "totalPages": 2},
    "links": {"self": "...page=2", "first": "...", "next": null}
}
Eco Laravel cap 27: JsonResource::toArray ≙ BaseTransformer::toArray(); ->whenLoaded('detalles') ≙ ?include=detalles; paginate()->withQueryString ≙ ResponseTrait::paginate(). El cliente consume JSON idéntico en espíritu aunque cambie el framework detrás.

Puntos clave

  • $casts 'total' => 'float': DECIMAL string convertido al leer/escribir.
  • $returnType entidad: find/findAll devuelven objetos tipados.
  • Transformer = molde JSON: qué exponer, cómo renombrar, formato.
  • ?fields= filtra columnas; ?include= agrega relaciones bajo demanda.
  • paginate(resource, perPage, transformWith): data/meta/links oficial.

28 · Tokens de API artesanales

Avanzado ~16 min

Sin Sanctum en CI4, construimos el nuestro — como prometió el diseño: didáctico y sin magia. Un token por aplicación cliente, guardado como HASH (jamás plano), validado por un filtro antes de tocar cualquier endpoint. El eco honesto de los personal access tokens del cap 28 de Laravel.

  • Migrar api_tokens con hash único y expiración opcional.
  • Generar tokens seguros: random_bytes + hash('sha256').
  • Escribir FiltroApiToken que responde 401 en JSON.
  • Registrar el alias y aplicarlo al grupo api.

La tabla

php spark make:migration CreateApiTokens
<?php
public function up(): void
{
    $this->forge->addField([
        'id'         => ['type' => 'INT', 'constraint' => 11,
                         'unsigned' => true, 'auto_increment' => true],
        // usuario_id llegara en el cap 32 cuando existan usuarios
        'nombre'     => ['type' => 'VARCHAR', 'constraint' => 80],
        'token_hash' => ['type' => 'VARCHAR', 'constraint' => 64,
                         'unique' => true],
        'ultimo_uso' => ['type' => 'DATETIME', 'null' => true],
        'expira_en'  => ['type' => 'DATETIME', 'null' => true],
        'created_at' => ['type' => 'DATETIME', 'null' => true],
    ]);
    $this->forge->addKey('id', true);
    $this->forge->createTable('api_tokens', true);
}

Generación: mostrar una vez, guardar el hash

<?php
// app/Libraries/Tokens.php
namespace App\Libraries;

class Tokens
{
    /** Devuelve [token_plano, id]. El plano SOLO se muestra al crearlo. */
    public static function emitir(string $nombre, ?string $dias = null): array
    {
        $plano = bin2hex(random_bytes(32));              // 64 caracteres

        $id = model(\App\Models\ApiTokenModelo::class)->insert([
            'nombre'     => $nombre,
            'token_hash' => hash('sha256', $plano),
            'expira_en'  => $dias ? date('Y-m-d H:i:s',
                                       strtotime("+$dias days")) : null,
        ]);
        return [$plano, $id];
    }
}

¿Por qué hash? La misma razón que las contraseñas: si te roban la tabla, los tokens robados no sirven — solo el SHA-256 del plano valida contra token_hash. El plano viaja una sola vez, del generador a la aplicación cliente.

# emitir uno desde una ruta temporal o tinker-style:
# Tokens::emitir('panel-spa') →
# a3f8c92e...64-hex... (guardalo AHORA: no se vuelve a mostrar)

El filtro que custodia la API

<?php
// app/Filters/FiltroApiToken.php
namespace App\Filters;

use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;

class FiltroApiToken implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        $header = $request->getHeaderLine('Authorization');

        if (! preg_match('/^Bearer\s+(\S+)$/', $header, $m)) {
            return service('response')->setStatusCode(401)
                ->setJSON(['status' => 401,
                           'messages' => ['error' => 'Token requerido.']]);
        }

        $fila = model(\App\Models\ApiTokenModelo::class)
            ->where('token_hash', hash('sha256', $m[1]))->first();

        if ($fila === null
            || ($fila['expira_en'] !== null && $fila['expira_en'] < date('Y-m-d H:i:s'))) {
            return service('response')->setStatusCode(401)
                ->setJSON(['status' => 401,
                           'messages' => ['error' => 'Token invalido o expirado.']]);
        }

        model(\App\Models\ApiTokenModelo::class)
            ->update($fila['id'], ['ultimo_uso' => date('Y-m-d H:i:s')]);
    }

    public function after(RequestInterface $r, ResponseInterface $resp, $a = null) {}
}

Registro y activación — el alias ya lo conectamos en resource() del cap 26:

// app/Config/Filters.php
public array $aliases = [
    'csrf'       => CSRF::class,
    'api-token'  => \App\Filters\FiltroApiToken::class,
];
curl -H "Authorization: Bearer a3f8c92e..." \
-H "Accept: application/json" http://localhost:8080/api/pedidos

curl http://localhost:8080/api/pedidos
# {"status":401,"messages":{"error":"Token requerido."}}
Por qué la API no usa CSRF: el ataque CSRF explota cookies de sesión automáticas. Aquí NO hay sesión ni cookie — el token viaja explícito en un header que ningún navegador tercero puede forjar. Dos mundos, dos defensas.

Puntos clave

  • random_bytes(32) genera; hash('sha256') guarda: nunca plano en BD.
  • Header Authorization: Bearer <token> es el estándar de facto.
  • El filtro corta con 401 JSON ANTES del controlador.
  • expira_en nullable: tokens permanentes para tu propia SPA.
  • usuario_id llegará con auth (cap 32): esquema preparado.

29 · Endpoints Pedidos completos

Avanzado ~17 min

El controlador API completo, con las reglas canónicas intactas: solo REGISTRADO se edita o borra; PAGADO/ANULADO son terminales; el precio se congela del lado servidor. Cada endpoint con su curl de verificación — mismo estándar del cap 29 de Laravel.

  • index() con filtros estado/q y paginación transparente.
  • show/create con transformer y validación del cap 12.
  • update() como máquina de transiciones de estado.
  • delete() solo para borradores REGISTRADO.

Listado filtrado y paginado

<?php
// app/Controllers/Api/PedidoControlador.php (recortado)
use App\Transformers\PedidoTransformer;

public function index()
{
    $estado = $this->request->getGet('estado');
    $q      = $this->request->getGet('q');
    $pagina = max(1, (int) ($this->request->getGet('page') ?? 1));
    $porPagina = 20;

    $consulta = ($q !== null && $q !== '')
        ? $this->model->buscar($q)
        : $this->model;

    if ($estado !== null && $estado !== '') {
        $consulta = $consulta->porEstado($estado);
    }

    $total    = (clone $consulta)->countAllResults();
    $filas    = $consulta->orderBy('fecha_pedido', 'DESC')
                          ->findAll($porPagina, ($pagina - 1) * $porPagina);

    return $this->respond([
        'data' => (new PedidoTransformer())->transformMany($filas),
        'meta' => ['pagina' => $pagina, 'por_pagina' => $porPagina,
                   'total' => $total,
                   'paginas' => (int) ceil($total / $porPagina)],
    ]);
}

Presupuesto explícito: COUNT + SELECT = 2 queries. El clone() evita que el countAllResults contamine la consulta siguiente — detalle fino pero crítico.

Detalle, alta y validación

<?php
public function show($id = null)
{
    $pedido = $this->model->find($id);
    if ($pedido === null) {
        return $this->failNotFound("Pedido $id no existe.");
    }
    return $this->respond(
        (new PedidoTransformer())->transform($pedido));
}

public function create()
{
    $datos = $this->request->getJSON(true);   // JSON body → arreglo

    $validacion = service('validation');
    if (! $validacion->run($datos, 'pedido')) {
        return $this->failValidationErrors($validacion->getErrors());
    }

    $id = $this->model->insert([
        'cliente_id'   => (int) $datos['cliente_id'],
        'estado'       => 'REGISTRADO',
        'total'        => (float) $datos['total'],
        'fecha_pedido' => date('Y-m-d H:i:s'),
    ]);

    return $this->respondCreated(['id' => $id]);
}

getJSON(true): los clientes API envían JSON puro, no formularios — y por eso aquí no existe CSRF (cap 28). La validación reutiliza el grupo pedido: UNA definición, dos mundos.

update(): la máquina de estados

<?php
private const TRANSICIONES = [
    'REGISTRADO' => ['PAGADO', 'ANULADO'],
    // PAGADO y ANULADO: terminales, sin salida
];

public function update($id = null)
{
    $pedido = $this->model->find($id);
    if ($pedido === null) {
        return $this->failNotFound("Pedido $id no existe.");
    }

    $nuevo = $this->request->getJSON(true)['estado'] ?? '';
    $permitidos = self::TRANSICIONES[$pedido->estado] ?? [];

    if (! in_array($nuevo, $permitidos, true)) {
        return $this->failForbidden(
            "Transicion {$pedido->estado} → $nuevo no permitida.");
    }

    $this->model->update($id, ['estado' => $nuevo]);
    return $this->respond(['id' => (int) $id, 'estado' => $nuevo]);
}

public function delete($id = null)
{
    $pedido = $this->model->find($id);

    if ($pedido === null) {
        return $this->failNotFound("Pedido $id no existe.");
    }
    if ($pedido->estado !== 'REGISTRADO') {
        return $this->failForbidden('Solo REGISTRADO se elimina.');
    }

    model(\App\Models\DetalleModelo::class)
        ->where('pedido_id', $id)->delete();
    $this->model->delete($id);

    return $this->respondDeleted(['id' => (int) $id]);
}
# ciclo completo verificado:
curl -X PATCH -H "Authorization: Bearer $T" \
-H "Content-Type: application/json" \
-d '{"estado":"PAGADO"}' http://localhost:8080/api/pedidos/4
# {"id":4,"estado":"PAGADO"}

# segundo intento:
# {"status":403,...}"Transicion PAGADO → PAGADO no permitida."
401 vs 403 otra vez: token ausente/inválido = failUnauthorized (el filtro lo hace); token válido pero acción prohibida = failForbidden. El cliente JS distingue «debo autenticarme» de «no insistas».

Puntos clave

  • Filtros + paginación manual: clone() antes de countAllResults.
  • getJSON(true): entrada JSON nativa, sin formularios.
  • TRANSICIONES: el mapa ES la regla de negocio.
  • respondCreated/respondDeleted: semántica REST correcta.
  • Grupo de validación compartido web/API: una sola verdad.

30 · Consumo con fetch desde JavaScript

Avanzado ~15 min

Cierre de la Parte V: la SPA mínima que consume nuestra API — el mismo ejercicio del cap 36 de js_01 y del cap 30 de Laravel. fetch() con Bearer token, manejo del shape {status, messages} que definimos en fail(), y las diferencias finas frente al consumo con cookies.

  • Listar pedidos con fetch + Authorization header.
  • Crear un pedido por POST con cuerpo JSON.
  • Pagar/anular con PATCH leyendo la respuesta de error 403.
  • Entender por qué aquí NO va CSRF y sí cuidado con el token.

La vista anfitriona

<!-- app/Views/api/demo.php (dentro del layout) -->
<table class="table" id="tabla-pedidos"><tbody></tbody></table>

<form id="form-pedido">
    <input type="number" name="cliente_id" placeholder="ID cliente" required>
    <input type="number" name="total" step="0.01" placeholder="Total" required>
    <button>Crear</button>
</form>

<script src="/js/api-demo.js"></script>

El módulo JS completo

// public/js/api-demo.js
const API = '/api/pedidos';
// DEMO: token fijo para aprender. En produccion NUNCA hardcodeado ni
// en localStorage si hay riesgo XSS (leccion del cap seguridad de js_01):
const TOKEN = 'a3f8c92e...';   // tu token emitido en el cap 28

async function api(ruta, opciones = {}) {
    const resp = await fetch(API + ruta, {
        ...opciones,
        headers: {
            'Authorization': `Bearer ${TOKEN}`,
            'Content-Type': 'application/json',
            ...opciones.headers,
        },
    });
    const cuerpo = await resp.json();
    if (!resp.ok) {
        // shape canónico de fail(): {status, code, messages}
        throw new Error(cuerpo?.messages?.error
            ?? Object.values(cuerpo?.messages ?? {}).join(' ')
            ?? `HTTP ${resp.status}`);
    }
    return cuerpo;
}

async function listar() {
    const {data} = await api('');
    document.querySelector('#tabla-pedidos tbody').innerHTML =
        data.map(p => `
            <tr>
                <td>#${p.id}</td>
                <td>${p.estado}</td>
                <td>S/ ${p.total.toFixed(2)}</td>
                <td>${p.estado === 'REGISTRADO'
                    ? `<button data-id="${p.id}" class="pagar">Pagar</button>`
                    : ''}</td>
            </tr>`).join('');
}

document.querySelector('#form-pedido').addEventListener('submit', async e => {
    e.preventDefault();
    const fd = new FormData(e.target);
    try {
        await api('', {
            method: 'POST',
            body: JSON.stringify({
                cliente_id: Number(fd.get('cliente_id')),
                total: Number(fd.get('total')),
            }),
        });
        await listar();
    } catch (err) {
        alert(err.message);   // ej: validación 400 con mensajes por campo
    }
});

document.addEventListener('click', async e => {
    if (!e.target.matches('.pagar')) return;
    try {
        await api(`/${e.target.dataset.id}`, {
            method: 'PATCH',
            body: JSON.stringify({estado: 'PAGADO'}),
        });
        await listar();
    } catch (err) {
        alert(err.message);   // 403: "Transicion PAGADO → PAGADO no permitida."
    }
});

listar();

Las tres diferencias con el mundo web

AspectoFormularios web (caps 12–22)API con tokens
Credencialcookie de sesión automáticaheader Authorization explícito
CSRFobligatorio (@csrf / csrf_field)no aplica: sin cookie que secuestrar
Cuerpomultipart/form-dataapplication/json + getJSON(true)

Y si algún día la SPA vive en OTRO dominio, ahí sí entra el filtro integrado cors configurando orígenes permitidos — mismo nombre que el concepto que ya conoces.

Eco js_01/Laravel: mismo fetch, mismo async/await, misma disciplina de errores. La API cambió de framework detrás; el cliente ni se enteró. Esa independencia ES el valor de una buena API.

Puntos clave

  • Bearer token en header; Content-Type application/json.
  • Leer SIEMPRE el shape {status, code, messages} de fail().
  • PATCH para transiciones: el verbo correcto importa.
  • Sin CSRF porque sin cookies — pero protege el token como contraseña.
  • Mismo dominio hoy; cors filter cuando haya otro origen.

31 · Autenticación artesanal

Avanzado ~17 min

Misma decisión didáctica del cap 31 de Laravel: login a mano con sesiones para VER cómo funciona todo — password_hash al registrar, password_verify al entrar, ID de sesión renovado tras autenticar. Sin paquetes que oculten la magia (Shield existe y lo mencionaremos, pero después).

  • Migrar usuarios con password_hash y email único.
  • Implementar registro/login/salir completos.
  • Regenerar el ID de sesión tras autenticar (anti-fixation).
  • Error genérico: nunca revelar cuál campo falló.

La tabla de usuarios

php spark make:migration CreateUsuarios
<?php
public function up(): void
{
    $this->forge->addField([
        'id'            => [...id canonico...],
        'nombre'        => ['type' => 'VARCHAR', 'constraint' => 120],
        'email'         => ['type' => 'VARCHAR', 'constraint' => 150,
                           'unique' => true],
        'password_hash' => ['type' => 'VARCHAR', 'constraint' => 255],
        'es_admin'      => ['type' => 'TINYINT', 'constraint' => 1,
                           'default' => 0],
        ...created_at / updated_at...
    ]);
    $this->forge->addKey('id', true);
    $this->forge->createTable('usuarios', true);
}

Nunca guardamos «clave»: guardamos el HASH. La columna admite 255 porque el algoritmo puede cambiar en el futuro (bcrypt hoy, argon2 mañana).

Registro

<?php
public function registrar(): RedirectResponse   // POST /registro
{
    $datos = $this->request->getPost();

    if (! $this->validateData($datos, [
        'nombre' => 'required|min_length[3]',
        'email'  => 'required|valid_email|is_unique[usuarios.email]',
        'clave'  => 'required|min_length[8]|max_length[72]',
    ])) {
        return redirect()->back()->withInput()
            ->with('errors', $this->validator->getErrors());
    }

    model(\App\Models\UsuarioModelo::class)->insert([
        'nombre'        => $datos['nombre'],
        'email'         => $datos['email'],
        'password_hash' => password_hash($datos['clave'], PASSWORD_DEFAULT),
    ]);

    return redirect()->to('/ingresar')->with('success', 'Ya puedes entrar.');
}

Login: verificar, sesionar, regenerar

<?php
public function ingresar(): RedirectResponse   // POST /ingresar
{
    $email = $this->request->getPost('email');
    $clave = $this->request->getPost('clave');

    $usuario = model(\App\Models\UsuarioModelo::class)
        ->where('email', $email)->first();

    // mensaje UNICO: un atacante no debe saber si fallo el email o la clave
    if ($usuario === null || ! password_verify($clave, $usuario['password_hash'])) {
        return redirect()->back()->withInput()
            ->with('error', 'Credenciales invalidas.');
    }

    session()->set([
        'usuario_id' => $usuario['id'],
        'nombre'     => $usuario['nombre'],
        'es_admin'   => (bool) $usuario['es_admin'],
    ]);
    session()->regenerate();     // ID nuevo: session fixation abortada

    return redirect()->to('/panel');
}

public function salir(): RedirectResponse      // GET /salir
{
    session()->destroy();
    return redirect()->to('/')->with('success', 'Sesion cerrada.');
}

regenerate() es la pieza fina: crea un ID de sesión nuevo para la misma data, matando cualquier intento de fijación previa. El cap 13 te contó que CI4 además regenera automáticamente cada timeToUpdate segundos — doble capa.

El layout reconoce quién entra

<!-- app/Views/layout_pedidos.php, en la barra -->
<?php if (session('nombre')): ?>
    <span><?= esc(session('nombre')) ?></span>
    <a href="<?= site_url('salir') ?>">Salir</a>
<?php else: ?>
    <a href="<?= site_url('ingresar') ?>">Entrar</a>
<?php endif ?>
PasoLaravel (cap 31)CI4 (este cap)
Guardar claveHash::make()password_hash(PASSWORD_DEFAULT)
VerificarHash::check()password_verify()
Sesión post-login$request->session()->regenerate()session()->regenerate()
Starter kit oficialBreeze / FortifyShield (mencionado, no usado)
Reglas de oro intactas: error genérico siempre; hash SIEMPRE (jamás md5/sha1 «porque sí»); límite de intentos recomendado (el filtro Throttler existe para eso); y logout = destroy() completo, no solo unset.

Puntos clave

  • password_hash/verify: funciones PHP nativas, cero dependencias.
  • is_unique[usuarios.email] evita duplicados a nivel validación.
  • regenerate() tras login: fixation muere ahí mismo.
  • Sesión guarda usuario_id/nombre/es_admin: el cap 32 las usa.
  • Shield existe como kit oficial — entiende primero, automatiza luego.

32 · Autorización: filtros, roles y tokens con dueño

Avanzado ~16 min

Autenticado no es autorizado. Aquí conectamos las piezas: el filtro del cap 11 se vuelve REAL con la sesión del cap 31, los controladores ganan un guardián propio, y los tokens de API del cap 28 aprenden quién es su dueño.

  • Activar FiltroAdmin sobre el grupo admin con la sesión real.
  • Añadir guardianes en controladores para lógica fina.
  • Vincular api_tokens a usuarios (migración alter + emisión por usuario).
  • Mapear policies/gates de Laravel a sus equivalentes aquí.

El filtro cobra vida

<?php
// app/Filters/FiltroAdmin.php — ahora con sesion real (cap 11 era teaser)
public function before(RequestInterface $request, $arguments = null)
{
    if (! session('usuario_id')) {
        return redirect()->to('/ingresar')
            ->with('error', 'Inicia sesion primero.');
    }
    if (! session('es_admin')) {
        return redirect()->back()
            ->with('error', 'Zona solo para administradores.');
    }
}
// app/Config/Routes.php
$routes->group('admin', ['filter' => 'filtro-admin'],
    static function ($routes) {
        $routes->get('panel', 'Admin\Panel::index');
        $routes->get('usuarios', 'Admin\Usuarios::index');
    });

Dos respuestas distintas por dos motivos distintos: 302 al login si NO estás autenticado; 302 atrás con flash si faltan permisos. El navegador distingue, el log también.

Guardianes dentro del controlador

El filtro protege RUTAS completas. Para decisiones por-acción o por-recurso, el guardián vive en el controlador:

<?php
private function exigirAdmin(): void
{
    if (! session('es_admin')) {
        throw PageNotFoundException::forPageNotFound();  // 404, ni confiesa
    }
}

public function anular(int $id): RedirectResponse
{
    $this->exigirAdmin();
    // solo admin llega aqui...
}

API: tokens con dueño

Promesa del cap 28 cumplida — ahora cada token pertenece a un usuario:

php spark make:migration AddUsuarioAApiTokens
<?php
public function up(): void
{
    $this->forge->addColumn('api_tokens', [
        'usuario_id' => ['type' => 'INT', 'constraint' => 11,
                         'unsigned' => true, 'null' => true,
                         'after' => 'nombre'],
    ]);
    $this->forge->addForeignKey('usuario_id', 'usuarios', 'id',
                                '', '', 'fk_tokens_usuario');
}
// nota: addForeignKey en ALTER puede requerir processIndexes segun driver;
// si tu MariaDB lo aplica directo, perfecto.
<?php
// Tokens::emitir ahora recibe dueno:
[$plano, $id] = \App\Libraries\Tokens::emitir('panel-spa', null, $usuarioId);

// FiltroApiToken guarda el dueno para los controladores:
\App\Libraries\ApiAuth::$usuarioId = $fila['usuario_id'];

// app/Libraries/ApiAuth.php — compartido por request:
class ApiAuth
{
    public static ?int $usuarioId = null;
    public static bool $esAdmin = false;
}

Un endpoint API puede entonces distinguir: listar pedidos es para cualquiera con token; ANULAR exige que ApiAuth::$esAdmin sea true — failForbidden() si no.

El mapa completo de autorización

NecesidadLaravel (cap 32)CI4
Zona completa protegidamiddleware auth en grupofiltro por group()/URI pattern
Regla por recurso («dueño o admin»)Policy update($user,$pedido)método guardián + consulta del modelo
Permiso puntual booleanoGate::definesession('es_admin') / ApiAuth::$esAdmin
Kit oficial completoBreeze/FortifyShield (roles/permisos listos)
Cuándo Shield: si tu app crece a roles múltiples, permisos granulares o equipos — instala Shield y borra este capítulo de tu cabeza. Para entenderlo, nunca: primero artesanal (este), luego herramienta. Siempre en ese orden.

Puntos clave

  • Filtro para rutas; guardián para acciones; sesión como fuente de verdad.
  • 404 en vez de 403 ante sondeos: menos información al atacante.
  • Tokens API con usuario_id: auditoría y permisos por persona.
  • ApiAuth estático vive UNA petición: sin estado global persistente.
  • Policy ≙ filtro+guardián+regla: el concepto, tres piezas aquí.

33 · Cómo CI4 blinda tu app

Avanzado ~15 min

El capítulo espejo del 32 de Laravel y del cap de seguridad de php_01. La doc oficial de CI4 organiza sus defensas según OWASP Top 10 — aquí las destilamos a la tabla que usarás toda la carrera: amenaza → defensa nativa → quién la activa.

  • Inventariar el blindaje automático vs lo que depende de ti.
  • Distinguir XSS (disciplina esc()) de SQLi (bindings automáticos).
  • Conocer los filtros extra: secureheaders, cors, forcehttps, throttler.
  • Fijar qué NUNCA se delega al framework.

El inventario de defensas

AmenazaDefensa CI4¿Automática?
XSSfunción esc() + tu disciplina en cada echoNO — es TU responsabilidad (cap 9)
SQL InjectionQuery Builder + bindings escapados por driverSÍ, si usas builder/prepare
CSRFfiltro csrf global + csrf_field()SÍ (ya viene conectado)
Mass assignment$allowedFields del modeloSÍ si declaraste campos
Acceso directo al códigopublic/ como único webrootSÍ con DocumentRoot correcto
Session fixationregeneración automática + regenerate()SÍ (cap 13/31)
Cabeceras insegurasfiltro secureheadersa petición ($required/$globals)
Fuerza bruta / abusolibrería Throttler (rate limit)a petición
HTTPS ausenteforcehttps filter / forceGlobalSecureRequestsa petición (producción)

XSS e SQLi: dos naturalezas distintas

<!-- VULNERABLE: dato crudo al HTML -->
<p>Hola <?= $nombre ?></p>

<!-- BLINDADO: esc() neutraliza <script>, comillas, todo -->
<p>Hola <?= esc($nombre) ?></p>
// VULNERABLE: concatenacion directa — jamas:
$db->query("SELECT * FROM usuarios WHERE email = '$email'");

// BLINDADO A: bindings manuales con ?
$db->query('SELECT * FROM usuarios WHERE email = ?', [$email]);

// BLINDADO B: query builder SIEMPRE escapa valores:
model(UsuarioModelo::class)->where('email', $email)->first();

La asimetría clave: contra SQLi el framework te protege SOLO si usas sus herramientas (builder/bindings); contra XSS nadie puede protegerte automáticamente porque el framework no sabe qué es contenido confiable — por eso Blade pudo escapar {{ }} siempre y aquí esc() es un contrato contigo mismo.

Los filtros que faltan por encender

<?php
// app/Config/Filters.php — endurecimiento para produccion:
public array $globals = [
    'before' => ['csrf', 'secureheaders'],
    'after'  => ['secureheaders'],
];

// o global via required, mas CORS para APIs multi-dominio:
protected array $required = [
    'before' => ['forcehttps', 'pagecache'],
    'after'  => ['pagecache', 'performance', 'toolbar', 'secureheaders'],
];

Y existe Config\ContentSecurityPolicy.php completo si quieres CSP fina (whitelist de orígenes para scripts/estilos) — opcional pero recomendable cuando la app madure.

Lo que NUNCA se delega

  • La decisión de qué mostrar: esc() en cada echo dinámico.
  • La regla de negocio: «solo REGISTRADO se edita» vive en TU código — ningún framework la infiere.
  • Las credenciales: .env fuera del repo, usuario BD sin root, tokens como hashes.
  • La sospecha: validar TODO input aunque venga de «tu» JS.
El eco de tres manuales: php_01 te enseñó POR QUÉ existen estas amenazas; Laravel te mostró UNA automatización; CI4 demuestra que el patrón es independiente del framework. La seguridad no era de Laravel — era tuya.

Puntos clave

  • SQLi/CSRF/mass-assignment: automáticos con las herramientas correctas.
  • XSS: contrato personal — esc() o nada.
  • secureheaders/cors/forcehttps/throttler: se encienden cuando toca.
  • OWASP organiza la doc oficial: úsala como checklist de auditoría.
  • Nada reemplaza reglas de negocio escritas por ti.

34 · Subida de archivos e imágenes

Avanzado ~16 min

El payoff de la columna foto que sembramos en la migración del cap 16: cada producto del catálogo podrá mostrar su imagen. CI4 trae una clase UploadedFile con reglas de validación propias — y una recomendación importante sobre DÓNDE vivir.

  • Validar subidas con uploaded/is_image/mime_in/max_size/max_dims.
  • Mover con getRandomName() y persistir el nombre en la BD.
  • Servir la imagen desde public/ (o protegerla en writable/).
  • Distinguir los métodos getClient* (NO confiables) de los seguros.

El formulario: enctype obligatorio

<form action="<?= site_url("productos/{$p['id']}/foto") ?>"
      method="post" enctype="multipart/form-data">
    <?= csrf_field() ?>
    <input type="file" name="foto" accept="image/jpeg,image/png,image/webp">
    <button>Subir foto</button>
</form>

Sin enctype="multipart/form-data" el archivo no viaja — el olvido clásico número uno.

Validación y movimiento

<?php
public function guardarFoto(int $id): RedirectResponse   // POST
{
    $reglas = [
        'foto' => [
            'rules' => 'uploaded[foto]|is_image[foto]'
                     . '|mime_in[foto,image/jpg,image/jpeg,image/png,image/webp]'
                     . '|max_size[foto,2048]|max_dims[foto,3000,3000]',
            'errors' => ['max_size' => 'La foto supera los 2 MB.'],
        ],
    ];

    // OJO: required NO funciona con archivos — uploaded[foto] hace ese papel
    if (! $this->validateData([], $reglas)) {
        return redirect()->back()
            ->with('errors', $this->validator->getErrors());
    }

    $foto = $this->request->getFile('foto');
    if (! $foto->isValid()) {
        return redirect()->back()->with('error', 'Subida invalida.');
    }

    $nombre = $foto->getRandomName();                    // nombre seguro
    $ruta   = ROOTPATH . 'public/uploads/productos';
    $foto->move($ruta, $nombre);                         // mueve del tmp

    model(\App\Models\ProductoModelo::class)
        ->update($id, ['foto' => 'uploads/productos/' . $nombre]);

    return redirect()->back()->with('success', 'Foto actualizada.');
}

Tres piezas clave: la instancia existe AUNQUE no subas nada (por eso uploaded[foto] hace de required); move() saca el archivo del tmp PHP antes de que expire; y guardamos la RUTA relativa en la columna foto de productos — la siembra del cap 16 fructifica.

¿public/ o writable/? La decisión honesta

OpciónCómoCuándo
public/uploads/ (este cap)URL directa: /uploads/productos/x.jpgimágenes públicas de catálogo — el navegador las pide sin PHP
writable/uploads/ (recomendación oficial)$file->store() crea YYYYMMDD/nombre-aleatorioarchivos PRIVADOS: facturas, docs — servidos vía controlador tras verificar sesión
<!-- pintar la foto del catalogo -->
<img src="<?= base_url(esc($p['foto'])) ?>" alt="<?= esc($p['nombre']) ?>">

Confía, pero verifica: getClient* vs seguros

MétodoFuenteConfiabilidad
getClientName() / getClientExtension() / getClientMimeType()el NAVEGADOR del clienteNO confiar — falsificables
guessExtension() / getMimeType()análisis real del contenidoconfiables para decidir
getSizeByUnit('kb')tamaño real subidoconfiable
hasMoved() / getErrorString()estado del archivodiagnóstico

Por eso mime_in valida el MIME REAL del contenido, no el que declara el navegador — un «.jpg» que es un .php queda afuera.

Eco Laravel cap 34: storeAs determinista ≙ move(ruta, nombre) a elección; hashName ≙ getRandomName(); Storage::fake del cap 40 tendrá su gemelo cuando testees uploads con PHPUnit. Y la misma regla: nombre original del cliente JAMÁS llega al disco tal cual.

Puntos clave

  • enctype multipart o el archivo no viaja.
  • uploaded[foto] = el required de los archivos; max_dims limita píxeles.
  • getRandomName() + move(): nombre seguro, tmp liberado.
  • Público → public/; privado → writable/store() + controlador.
  • mime_in analiza contenido real: extensiones mentirosas quedan fuera.

35 · Colas: el paquete oficial Queue

Avanzado ~17 min

Aquí la primera diferencia estructural seria con Laravel: el sistema de colas NO viene en el core de CI4 — es un paquete oficial separado (codeigniter4/queue) que se instala por composer. Misma idea, vocabulario propio: jobs con process(), colas nombradas y un worker spark.

  • Instalar Queue y crear sus tablas (queue_jobs / queue_jobs_failed).
  • Escribir un Job con process() y registrar su handler.
  • Despachar con service('queue')->push().
  • Correr queue:work y gestionar fallidos.

Instalación y tablas

composer require codeigniter4/queue

# las migraciones VIAJAN CON EL PAQUETE:
php spark migrate --all

# crea queue_jobs y queue_jobs_failed

Nada de inventar tablas ni helpers: el paquete trae las migraciones propias. El driver por defecto es database (también hay redis, predis y rabbitmq para cuando crezcas).

El Job: una clase con process()

php spark queue:job GenerarReporteVentas
# crea app/Jobs/GenerarReporteVentas.php
<?php
namespace App\Jobs;

use CodeIgniter\Queue\BaseJob;

class GenerarReporteVentas extends BaseJob
{
    protected int $tries      = 3;    // reintentos ante fallo
    protected int $retryAfter = 60;   // segundos entre intentos

    public function process(): void
    {
        // los parametros llegan serializados en $this->data:
        $fecha = $this->data['fecha'] ?? date('Y-m-d');

        $filas = model(\App\Models\PedidoModelo::class)->
            builder()->select('estado, COUNT(id) AS cuantos', false)->
            groupBy('estado')->get()->getResultArray();

        $resumen = array_column($filas, 'cuantos', 'estado');

        if (! is_dir(WRITEPATH . 'reportes')) {
            mkdir(WRITEPATH . 'reportes', 0775, true);
        }
        file_put_contents(
            WRITEPATH . "reportes/ventas-$fecha.json",
            json_encode($resumen, JSON_PRETTY_PRINT)
        );
        // => {"PAGADO": 2, "ANULADO": 1, "REGISTRADO": 1}
    }
}

El handler se registra en la config publicada del paquete:

<?php
// app/Config/Queue.php  (php spark queue:publish)
public array $jobHandlers = [
    'generar-reporte' => \App\Jobs\GenerarReporteVentas::class,
];

Despachar y trabajar

<?php
// donde toque (controlador, evento, tarea):
$result = service('queue')->push('default', 'generar-reporte',
                                 ['fecha' => '2026-08-24']);

if (! $result) {           // QueuePushResult
    log_message('error', 'No se pudo encolar: ' . $result->getError());
}
# terminal 1 — el worker (desarrollo):
php spark queue:work default --stop-when-empty

# Processing job (id:1), handler: generar-reporte
# Processed job (id:1)

# produccion: sin --stop-when-empty, con supervisor (cap 43)
ComandoHace
queue:work <cola>worker continuo; opciones -sleep, -max-jobs, -memory, -tries, -priority, --stop-when-empty
queue:failedlista los caídos (tabla queue_jobs_failed)
queue:retry · queue:forget · queue:flushreintentar uno/todos · borrar uno · vaciar
queue:stop · queue:clearparar worker tras el job actual · vaciar cola
Paquete oficial ≠ core: Queue tiene SU ciclo de releases. Al desplegar, composer install debe traerlo alineado; y las tablas viven bajo TU migración --all, así que respeta la regla de siempre: jamás migrar con usuarios dentro.

Puntos clave

  • Queue = paquete oficial: composer require + migrate --all.
  • Job extiende BaseJob: process() lee $this->data; tries/retryAfter.
  • push(cola, handler, datos): el handler vive en Config\Queue.
  • Familia failed/retry/forget/flush espeja la de Laravel.
  • Driver database por defecto: tus tablas, tu MariaDB.

36 · Eventos y listeners: pedido pagado

Avanzado ~15 min

Aquí CI4 se toma la revancha del minimalismo: los eventos NO necesitan clases de evento, ni discovery, ni interfaces — un nombre y una función. Cuando el PATCH del cap 29 marca PAGADO, disparamos pedido.pagado y dos listeners hacen su vida: estadísticas al instante, recibo a la cola.

  • Registrar listeners con Events::on() y disparar con trigger().
  • Usar prioridades y entender cuándo false corta la cadena.
  • Conocer los eventos del core (¡incluido DBQuery para el cap 41!).
  • Combinar evento + job: listener que encola el recibo.

Disparar el evento en el punto exacto

<?php
// app/Controllers/Api/PedidoControlador.php — dentro de update():
$this->model->update($id, ['estado' => $nuevo]);

if ($nuevo === 'PAGADO') {
    \CodeIgniter\Events\Events::trigger('pedido.pagado', (int) $id);
}
return $this->respond(['id' => (int) $id, 'estado' => $nuevo]);

Sin clase PedidoPagado ni nada: el nombre del evento es solo un string. Los argumentos extra viajan en orden hacia los listeners.

Los listeners, en Config/Events.php

<?php
// app/Config/Events.php
namespace Config;

use CodeIgniter\Events\Events;

Events::on('pedido.pagado', static function (int $pedidoId): void {
    // 1) sincrono rapido: refrescar contadores cacheados
    cache()->delete('top-clientes');        // invalida el cell del cap 25
});

Events::on('pedido.pagado', static function (int $pedidoId): void {
    // 2) pesado: ENCOLAR el envio del recibo (cap 35 + cap 37)
    service('queue')->push('emails', 'enviar-recibo',
                           ['pedido_id' => $pedidoId]);
}, Events::PRIORITY_LOW);   // corre DESPUES del primero

Prioridades: menor número = antes (HIGH=10, NORMAL=100, LOW=200). Y si un listener devuelve false, la cadena completa se detiene — útil como interruptor de emergencia, no como control de flujo normal.

Formas de listener aceptadas

FormaEjemploCuándo
ClosureEvents::on('x', fn ($id) => ...)lógica corta, una sola vez
Estático'\App\Listeners\Recibos::encolar'lógica reutilizable/testeable
Instancia[$objeto, 'metodo']cuando ya tienes el objeto vivo

Cuando el closure crece, extráelo a una clase estática — mismo criterio que aplicamos a helpers y scopes. La convención de la casa: app/Listeners/ para esas clases.

Los eventos del core que ya existen

EventoSe disparaEco futuro
pre_system / post_systemarranque / antes de enviar respuestainstrumentación global
post_controller_constructorcontrolador listo, método por correrauditoría por acción
DBQueryTRAS CADA query SQLquery log del cap 41
emailcorreo enviado con éxitobitácora de envíos (cap 37)
migrate · pre_command/post_commandmigraciones y comandos sparkpipelines de despliegue

Para pruebas existen Events::simulate(true) (ignora todos los eventos) — lo usaremos en el cap 40.

Eco Laravel: Event::dispatch ≙ trigger(); listener ShouldQueue ≙ push() a la cola desde tu listener; discovery automático ≙ registro explícito en Events.php. Menos magia aquí, cero sorpresas: sabes SIEMPRE qué corre y en qué orden.

Puntos clave

  • trigger(nombre, args...) dispara; Events::on() escucha.
  • Sin clases de evento: string + callables registrados.
  • PRIORITY_HIGH(10)/NORMAL(100)/LOW(200); false detiene la cadena.
  • Listener pesado = encola un job: nunca bloquees el request.
  • DBQuery/email/migrate: ganchos del core listos para usar.

37 · Correo: Email service y Mailpit

Avanzado ~15 min

El job enviar-recibo del cap 36 ya está encolado; ahora lo hacemos existir. CI4 envía con la clase Email (service) configurada por app/Config/Email.php — sin mailables con sobre, pero igual de honesta: vista + datos + send().

  • Configurar SMTP local con Mailpit para desarrollo.
  • Enviar HTML desde una vista Blade-free con setMailType.
  • Diagnosticar fallos con printDebugger().
  • Encapsular el envío como Job de cola.

Mailpit en desarrollo

# docker run --rm -p 1025:1025 -p 8025:8025 axllent/mailpit

# SMTP escucha :1025 — interfaz web en http://localhost:8025
# atrapa TODO el correo saliente: nada llega a nadie real
<?php
// app/Config/Email.php (recortado)
public string $protocol = 'smtp';
public string $SMTPHost = 'localhost';
public string $SMTPPort = '1025';      // Mailpit
public string $SMTPCrypto = '';        // solo para 587 tls / 465 ssl
public string $mailType = 'html';
public string $fromEmail = 'pedidos@tienda.test';
public string $fromName  = 'Pedidos Tienda';

Notas de la doc que evitan horas de debugging: puerto 465 implica TLS automático sin importar $SMTPCrypto; 587 espera STARTTLS. Para producción solo cambias host/credenciales aquí — cero código.

El job EnviarRecibo

php spark queue:job EnviarRecibo
<?php
namespace App\Jobs;

use CodeIgniter\Queue\BaseJob;

class EnviarRecibo extends BaseJob
{
    protected int $tries = 3;

    public function process(): void
    {
        $pedidoId = (int) ($this->data['pedido_id'] ?? 0);

        $pedido = model(\App\Models\PedidoModelo::class)->find($pedidoId);
        if ($pedido === null) {
            return;    // nada que enviar
        }

        $email = service('email');
        $email->setTo($this->data['email'] ?? 'cliente@example.com')
              ->setSubject("Recibo pedido #$pedidoId")
              ->setMessage(view('emails/recibo', ['pedido' => $pedido]));

        if (! $email->send()) {                 // send() devuelve BOOL
            log_message('error', "Fallo envio recibo #$pedidoId");
            throw new \RuntimeException('SMTP rechazo el envio');
            // lanzar = el worker reintenta segun tries
        }
    }
}

La vista del correo

<!-- app/Views/emails/recibo.php — tablas, no divs: los clientes de correo -->
<p>Hola, gracias por tu compra.</p>
<p>Tu pedido <b>#<?= (int) $pedido['id'] ?></b> quedo
   en estado <?= esc($pedido['estado']) ?>.</p>
<p>Total: <b><?= moneda($pedido['total']) ?></b></p>

El helper pedidos está disponible si lo cargas al inicio del archivo o del job. Estilos inline y tablas simples: los clientes de correo viven en 2005.

Diagnóstico y auditoría

<?php
// si send() devolvio false:
$email->send(false);                          // NO limpia los datos
printDebugger(['headers', 'subject']);       // conversacion SMTP completa

// y el evento core 'email' se dispara tras CADA envio exitoso:
Events::on('email', static function (array $props): void {
    log_message('info', "Correo a {$props['toEmail']} enviado.");
});
Una sola capa de cola: este job corre EN el worker — jamás vuelvas a encolar dentro de un job (bucle infinito). Y el listener del cap 36 encoló; no envió directo: el request HTTP nunca espera al SMTP.

Puntos clave

  • service('email') + Config\Email.php: SMTP configurable sin tocar código.
  • send() devuelve bool; send(false) + printDebugger() para depurar.
  • Vista PHP normal = plantilla del correo; monedas y esc() intactos.
  • Lanzar excepción en process(): el worker reintenta según tries.
  • Evento core email: bitácora automática de envíos exitosos.

38 · Tareas programadas con Tasks

Avanzado ~15 min

El scheduler tampoco vive en el core: codeigniter4/tasks es el paquete oficial — y depende de Queue (cap 35). El patrón de la casa se repite tal cual: UNA entrada cron por minuto, el scheduler decide qué toca, los jobs hacen el trabajo pesado.

  • Instalar Tasks y publicar su configuración.
  • Definir horarios en Config\Tasks con init(Scheduler).
  • Programar comandos spark, closures y JOBS de cola.
  • Instalar el crontab único y auditar con tasks:list.

Instalación

composer require codeigniter4/tasks
# (arrastra codeigniter4/queue y settings como dependencias)

php spark tasks:publish    # crea app/Config/Tasks.php
php spark migrate -n CodeIgniter\Settings

Definir la agenda

<?php
// app/Config/Tasks.php
namespace Config;

use CodeIgniter\Tasks\Config\Tasks as BaseTasks;
use CodeIgniter\Tasks\Scheduler;

class Tasks extends BaseTasks
{
    public function init(Scheduler $schedule): void
    {
        // 1) comando spark propio (lo creamos abajo):
        $schedule->command('pedidos:reporte --fecha=hoy')
                 ->daily('11:50 pm')
                 ->named('reporte-diario');

        // 2) closure rapida cada 5 minutos:
        $schedule->call(static function () {
            cache()->delete('top-clientes');
        })->everyFiveMinutes();

        // 3) JOB de cola: el scheduler despacha, el worker ejecuta:
        $schedule->queue('default', 'generar-reporte', ['fecha' => 'hoy'])
                 ->hourly()
                 ->singleInstance(30 * MINUTE);   // no duplicar si sigue vivo
    }
}
FrecuenciaEquivale a
->daily('11:50 pm') / ->hourly() / ->mondays()nombres humanos para el cron
->everyFiveMinutes()cada N minutos exactos
->cron('*/15 * * * *')expresión crontab cruda cuando hace falta
->environments(['production']) / ->named('x') / ->singleInstance(seg)dónde / quién es / evitar solapamiento

El comando que programa

Tareas serias viven en comandos spark reutilizables (y testeables):

php spark make:command PedidosReporte
<?php
// app/Commands/PedidosReporte.php (recortado)
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;

class PedidosReporte extends BaseCommand
{
    protected $group       = 'Pedidos';
    protected $name        = 'pedidos:reporte';
    protected $description = 'Genera el reporte de ventas del dia.';

    public function run(array $params): void
    {
        service('queue')->push('default', 'generar-reporte',
                               ['fecha' => CLI::getOption('fecha') ?? 'hoy']);
        CLI::write('Reporte encolado.', 'green');
    }
}

El crontab único

crontab -e

* * * * * cd /var/www/pedidos && php spark tasks:run &gt;&gt; writable/logs/tasks.log 2&gt;&amp;1

# auditoria:
php spark tasks:list
Eco Laravel cap 38: Schedule::command()->dailyAt() ≙ $schedule->command()->daily(); schedule:work ≙ tasks:run en loop para dev; sin withoutOverlapping aquí — singleInstance(seg) cumple ese papel vía lock. Y la misma cadena mental: cron → scheduler → despacha → worker → ejecuta.

Puntos clave

  • Tasks = paquete oficial dependiente de Queue y Settings.
  • init(Scheduler): command/call/shell/url/queue, frecuencias legibles.
  • singleInstance(): sin solapamientos de tareas largas.
  • Comandos spark = unidades testeables y reutilizables.
  • Crontab único + tasks:list: agenda auditable.

39 · Caché: recuerda lo que cuesta

Avanzado ~14 min

Cerramos la Parte VII con la capa que ya usamos tres veces sin mirar dentro: cache()->delete() en el listener, view_cell con TTL, el cell del panel. La API es corta y el handler por defecto vive en archivos — pero las reglas de cuándo cachear son las mismas de siempre.

  • Configurar handler (file default) y conocer los alternativos.
  • Dominar remember(): lee-o-computa-en-una-línea.
  • Invalidar en el momento EXACTO: tras el write, no antes.
  • Usar spark cache:clear/cache:info para auditar.

La configuración mínima

<?php
// app/Config/Cache.php (recortado)
public string $handler = 'file';       // apcu|dummy|file|memcached|redis|predis|wincache
public string $backupHandler = 'file';
public string $prefix  = '';
public int    $ttl     = 60;           // default general, en SEGUNDOS

El handler file escribe en writable/cache/ — cero dependencias para desarrollo. dummy existe para tests (siempre «miss»). Redis/memcached cuando haya varios servidores.

remember(): el patrón de la casa

<?php
// app/Controllers/PanelControlador.php — reemplazo del calculo directo:
$topClientes = cache()->remember('top-clientes', 300,
    static fn () => model(\App\Models\ClienteModelo::class)->
                     clientesConPedidos());

// primera peticion: ejecuta el closure y GUARDA 300 s
// siguientes: devuelve lo guardado SIN tocar MariaDB

Otros métodos del handler:

MétodoHace
get($clave) / save($clave,$valor,$ttl=60)TTL SIEMPRE explícito aquí también
delete($clave) / clean()invalidación puntual / vaciar todo
increment($k) / decrement($k)contadores atómicos
getMetadata($k)['expire']epoch de expiración (diagnóstico)
deleteMatching($patrón)solo handlers File/Redis/Predis

Invalidación en el momento exacto

Ya lo hicimos en el cap 36 — recordemos POR QUÉ ahí:

<?php
// Events.php — al marcar PAGADO:
Events::on('pedido.pagado', static function (int $id): void {
    cache()->delete('top-clientes');   // stale data fuera AL INSTANTE
});

La regla completa: cada write invalida sus lecturas cacheadas. Si mañana agregas edición de clientes (update()), su línea delete() va dentro de esa misma transacción. Cache sin plan de invalidación es bug programado.

Auditar qué hay guardado

php spark cache:info      # solo handler file: claves vivas
php spark cache:clear    # vacia TODO el store actual

Y el eco del cap 25 se cierra: el cell Metricas con TTL ya era caché — ahora sabes que debajo corre exactamente este save()/get(). Tres formas, una sola verdad: view caching (cap 9), cells con TTL, remember() manual.

No caches lo barato: una query indexada de COUNT tarda ~1ms; el código para invalidarla bien puede costarte más de lo que ahorra. Cachear es para AGREGADOS pesados (resúmenes GROUP BY), vistas completas estables y cálculos caros — como decidimos en Laravel capítulo a capítulo.

Puntos clave

  • cache() helper + Config\Cache: file default, dummy para tests.
  • remember(clave, ttl, closure): read-through en una línea.
  • Invalidar DENTRO del flujo que escribe, no en cron aleatorio.
  • cache:clear/info auditan el store file.
  • Mismo presupuesto mental de Laravel: cachear caro, no barato.

40 · Testing con PHPUnit

Avanzado ~17 min

Diferencia declarada desde el diseño: aquí NO hay Pest — PHPUnit es el estándar de CI4 y así lo respetamos. La red de seguridad prueba lo mismo que la de Laravel: listado API autenticado, transición prohibida, persistencia real en MariaDB.

  • Ejecutar la suite con vendor/bin/phpunit sobre phpunit.dist.xml.
  • Feature tests con FeatureTestTrait::call() y TestResponse.
  • BD de pruebas exclusiva con DatabaseTestTrait y $refresh.
  • Fabricar datos con fake() y aserciones de log/eventos.

La base de pruebas, aislada de verdad

# .env — grupo "tests" aparte (gemelo del sqlite :memory: de Laravel):
database.tests.hostname = localhost
database.tests.database = pedidos_test
database.tests.username = pedidos_app
database.tests.DBDriver = MySQLi

El grupo tests vive en Config/Database.php igual que default — tus datos reales jamás corren riesgo.

Un feature test completo

php spark make:test PedidosApiTest    # crea tests/App/PedidosApiTest.php
<?php
namespace App\Tests;

use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;

final class PedidosApiTest extends CIUnitTestCase
{
    use DatabaseTestTrait, FeatureTestTrait;

    protected $refresh = true;   // regress + migrate ANTES de cada test

    public function testListaPedidosConTokenValido(): void
    {
        $this->seed('App\Database\Seeds\DatabaseSeeder');

        [$token] = \App\Libraries\Tokens::emitir('test');

        $resultado = $this->withHeaders([
                'Authorization' => "Bearer {$token}",
            ])->get('/api/pedidos');

        $resultado->assertStatus(200);
        $resultado->assertJSONFragment(['estado' => 'PAGADO']);
    }

    public function testTransicionProhibidaDevuelve403(): void
    {
        $this->seed('App\Database\Seeds\DatabaseSeeder');
        [$token] = \App\Libraries\Tokens::emitir('test');

        // pedido #1 ya esta PAGADO segun el seeder canonico:
        $resultado = $this->withHeaders([
                'Authorization' => "Bearer {$token}",
            ])->withBody(json_encode(['estado' => 'PAGADO']))
             ->withBodyFormat('json')
             ->patch('/api/pedidos/1');

        $resultado->assertStatus(403);
    }

    public function testCrearPersisteEnBase(): void
    {
        $this->seed('App\Database\Seeds\DatabaseSeeder');
        [$token] = \App\Libraries\Tokens::emitir('test');

        $this->withHeaders(['Authorization' => "Bearer {$token}"])
             ->withBody(json_encode(['cliente_id' => 1, 'total' => 99.90]))
             ->withBodyFormat('json')
             ->post('/api/pedidos')
             ->assertStatus(201);

        $this->seeInDatabase('pedidos', ['total' => '99.90',
                                         'estado' => 'REGISTRADO']);
    }
}

El vocabulario de aserciones

NecesidadPHPUnit + CI4Pest (Laravel)
Código HTTP->assertStatus(200) · assertOK()assertStatus/assertOk
JSON contieneassertJSONFragment([...])assertJsonFragment
Fila existe en BD$this->seeInDatabase(tabla,criterio)assertDatabaseHas
Sesión tras loginassertSessionHas('usuario_id')assertAuthenticated
Se escribió al log$this->assertLogged('error')Log::spy
Evento disparadoassertEventTriggered('pedido.pagado')Event::fake+assertDispatched

Complementos útiles: fake(ProductoModelo::class, ['stock' => 0]) fabrica filas al vuelo; $this->skipEvents() apaga listeners para un test quirúrgico; withSession([...]) simula login web sin pasar por el formulario. Los mocks de Email/Sesión ya vienen activados por defecto — ningún test enviará correo de verdad.

Mismo estándar distinto idioma: RefreshDatabase ≙ $refresh; actingAs ≙ withSession/Bearer; assertDatabaseHas ≙ seeInDatabase. Si dominaste Pest, leer estas suites te toma minutos — la estructura describe-it se vuelve métodos testNombre().

Puntos clave

  • Grupo tests + $refresh = BD limpia y migrada por cada test.
  • call/get/post devuelven TestResponse rico en asserts.
  • Bearer header real: el filtro api-token SÍ corre en feature tests.
  • seeInDatabase/grabFromDatabase verifican efectos, no pantallas.
  • Email/Sesión mockeadas por defecto: cero efectos secundarios.

41 · Depuración y Debug Toolbar

Avanzado ~15 min

CI4 trae de fábrica lo que en Laravel instalábamos como paquete: una Debug Toolbar que vive abajo de cada página mostrando consultas, timeline, logs y sesión. Junto con el logger por canales y el evento DBQuery, tienes observabilidad sin instalar nada.

  • Loggear por niveles con log_message() y ajustar thresholds.
  • Leer la Toolbar: collectors Database/Logs/Timeline/Routes.
  • Logear queries lentas con el evento DBQuery del cap 36.
  • Elegir herramientas: Toolbar vs Xdebug vs Sentry.

El logger: canales y umbrales

<?php
log_message('error', 'Fallo pago pedido #{id}', ['id' => $id]);
// writable/logs/log-2026-08-24.log:
// ERROR - 2026-08-24 14:03:11 -- Fallo pago pedido #4

Niveles RFC 5424: emergency, alert, critical, error, warning, notice, info, debug. Qué nivel se escribe se decide en app/Config/Logger.php — cada canal tiene su threshold (en development suele ser 9 = todo; production baja a error). Y los tests ya conocen la pareja: assertLogged() / assertLogContains().

La Toolbar que ya estabas viendo

Llevas viéndola desde el cap 2. Se activa sola cuando CI_DEBUG es true (boot de desarrollo), corre como after filter y NO aparece si baseURL no coincide con tu URL real ni jamás en producción:

PestañaMuestraCuándo salva vidas
Databasetodas las queries + tiemposauditar presupuesto cap 25
Timelineduración por fase del request«¿qué está lento?»
Logslo escrito este requesterrores silenciosos
Routestabla completa + cuál matcheórutas sombra (cap 7)
Events / Session / Cache / Files / Viewslo disparado, guardado, incluido«¿por qué no corrió mi listener?»

Collectors configurables en Config\Toolbar.php ($collectors); hot reloading desde 4.4. Si algún día necesitas apagarla: quita 'toolbar' de $required en Config/Filters.php.

Queries lentas al log, con DBQuery

El evento core del cap 36 paga aquí — detector propio en tres líneas:

<?php
// app/Config/Events.php
use CodeIgniter\Database\Query;

Events::on('DBQuery', static function (Query $query): void {
    $duracion = $query->getDuration();     // segundos (float)

    if ($duracion > 0.5) {
        log_message('warning', 'Query lenta ({t}s): {sql}', [
            't'   => round($duracion, 2),
            'sql' => $query->getQuery(),
        ]);
    }
});

Es el gemelo artesanal de DB::listen() del manual anterior — y gratis, porque el framework ya dispara el evento por cada consulta.

La caja de herramientas completa

HerramientaNivelPara qué
Debug Toolbardev, integradael 90% del día a día
Xdebug + IDEdev profundopaso a paso, breakpoints reales
logs + DBQuery listenerdev y producciónpost-mortem y vigilancia
Sentry/Bugsnag (SDK)producciónagregación de excepciones reales
Nunca al log: tokens Bearer completos, hashes de contraseña, datos de tarjetas. El log es la fuga silenciosa más común. Loguea IDs y estados, jamás secretos — misma regla del cap 41 de Laravel.

Puntos clave

  • log_message(nivel, msg, contexto) + thresholds por canal.
  • Toolbar = CI_DEBUG + after filter: queries/timeline/logs/routes.
  • No aparece si baseURL difiere o entorno es production.
  • DBQuery + getDuration(): detector de lentas sin instalar nada.
  • assertLogged/assertEventTriggered cierran tests de comportamiento.

42 · Spark a fondo: referencia operativa

Avanzado ~15 min

Cuarenta y un capítulos usando spark a destajo merecen el mapa — mismo formato del cap 42 de Laravel. Nada nuevo aquí: consolida familias con la variante correcta para cada momento. Guárdalo como chuleta.

Familia make: qué genera cada letra

ComandoGenera enOpciones útiles
make:controller Xapp/Controllers--bare (sin métodos), --restful, --suffix
make:model Xapp/Models--return entity|object|array, --table, --dbgroup
make:migration CreateXapp/Database/Migrations--session (tabla ci_sessions oficial)
make:seeder XSeederapp/Database/Seeds--namespace
make:entity / make:cell / make:transformerEntities/Cells/Transformers--suffix añade el sufijo de convención
make:command Xapp/Commandscomando spark propio (cap 38)
make:test XTesttests/Appskeleton PHPUnit listo
queue:job Xapp/Jobspaquete Queue (cap 35)

Base de datos: el ciclo completo

ComandoCuándo usarlo
migrateaplica pendientes (-g grupo, -n namespace, --all paquetes)
migrate:rollback -b2deshace el lote batch elegido
migrate:refreshrollback TODO + reaplica (dev only; NO existe fresh)
migrate:statusqué corrió, en qué batch, cuándo
db:seed ClaseSeedersiembra datos (nombre COMPLETO de la clase)

Colas y agenda (paquetes oficiales)

FamiliaComandos
queue:work cola (--stop-when-empty, -tries, -memory) · failed · retry · forget · flush · stop · clear
tasks:list · run (el crontab llama esto) · enable/disable

Auditoría e inspección diaria

php spark routes            # tabla completa ≙ route:list
php spark filter:check get admin/panel  # filtros por URI y verbo
php spark config:check App       # valores efectivos clase+.env
php spark phpini:check        # auditoria de tu php.ini
php spark cache:info / cache:clear   # store file

Optimización y servidor

ComandoHace
spark optimize(desde 4.5) prepara PRODUCCIÓN: quita dev packages + activa Config Caching y FileLocator Caching
spark serveservidor dev :8080 (--host/--port/--php) — JAMÁS producción
worker:installFrankenPHP Worker Mode (experimental, cap 4)
La trampa del Config Caching: tras optimize, los valores de config quedan CONGELADOS — editar .env ya no surte efecto hasta borrar esa caché. Es el «credenciales fantasma» en su forma más traicionera: si despliegas un cambio de .env, regenera o limpia la caché de configuración. Y optimize no se usa en Worker Mode.

Y lo que NO existe aquí (verificado contra el changelog): sin artisan down/up nativo — el mantenimiento se construye a mano, que es exactamente el tema del siguiente capítulo.

Puntos clave

  • make:* cubre todo el ciclo; opciones --suffix/--return evitan retoques.
  • migrate --all incluye migraciones de PAQUETES (Queue/Settings).
  • Auditoría diaria: routes, filter:check, config:check.
  • optimize = producción: congela config — cuidado con .env después.
  • Sin down/up nativo: filtro propio (cap 43).

43 · Despliegue y ventana de mantenimiento

Avanzado ~17 min

Verificado contra el changelog: CI4 NO trae down/up nativo. Así que construimos el nuestro — un filtro de mantenimiento con bypass personal que cierra el círculo perfecto: el front controller artesanal del primer manual, ahora al servicio de producción.

  • Escribir FiltroMantenimiento con flag en archivo y cookie-bypass.
  • Ejecutar la coreografía completa de despliegue.
  • Usar spark optimize sabiendo qué congela.
  • Chequear la lista oficial de producción sin improvisar nada.

FiltroMantenimiento: la puerta con llave

<?php
// app/Filters/FiltroMantenimiento.php (recortado)
public function before(RequestInterface $request, $arguments = null)
{
    if (! file_exists(WRITEPATH . 'mantenimiento')) {
        return;    // puerta abierta
    }

    // bypass: quien tenga la cookie pasa (tu verificacion post-deploy):
    if ($request->getCookie('paso-servicio') === env('mantenimiento.llave')) {
        return;
    }

    return service('response')->setStatusCode(503)
        ->setBody(view('errors/mantenimiento'));
}
<?php
// activar / desactivar:
touch(WRITEPATH . 'mantenimiento');
unlink(WRITEPATH . 'mantenimiento');

// tu acceso: una visita a /paso-servicio?llave=... emite la cookie:
$routes->get('paso-servicio', static function () {
    $llave = $this->request->getGet('llave');   // o service request
    if ($llave === env('mantenimiento.llave')) {
        return service('response')->setCookie(
            'paso-servicio', $llave, HOUR * 4)->redirect(base_url());
    }
});

El filtro va en $globals['before'] — corre para TODO antes de rutas, sesiones y colas. El estado vive en writable/ (como el archivo de Laravel vivía en bootstrap/cache/): si hay varios servidores, el flag debe compartirse.

La coreografía completa

# 1. cerrar la puerta:
touch writable/mantenimiento

# 2. pausar workers SIN perder el job actual:
# (supervisorctl stop pedidos-worker:* — cap 42 familia queue)

# 3. codigo nuevo + dependencias de produccion:
git pull && composer install --no-dev

# 4. esquema, TODOS los namespaces (app + paquetes):
php spark migrate --all
# 5. optimizacion oficial (4.5+):
# quita dev packages + cachea Config y FileLocator
php spark optimize

# 6. workers toman el codigo nuevo:
# (supervisorctl start pedidos-worker:*)

# 7. verificar CON tu cookie y reabrir:
unlink writable/mantenimiento

Checklist de producción

ÍtemPor qué
CI_ENVIRONMENT = productionmuestra errores genéricos, apaga Toolbar
DocumentRoot → public/app/, vendor/, .env fuera del alcance web
writable/ escribible por www-datalogs, caché, sesiones, uploads
Backup ANTES de migrate --allrollback de datos no existe
forcehttps filter activotodo a HTTPS (cap 33)
Health check propio (/salud)ruta GET trivial monitoreada
.env fuera del repo SIEMPREregla desde el cap 5, inamovible
El contraste honesto con Laravel: allá down --secret era una línea; aquí son ~30 escritas por ti. Pero ahora SABES cómo funciona un modo de mantenimiento por dentro — flag en disco, cookie de bypass, filtro global. El patrón te pertenece, no el comando.

Puntos clave

  • Sin down/up nativo: flag en writable/ + filtro global + cookie-bypass.
  • Orden sagrado: cerrar → parar workers → código → migrate --all → optimize → abrir.
  • optimize congela Config: cambios de .env exigen regenerar caché.
  • composer install --no-dev u optimize — nunca vendor de dev en prod.
  • Mismo checklist de siempre: backup, permisos, DocumentRoot, HTTPS.

44 · Rumbo a la meta: mapa y graduación

Meta ~12 min

Cuarta vuelta completada. Pedidos existe ahora en cuatro dialectos: artesanal, Eloquent standalone, Laravel y CodeIgniter. Este capítulo cierra el mapa, entrega el diccionario definitivo de tres columnas y te deja el examen final — porque graduarse es construir sin guía.

El recorrido por partes

ParteCapsLo que dominas
I · Del standalone al framework1–7instalación explícita, anatomía 5 carpetas, spark, config clases+.env, helpers, Routes.php
II · Núcleo HTTP8–13BaseController, vistas PHP+layouts, cells, filtros, formularios CSRF, sesiones flashdata
III · Base de datos14–19MariaDB, Forge/Fields espejo de tienda_orm, modelos CRUD integrado, seeders+Fabricator, joins a mano
IV · CRUD web Pedidos20–25transacciones, estados terminales, builder profundo, Pager, dashboard con presupuesto
V · API REST26–30resource(), ResponseTrait, entidades+transformers, tokens Bearer propios, fetch JS
VI · Seguridad y sesión31–34auth artesanal, roles con filtros/guardianes, blindaje OWASP, uploads validados
VII · Servicios35–39paquete Queue, eventos core, Email+Mailpit, Tasks+cron, caché remember()
VIII · Calidad y producción40–43PHPUnit feature/BD, Toolbar+logs, spark a fondo, despliegue con mantenimiento propio

El diccionario definitivo: artesanal → Laravel → CI4

PiezaLaravelCodeIgniter 4
Rutas CRUDRoute::apiResource$routes->resource + except
PlantillasBlade {{ }} / x-componentesesc() + layout()/cells
Filtro HTTPmiddlewarefiltros before/after
RelacionesbelongsTo + eager loadmétodos JOIN a mano
Transformador JSONJsonResourceBaseTransformer ?fields/?include
Tokens APISanctumtabla + filtro propio (cap 28)
Colascore (Queue::)paquete oficial Queue
Schedulercore Schedulepaquete oficial Tasks
TestingPest actingAsPHPUnit withSession/Bearer
Mantenimientodown --secret nativofiltro propio flag+bypass

Diez filas, tres frameworks, UN patrón. Esa columna izquierda es tu propiedad intelectual — las otras dos son sintaxis consultable.

Examen de graduación: módulo Facturación

  • Migración facturas FK RESTRICT a pedidos + modelo con entidad (casts float).
  • PedidoTransformer extendido con ?include=factura.
  • Endpoints API: GET index/show con token; PATCH emitir SOLO ApiAuth::$esAdmin (403 si no); número correlativo F001-0001 e importe congelado.
  • Evento factura.emitida + listener que encola job de correo al cliente.
  • Tarea programada mensual que despacha reporte de facturación.
  • Suite PHPUnit: feature test del flujo completo con seeInDatabase y assertEventTriggered.

Si los seis puntos salen sin releer el manual, dominaste CI4 — y por construcción, también el patrón que Laravel y tu artesanal comparten.

Rutas futuras

  • Shield: roles y permisos listos cuando lo artesanal se quede corto.
  • FrankenPHP Worker Mode: el futuro residente-en-memoria (4.7 experimental).
  • Symfony comparativo: el tercer dialecto grande de la serie futura.
  • Serie IA: agentes y LLMs aplicados a apps PHP reales.
La lección de las cuatro vueltas: cada framework resolvió el mismo dominio con herramientas distintas — y en todas sobrevivieron los mismos patrones: transacción para el alta, precio congelado, presupuesto de queries, validación doble, seguridad como diseño. Los frameworks pasan; el oficio queda. Eso era la meta desde el capítulo uno del manual HTML.

Puntos clave

  • 44 caps, 8 partes: del esqueleto slim al despliegue supervisado.
  • El diccionario de 3 columnas cierra la serie PHP completa.
  • Facturación: el examen mide independencia, no memoria.
  • Shield/Worker Mode/Symfony/IA: siguientes estaciones, no urgencias.