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.
1 · Por qué CodeIgniter y qué resuelve
Básico ~14 minCuarta 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:
| Pieza | Laravel (manual anterior) | CodeIgniter 4 |
|---|---|---|
| Rutas | routes/web.php fluido | app/Config/Routes.php con $routes->get() |
| CLI | artisan | spark |
| Vistas | Blade ({{ }}, @extends, x-componentes) | PHP nativo + layout() + esc() |
| ORM | Eloquent (relaciones automáticas) | Model + Query Builder explícitos |
| Peticiones HTTP | middleware por capas | filtros before/after |
| Validación | validate() + Form Requests | servicio $validation |
| Migraciones | Schema::create | Forge + Fields |
| Datos de prueba | factories + Pest | Fabricator + PHPUnit |
| Servidor dev | artisan serve | php 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:
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.
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 minLa 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
| Requisito | Versión / valor | Notas |
|---|---|---|
| PHP | 8.2 o superior | PHP 8.5 exige CI ≥ 4.7.0; 8.4 exige ≥ 4.6.0 |
| ext-intl | OBLIGATORIA | internacionalización; sin ella CI4 ni arranca |
| ext-mbstring | OBLIGATORIA | cadenas multibyte |
| Composer | ≥ 2.0.14 | composer --version |
| MariaDB | la de siempre | ví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:
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
composer create-project codeigniter4/appstarter pedidos
cd pedidos
ls
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
cp env .env
# 2. edita .env — las dos líneas minimas:
CI_ENVIRONMENT = development
app.baseURL = 'http://localhost:8080/'
Y permisos para writable/ — aquí viven caché, logs y subidas, el gemelo de storage/ + bootstrap/cache/ que ya conoces:
sudo chmod -R u+rwx,g+rx writable
Primer arranque
# 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 --host pedidos.test # otro host (via /etc/hosts)
Novedad útil desde 4.5 — auditoría de tu php.ini contra los valores recomendados:
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 minCinco 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
|-- 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 CI4 | Equivalente Laravel | Papel |
|---|---|---|
| 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/…/system | vendor/laravel/framework | el 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 muestraDos 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
| Namespace | Carpeta | Regla |
|---|---|---|
| App | app/ | TUYO: modifica, renombra, amplía libremente |
| CodeIgniter | vendor/…/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>/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 minspark 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 serve --port 8081 # comando + opcion
php spark make:controller PedidoControlador # argumento
php spark migrate --help # ayuda de UN comando
| Concepto | Laravel (artisan) | CodeIgniter (spark) |
|---|---|---|
| Listar comandos | php artisan list | php spark list |
| Servidor dev | artisan serve (:8000) | php spark serve (:8080) |
| Generadores | make:model, make:controller… | spark make:controller, make:model… |
| Ayuda por comando | artisan help X / X --help | spark 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 -> navegadorTres 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
// 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.
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 minLaravel 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 newEl 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:
app.baseURL = 'http://pedidos.test/'
app.appTimezone = 'America/Lima'
# equivalente con guion bajo (util en Docker):
app_baseURL = 'http://pedidos.test/'
Extras útiles del formato: variables anidadas con ${OTRA}, y las
variables ya presentes en el entorno real nunca se sobreescriben:
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»:
# Config\App#6 (12) (
# public 'baseURL' -> "http://localhost:8080/"
# public 'indexPage' -> "index.php"
# ...
| Necesidad | Laravel | CodeIgniter 4 |
|---|---|---|
| Archivo(s) | config/*.php (arreglos) | app/Config/*.php (clases) |
| Leer valor | config('app.name') | config('App')->name |
| Entorno | .env → env() | .env → getenv()/$_ENV |
| Puente | carga directa del arreglo | prefijo punto reemplaza propiedad |
| Inspección | php artisan about | php 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 minMismo 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
| Helper | Funciones estrella | Uso típico aquí |
|---|---|---|
| url (auto) | url_to(), site_url(), anchor() | rutas inversas cap 7, links en vistas |
| form | form_open(), csrf_field() | formularios + CSRF caps 12–13 |
| text | word_limiter(), character_limiter() | resúmenes en listados |
| number | number_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);
}
}# $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'];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 minEl 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
| Placeholder | Coincide 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) |
¿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 actualGrupos: 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
# +--------+-------------------+--------------------------------+
# | 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 minAbre 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
| Retorno | Cuándo | Ejemplo |
|---|---|---|
| string | vista renderizada o HTML/texto plano | return view('pedidos/lista'); |
| Response | control fino de headers/status (API caps 26+) | return $this->response->setJSON($datos); |
| RedirectResponse | tras POST exitoso o denegado | return redirect()->to('/pedidos'); |
Con named routes del cap 7, redirige por nombre:
redirect()->route('pedido.detalle', [15]).
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 minAquí 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 ?>| Necesidad | Blade (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/@empty | if (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']);['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 minBlade 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:
# 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?
| Necesidad | Herramienta | Eco Laravel |
|---|---|---|
| Solo HTML repetido | $this->include() | @include |
| HTML + consulta/cálculo simple | Simple Cell | componente con lógica |
| Componente reutilizable con props | Controlled Cell | <x-componente :prop=""> |
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 minTodo 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
| Alias | Hace | Cuando te toca |
|---|---|---|
| csrf | valida el token en POST/PUT/PATCH/DELETE | cap 12 — ya viene global |
| toolbar | inyecta la Debug Toolbar en desarrollo | ya activo ($required) |
| forcehttps | manda todo a HTTPS | producción |
| pagecache / performance / secureheaders | caché de página, cabecera de tiempos, cabeceras seguras | despliegue cap 43 |
| honeypot / invalidchars / cors | anti-bots, caracteres inválidos, CORS | seguridad caps 32–33 |
¿Dudas sobre qué corrió en una URI? Inspección directa:
# Filtros before: forcehttps, csrf, filtro-admin
# Filtros after: pagecache, toolbar, performance
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 minEl 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.");
}| Necesidad | Regla exacta CI4 |
|---|---|
| Obligatorio / opcional vacío | required / permit_empty |
| Largo mínimo/máximo | min_length[3] · max_length[120] |
| Email válido | valid_email |
| Número decimal / entero | decimal · integer · numeric |
| Único en tabla (¡sin espacios!) | is_unique[clientes.email] |
| Igual a otro campo | matches[campo] |
| Dentro de lista / regex propia | in_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();
}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 minCierra 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 31Flashdata y tempdata: vidas distintas
| Tipo | Vive | Uso canónico |
|---|---|---|
| setFlashdata / getFlashdata | EXACTAMENTE una petición siguiente | mensajes tras redirect (with()) |
| keepFlashdata('clave') | prolonga otra petición más | redirect 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
| Handler | Guardar | Cuándo usarlo |
|---|---|---|
| files (default) | writable/session/ | dev y apps pequeñas — «el más seguro» según la doc |
| database | tabla ci_sessions | servers múltiples (nuestro cap 31+) |
| redis / memcached | servidor dedicado | alto tráfico |
| array | nada (memoria) | solo tests |
Cambio a database en dos pasos — configuración y tabla oficial:
# 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
# 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.
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 minAbrimos 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
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 = 3306El 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=mysql | database.default.DBDriver = MySQLi |
| DB_DATABASE=… · DB_USERNAME=… · DB_PASSWORD=… | database.default.database/username/password |
| DB_HOST · DB_PORT | database.default.hostname/port |
| DB_PREFIX | database.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 minLa 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
# 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 arreglo | Significa |
|---|---|
| 'type' · 'constraint' | INT · VARCHAR(120) — constraint = largo o enum de valores |
| 'unsigned' · 'auto_increment' | solo positivos · autonumérico |
| 'null' => true | permite NULL (SIN esta clave la columna es NOT NULL) |
| 'default' => valor | valor 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
# 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
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 minCompletamos 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
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
| Comando | Hace | Eco Laravel |
|---|---|---|
| php spark migrate | aplica pendientes | migrate |
| migrate:rollback -b2 | deshace UN lote (-b elige cuál) | rollback --step |
| migrate:refresh | rollback TODO + reaplica | fresh/refresh |
| migrate:status | qué corrió, en qué batch, cuándo | migrate: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).
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 minEl 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
# 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:
| Concepto | Eloquent (Laravel) | CI4 Model |
|---|---|---|
| Asignación masiva | $fillable / $guarded | $allowedFields (la PK jamás va) |
| Timestamps | PUBLIC $timestamps = true | $useTimestamps (mismos campos created_at/updated_at) |
| Formato de retorno | objetos 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, ...]); // insertRegla 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.
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 minMismo 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
# 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'],
]);
}
}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 DatabaseSeederPedidosSeeder: 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.
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 minAquí 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 CI4 | Hace | Primo Eloquent |
|---|---|---|
| ->select('a, b') / selectCount('x')/selectSum('total') | columnas/agregados | select()/withCount() |
| ->join('t', 'cond', 'left') | JOIN (inner/left/right...) | join() o belongsTo |
| ->where/orWhere/whereIn/like | condiciones (valores escapados) | mismos nombres |
| ->groupBy · orderBy('col','DESC') · limit(10) | orden y corte | groupBy/latest/take |
| ->get() -> getResult()/getRow() | ejecuta y devuelve filas | get()->rows |
| ->countAllResults() | cuenta lo filtrado | count() |
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 vistaEn 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();
}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 minAbrimos 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>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 minEl 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
}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 minCompletamos 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('cliente', $pedido['cliente_id']) ?>": 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:
| Pieza | Requisito | Efecto |
|---|---|---|
| $useSoftDeletes = true | columna deleted_at nullable | delete() marca fecha en vez de borrar |
| $deletedField = 'deleted_at' | (default) | find/findAll excluyen borrados automáticamente |
| ->withDeleted() / ->onlyDeleted() | en consultas | incluyen / muestran SOLO borrados |
| delete($id, true) | purga | borrado 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 minEn 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
| Necesidad | Dónde | Por qué |
|---|---|---|
| Filtro reutilizable simple | método del modelo | compone con otros y cierra en findAll/paginate |
| Consulta con JOIN + alias | método del modelo | la vista recibe datos listos |
| Reporte one-off complejo | $db->table() directo | no ensuciar el modelo de usos únicos |
| Subconsulta puntual | closure en where() | sintaxis oficial, legible |
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 minTercera 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
| Herramienta | Hace |
|---|---|
| $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
| Manual | Código | Vista |
|---|---|---|
| MVC artesanal | paginado() con COUNT+LIMIT+OFFSET manuales | links escritos a dedo |
| Laravel | ->paginate(15)->withQueryString() | {{ $x->links() }} |
| CI4 | ->paginate(20) | <?= $pager->only([...])->links() ?> |
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 minCierre 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:
| Query | Alimenta | Costo |
|---|---|---|
| Q1 resumenPorEstado() | tarjetas por estado + ingresos totales | GROUP BY único |
| Q2 stock bajo activos | alerta de reposición | COUNT filtrado |
| Q3 clientesConPedidos() | ranking LEFT JOIN+GROUP BY | 1 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
# render cacheado: Q1 + Q2 + [cache hit top-clientes] + lista = 3 queries
# objetivo cumplido: dashboard completo <= 5 queries
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 minAbrimos 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
]);
});| Verbo | URI | Método |
|---|---|---|
| GET | /api/pedidos | index() |
| GET | /api/pedidos/5 | show($id) |
| POST | /api/pedidos | create() |
| PUT/PATCH | /api/pedidos/5 | update($id) |
| DELETE | /api/pedidos/5 | delete($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 trait | HTTP | Cuándo |
|---|---|---|
| respond($data, 200) | 200 | éxito genérico |
| respondCreated($data) | 201 | recurso creado |
| respondDeleted($data) | 200 | borrado exitoso |
| respondNoContent() | 204 | ok sin cuerpo |
| failUnauthorized() | 401 | token ausente o inválido (reintenta con credenciales) |
| failForbidden() | 403 | autenticado pero prohibido (no insistas) |
| failNotFound() | 404 | recurso inexistente |
| failValidationErrors($errors) | 400 | reglas incumplidas |
| failResourceExists/Gone/TooManyRequests/failServerError | 409/410/400+/500 | casos 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);curl -H "Accept: application/json" http://localhost:8080/api/pedidos
# [{"id":1,"cliente_id":1,"estado":"PAGADO",...}, ...]
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 minDevolver 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
# 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}
}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 minSin 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
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.
# 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,
];-H "Accept: application/json" http://localhost:8080/api/pedidos
curl http://localhost:8080/api/pedidos
# {"status":401,"messages":{"error":"Token requerido."}}
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 minEl 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]);
}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."
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 minCierre 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
| Aspecto | Formularios web (caps 12–22) | API con tokens |
|---|---|---|
| Credencial | cookie de sesión automática | header Authorization explícito |
| CSRF | obligatorio (@csrf / csrf_field) | no aplica: sin cookie que secuestrar |
| Cuerpo | multipart/form-data | application/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.
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 minMisma 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
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 ?>| Paso | Laravel (cap 31) | CI4 (este cap) |
|---|---|---|
| Guardar clave | Hash::make() | password_hash(PASSWORD_DEFAULT) |
| Verificar | Hash::check() | password_verify() |
| Sesión post-login | $request->session()->regenerate() | session()->regenerate() |
| Starter kit oficial | Breeze / Fortify | Shield (mencionado, no usado) |
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 minAutenticado 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
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
| Necesidad | Laravel (cap 32) | CI4 |
|---|---|---|
| Zona completa protegida | middleware auth en grupo | filtro por group()/URI pattern |
| Regla por recurso («dueño o admin») | Policy update($user,$pedido) | método guardián + consulta del modelo |
| Permiso puntual booleano | Gate::define | session('es_admin') / ApiAuth::$esAdmin |
| Kit oficial completo | Breeze/Fortify | Shield (roles/permisos listos) |
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 minEl 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
| Amenaza | Defensa CI4 | ¿Automática? |
|---|---|---|
| XSS | función esc() + tu disciplina en cada echo | NO — es TU responsabilidad (cap 9) |
| SQL Injection | Query Builder + bindings escapados por driver | SÍ, si usas builder/prepare |
| CSRF | filtro csrf global + csrf_field() | SÍ (ya viene conectado) |
| Mass assignment | $allowedFields del modelo | SÍ si declaraste campos |
| Acceso directo al código | public/ como único webroot | SÍ con DocumentRoot correcto |
| Session fixation | regeneración automática + regenerate() | SÍ (cap 13/31) |
| Cabeceras inseguras | filtro secureheaders | a petición ($required/$globals) |
| Fuerza bruta / abuso | librería Throttler (rate limit) | a petición |
| HTTPS ausente | forcehttps filter / forceGlobalSecureRequests | a 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.
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 minEl 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ón | Cómo | Cuándo |
|---|---|---|
| public/uploads/ (este cap) | URL directa: /uploads/productos/x.jpg | imágenes públicas de catálogo — el navegador las pide sin PHP |
| writable/uploads/ (recomendación oficial) | $file->store() crea YYYYMMDD/nombre-aleatorio | archivos 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étodo | Fuente | Confiabilidad |
|---|---|---|
| getClientName() / getClientExtension() / getClientMimeType() | el NAVEGADOR del cliente | NO confiar — falsificables |
| guessExtension() / getMimeType() | análisis real del contenido | confiables para decidir |
| getSizeByUnit('kb') | tamaño real subido | confiable |
| hasMoved() / getErrorString() | estado del archivo | diagnó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.
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 minAquí 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
# 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()
# 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());
}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)
| Comando | Hace |
|---|---|
| queue:work <cola> | worker continuo; opciones -sleep, -max-jobs, -memory, -tries, -priority, --stop-when-empty |
| queue:failed | lista los caídos (tabla queue_jobs_failed) |
| queue:retry · queue:forget · queue:flush | reintentar uno/todos · borrar uno · vaciar |
| queue:stop · queue:clear | parar worker tras el job actual · vaciar cola |
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 minAquí 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 primeroPrioridades: 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
| Forma | Ejemplo | Cuándo |
|---|---|---|
| Closure | Events::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
| Evento | Se dispara | Eco futuro |
|---|---|---|
| pre_system / post_system | arranque / antes de enviar respuesta | instrumentación global |
| post_controller_constructor | controlador listo, método por correr | auditoría por acción |
| DBQuery | TRAS CADA query SQL | query log del cap 41 |
| correo enviado con éxito | bitácora de envíos (cap 37) | |
| migrate · pre_command/post_command | migraciones y comandos spark | pipelines de despliegue |
Para pruebas existen Events::simulate(true) (ignora todos los
eventos) — lo usaremos en el cap 40.
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 minEl 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
# 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
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.");
});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 minEl 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
# (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
}
}| Frecuencia | Equivale 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
// 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
* * * * * cd /var/www/pedidos && php spark tasks:run >> writable/logs/tasks.log 2>&1
# auditoria:
php spark tasks:list
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 minCerramos 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 SEGUNDOSEl 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 MariaDBOtros métodos del handler:
| Método | Hace |
|---|---|
| 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: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.
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 minDiferencia 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 = MySQLiEl grupo tests vive en Config/Database.php igual que default — tus datos reales jamás corren riesgo.
Un feature test completo
<?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
| Necesidad | PHPUnit + CI4 | Pest (Laravel) |
|---|---|---|
| Código HTTP | ->assertStatus(200) · assertOK() | assertStatus/assertOk |
| JSON contiene | assertJSONFragment([...]) | assertJsonFragment |
| Fila existe en BD | $this->seeInDatabase(tabla,criterio) | assertDatabaseHas |
| Sesión tras login | assertSessionHas('usuario_id') | assertAuthenticated |
| Se escribió al log | $this->assertLogged('error') | Log::spy |
| Evento disparado | assertEventTriggered('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.
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 minCI4 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 #4Niveles 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ña | Muestra | Cuándo salva vidas |
|---|---|---|
| Database | todas las queries + tiempos | auditar presupuesto cap 25 |
| Timeline | duración por fase del request | «¿qué está lento?» |
| Logs | lo escrito este request | errores silenciosos |
| Routes | tabla completa + cuál matcheó | rutas sombra (cap 7) |
| Events / Session / Cache / Files / Views | lo 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
| Herramienta | Nivel | Para qué |
|---|---|---|
| Debug Toolbar | dev, integrada | el 90% del día a día |
| Xdebug + IDE | dev profundo | paso a paso, breakpoints reales |
| logs + DBQuery listener | dev y producción | post-mortem y vigilancia |
| Sentry/Bugsnag (SDK) | producción | agregación de excepciones reales |
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 minCuarenta 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
| Comando | Genera en | Opciones útiles |
|---|---|---|
| make:controller X | app/Controllers | --bare (sin métodos), --restful, --suffix |
| make:model X | app/Models | --return entity|object|array, --table, --dbgroup |
| make:migration CreateX | app/Database/Migrations | --session (tabla ci_sessions oficial) |
| make:seeder XSeeder | app/Database/Seeds | --namespace |
| make:entity / make:cell / make:transformer | Entities/Cells/Transformers | --suffix añade el sufijo de convención |
| make:command X | app/Commands | comando spark propio (cap 38) |
| make:test XTest | tests/App | skeleton PHPUnit listo |
| queue:job X | app/Jobs | paquete Queue (cap 35) |
Base de datos: el ciclo completo
| Comando | Cuándo usarlo |
|---|---|
| migrate | aplica pendientes (-g grupo, -n namespace, --all paquetes) |
| migrate:rollback -b2 | deshace el lote batch elegido |
| migrate:refresh | rollback TODO + reaplica (dev only; NO existe fresh) |
| migrate:status | qué corrió, en qué batch, cuándo |
| db:seed ClaseSeeder | siembra datos (nombre COMPLETO de la clase) |
Colas y agenda (paquetes oficiales)
| Familia | Comandos |
|---|---|
| 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 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
| Comando | Hace |
|---|---|
| spark optimize | (desde 4.5) prepara PRODUCCIÓN: quita dev packages + activa Config Caching y FileLocator Caching |
| spark serve | servidor dev :8080 (--host/--port/--php) — JAMÁS producción |
| worker:install | FrankenPHP Worker Mode (experimental, cap 4) |
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 minVerificado 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
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
# 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
| Ítem | Por qué |
|---|---|
| CI_ENVIRONMENT = production | muestra errores genéricos, apaga Toolbar |
| DocumentRoot → public/ | app/, vendor/, .env fuera del alcance web |
| writable/ escribible por www-data | logs, caché, sesiones, uploads |
| Backup ANTES de migrate --all | rollback de datos no existe |
| forcehttps filter activo | todo a HTTPS (cap 33) |
| Health check propio (/salud) | ruta GET trivial monitoreada |
| .env fuera del repo SIEMPRE | regla desde el cap 5, inamovible |
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 minCuarta 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
| Parte | Caps | Lo que dominas |
|---|---|---|
| I · Del standalone al framework | 1–7 | instalación explícita, anatomía 5 carpetas, spark, config clases+.env, helpers, Routes.php |
| II · Núcleo HTTP | 8–13 | BaseController, vistas PHP+layouts, cells, filtros, formularios CSRF, sesiones flashdata |
| III · Base de datos | 14–19 | MariaDB, Forge/Fields espejo de tienda_orm, modelos CRUD integrado, seeders+Fabricator, joins a mano |
| IV · CRUD web Pedidos | 20–25 | transacciones, estados terminales, builder profundo, Pager, dashboard con presupuesto |
| V · API REST | 26–30 | resource(), ResponseTrait, entidades+transformers, tokens Bearer propios, fetch JS |
| VI · Seguridad y sesión | 31–34 | auth artesanal, roles con filtros/guardianes, blindaje OWASP, uploads validados |
| VII · Servicios | 35–39 | paquete Queue, eventos core, Email+Mailpit, Tasks+cron, caché remember() |
| VIII · Calidad y producción | 40–43 | PHPUnit feature/BD, Toolbar+logs, spark a fondo, despliegue con mantenimiento propio |
El diccionario definitivo: artesanal → Laravel → CI4
| Pieza | Laravel | CodeIgniter 4 |
|---|---|---|
| Rutas CRUD | Route::apiResource | $routes->resource + except |
| Plantillas | Blade {{ }} / x-componentes | esc() + layout()/cells |
| Filtro HTTP | middleware | filtros before/after |
| Relaciones | belongsTo + eager load | métodos JOIN a mano |
| Transformador JSON | JsonResource | BaseTransformer ?fields/?include |
| Tokens API | Sanctum | tabla + filtro propio (cap 28) |
| Colas | core (Queue::) | paquete oficial Queue |
| Scheduler | core Schedule | paquete oficial Tasks |
| Testing | Pest actingAs | PHPUnit withSession/Bearer |
| Mantenimiento | down --secret nativo | filtro 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.
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.