Blade · el motor de plantillas
Instalamos el motor de Laravel como paquete standalone sobre nuestro proyecto MVC artesanal y migramos el dominio Pedidos capítulo a capítulo: escape automático, directivas, herencia, componentes, formularios y directivas propias.
1 · Por qué un motor de plantillas
Básico ~13 minNuestro proyecto MVC funciona — pero seamos honestos sobre su capa de vistas: extract() mágico, includes encadenados para armar páginas y un contrato disciplinario con nosotros mismos de escribir e() ANTES de cada echo. Blade no inventa nada nuevo: automatiza exactamente eso, y compila tus plantillas a PHP plano.
- Inventariar los dolores reales de nuestra capa vista()+layout().
- Entender qué es un motor que COMPILA plantillas (no interpreta).
- Ver la misma vista artesanal y su versión Blade lado a lado.
- Fijar el alcance: solo el motor, el front controller sigue siendo nuestro.
El inventario honesto de nuestras vistas
| Pieza | Versión artesanal (caps. MVC) | Lo que hará Blade |
|---|---|---|
| Echo seguro | <?= e($nombre) ?> — a mano SIEMPRE | {{ $nombre }} — automático e infalible |
| Condicionales | <?php if (...): ?> ... <?php endif; ?> | @if ... @endif |
| Bucles | foreach con llaves mezcladas al HTML | @foreach + variable $loop gratis |
| Layout común | include('cabecera.php') arriba y abajo | @extends/@section — herencia real |
| Fragmentos | include con variables previamente extraídas | @include y componentes x- |
| Errores de sintaxis | PHP roto visible al usuario | plantilla compilada con errores claros |
Una vista, dos mundos
Así se ve hoy el listado de pedidos en nuestro MVC:
<?php // vistas/pedidos/lista.php (actual) ?>
<?php include __DIR__ . '/../plantillas/cabecera.php'; ?>
<h1>Pedidos</h1>
<?php foreach ($pedidos as $p): ?>
<p>#<?= (int)$p['id'] ?> — <?= e($p['cliente']) ?></p>
<?php endforeach; ?>
<?php include __DIR__ . '/../plantillas/pie.php'; ?>Y así se verá con Blade al final de este manual:
<!-- vistas/pedidos/lista.blade.php -->
@extends('layouts.base')
@section('titulo', 'Pedidos')
@section('contenido')
<h1>Pedidos</h1>
@foreach ($pedidos as $p)
<p>#{{ $p['id'] }} — {{ $p['cliente'] }}</p>
@endforeach
@endsectionMismo HTML resultante, cero llamadas a e(), layout declarado en vez de includes apilados. La lógica sigue viviendo en el controlador — Blade NO invita a meter negocio en las vistas; solo elimina el trabajo mecánico.
Compilado, no interpretado
El dato técnico que despeja el miedo al rendimiento: Blade NO procesa tus llaves en cada petición. La primera vez que una plantilla se usa, se compila a un archivo PHP puro que se guarda en disco; desde entonces el servidor ejecuta ese PHP normal a velocidad nativa. Solo recompila si editas la plantilla. Motor de plantillas = azúcar que desaparece antes de llegar al servidor web.
Puntos clave
- Blade automatiza: escape, condicionales, bucles, layouts, fragmentos.
- Compila a PHP plano una vez; luego velocidad nativa.
- Sintaxis declarativa (@foreach) en vez de PHP mezclado al HTML.
- La regla «lógica en el controlador» sobrevive intacta.
- Manual práctico: migraremos Pedidos progresivamente, sin big-bang.
2 · Instalación e integración al MVC
Básico ~15 minLa decisión de diseño de este manual: el motor entra como un paquete composer más, envuelto en UNA clase propia que nuestras vistas actuales ni se enteran. Requisitos verificados: PHP 8.1+ (nosotros ya vamos por 8.3+).
- Instalar jenssegers/blade v2.0.1 y saber qué trae dentro.
- Crear la carpeta de caché de plantillas compiladas.
- Encapsular el motor en app/Nucleo/MotorVistas.php.
- Conectarlo a un controlador SIN romper las vistas viejas.
Instalación
# v2.0.1 · PHP >= 8.1 · trae illuminate/view (motor real de Laravel)
Dato de transparencia: la versión 2.0.1 empaqueta el motor Blade de Illuminate 11. La sintaxis que aprenderemos es EXACTAMENTE la misma vigente en Laravel actual — componentes anónimos, slots, $attributes incluidos. Cuando llegues al manual de Laravel, todo te sonará.
Dos carpetas nuevas
touch cache/vistas/.gitignore && echo "*" > cache/vistas/.gitignore
| Carpeta | Papel | Regla |
|---|---|---|
| vistas/ | tus plantillas .blade.php | la de siempre — conviven con los .php viejos |
| cache/vistas/ | el PHP compilado | escribible, fuera del git, borrible sin miedo |
MotorVistas.php: nuestro único acoplamiento
El paquete jamás se usa directo desde controladores — lo envolvemos para que mañana cambiar de motor (o irse a Laravel) sea tocar UN archivo:
<?php
// app/Nucleo/MotorVistas.php
namespace App\Nucleo;
use Jenssegers\Blade\Blade;
final class MotorVistas
{
private static ?Blade $blade = null;
public static function render(string $plantilla, array $datos = []): string
{
return self::motor()->make($plantilla, $datos)->render();
}
private static function motor(): Blade
{
return self::$blade ??= new Blade(
__DIR__ . '/../../vistas', // donde viven las plantillas
__DIR__ . '/../../cache/vistas' // donde compila
);
}
}Singleton consciente: el motor es caro de construir y compartido — una
instancia por request. make() acepta notación punto:
pedidos.lista busca vistas/pedidos/lista.blade.php.
Primer controlador conectado
<?php
// app/Controladores/ControladorPedidos.php — metodo nuevo de prueba
public function demoBlade(): void
{
echo \App\Nucleo\MotorVistas::render('prueba', [
'titulo' => 'Blade funciona',
]);
}
// rutas.php:
$ruta->get('/demo-blade', 'ControladorPedidos@demoBlade');<!-- vistas/prueba.blade.php -->
<h1>{{ $titulo }}</h1>
<p>Compilado en: {{ date('H:i:s') }}</p># visita /demo-blade → "Blade funciona"
ls cache/vistas/ # hay un .php con hash — ese ES tu template traducido
Las vistas .php antiguas SIGUEN funcionando por su camino de siempre — la migración será capítulo a capítulo, archivo por archivo.
Puntos clave
- jenssegers/blade = el motor real de Laravel como paquete suelto.
- Un wrapper propio (MotorVistas) aísla la dependencia.
- make('punto.notacion') + render(): el contrato mínimo.
- cache/vistas/ guarda el PHP compilado: borrar = recompilar.
- Migración progresiva: .php y .blade.php conviven sin conflicto.
3 · Primer render real: la lista de pedidos
Básico ~14 minDel archivo de prueba al primer pedazo REAL del dominio: migramos el listado de pedidos completo — con su tabla, sus estados y su moneda — a una plantilla .blade.php. Sin directivas avanzadas todavía: solo datos, bucles y condicionales básicos.
- Migrar vistas/pedidos/lista.php → lista.blade.php.
- Usar @if/@foreach y la variable $loop por primera vez.
- Reutilizar los helpers propios (moneda()) dentro de {{ }}.
- Verificar que el HTML resultante es IDÉNTICO al artesanal.
La plantilla migrada
<!-- vistas/pedidos/lista.blade.php -->
<?php include __DIR__ . '/../plantillas/cabecera.php'; ?>
<h1>Pedidos registrados</h1>
@if (empty($pedidos))
<p>Sin pedidos todavía.</p>
@else
<table class="table">
<thead>
<tr><th>#</th><th>Cliente</th><th>Estado</th><th>Total</th></tr>
</thead>
<tbody>
@foreach ($pedidos as $p)
<tr>
<td>#{{ $p['id'] }}</td>
<td>{{ $p['cliente'] }}</td>
<td>{{ $p['estado'] }}</td>
<td>{{ moneda($p['total']) }}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
<?php include __DIR__ . '/../plantillas/pie.php'; ?>Tres observaciones del primer contacto:
- {{ moneda($p['total']) }}: dentro de las llaves hay PHP completo — tus helpers del manual php_01 siguen funcionando, ya sin envolverlos en e().
- @if/@else/@endif: misma lógica, cero llaves PHP mezcladas con HTML.
- Los includes de cabecera/pie quedan PROVISIONALMENTE — mueren en la Parte III cuando llegue la herencia de layouts.
El controlador, antes y después
<?php
// ANTES (MVC puro):
public function listar(): void
{
echo vista('pedidos/lista', ['pedidos' => $this->modelo->todos()]);
}
// DESPUES (Blade):
public function listar(): void
{
echo \App\Nucleo\MotorVistas::render('pedidos.lista',
['pedidos' => $this->modelo->todos()]);
}$loop: gratis y útil desde hoy
Dentro de cada @foreach existe una variable $loop con metadatos del ciclo — cosas que en el artesanal calculabamos a mano:
| Propiedad | Valor | Uso típico |
|---|---|---|
| $loop->index | 0, 1, 2… | numerar filas |
| $loop->iteration | 1, 2, 3… | números para humanos |
| $loop->first / ->last | true en el borde | estilos especiales de borde |
| $loop->even / ->odd | alternancia | zebra sin CSS nth-child |
| $loop->count | total de ítems | "mostrando N registros" |
<tr class="{{ $loop->even ? 'fila-par' : '' }}">Verificación honesta: diff de HTML
curl http://localhost:8080/pedidos -o nueva.html
# (visita tambien la ruta vieja si aun existe)
diff vieja.html nueva.html # idealmente: vacio o solo espacios
Puntos clave
- @if/@foreach reemplazan el PHP mezclado; helpers propios siguen vivos.
- $loop trae index/first/last/even/count gratis en cada bucle.
- render('carpeta.archivo') = notación punto sobre vistas/.
- Includes provisionales sobreviven hasta la Parte III (layouts).
- Ritual: migrar → comparar HTML → recién entonces borrar lo viejo.
4 · La regla de oro: {{ }} y {!! !!}
Intermedio ~15 minEl contrato de seguridad del manual entero se firma aquí. {{ }} NO es
magia: es un echo htmlspecialchars(...) automático. Y {!! !!}
no es «el echo de los valientes»: es una excepción con protocolo. Cerramos la
Parte I dominando cuándo corresponde cada uno — y el default absoluto: llaves.
- Saber EXACTAMENTE qué hace {{ }} por dentro.
- Migrar nuestros e() a {{ }} sin perder protección.
- Usar {!! !!} solo con HTML confiable y bajo protocolo.
- Reconocer el ataque clásico que cada opción permite o bloquea.
Qué hace realmente {{ $x }}
<?php
// {{ $x }} compila EXACTAMENTE a:
echo htmlspecialchars($x, ENT_QUOTES, 'UTF-8', true);
// comillas tb | utf-8 | doble-escape ONTres garantías: las comillas simples TAMBIÉN se escapan (ENT_QUOTES — cubre atributos HTML entre comillas simples), la codificación es UTF-8 fija, y si el valor ya venía escapado no se rompe (double_encode). Es nuestro e() del php_01 con el mismo rigor, aplicado sin posibilidad de olvido:
<!-- ANTES: la disciplina dependia de tu memoria -->
<p><?= e($p['cliente']) ?></p>
<-- DESPUES: imposible olvidarse -->
<p>{{ $p['cliente'] }}</p>La prueba del ácido
<?php $maligno = '<script>alert("XSS")</script>'; ?>
<p>{{ $maligno }}</p>
<!-- sale como TEXTO inocuo:
<script>alert("XSS")</script> -->
<p>{!! $maligno !!}</p>
<!-- EJECUTA el script: XSS consumado -->{!! !!} bajo protocolo
Existen casos donde necesitas imprimir HTML crudo — pero solo cuando TÚ generaste ese HTML, nunca el usuario. El protocolo de la casa:
| Caso | {!! !!} permitido? | Por qué |
|---|---|---|
| HTML construido por TU helper propio | sí | tú controlas cada carácter que entra |
| Contenido de un editor WYSIWYG de admin confiable | solo con sanitización previa (purificador) | "confiable" hoy puede estar comprometido mañana |
| Cualquier input del usuario (nombre, comentario...) | NUNCA | vector de ataque directo |
| Datos de la BD escritos por usuarios | NUNCA | la BD recuerda lo inyectado ayer |
<?php
// helper legitimo para el caso 1 (app/Nucleo/Html.php):
function badgeEstado(string $estado): string
{
$clase = match ($estado) {
'PAGADO' => 'success',
'ANULADO' => 'danger',
default => 'warning', // REGISTRADO y cualquier otro
};
return "<span class='badge text-bg-$clase'>$estado</span>";
}<!-- uso en plantilla: el estado viene de NUESTRO ENUM, no del usuario -->
<td>{!! badgeEstado($p['estado']) !!}</td>Nota fina: el helper recibe $estado de la columna ENUM de MariaDB — valores cerrados REGISTRADO/PAGADO/ANULADO definidos por TI en la migración. Por eso el HTML interno es seguro: la fuente está bajo control total.
El eco mental correcto
# Ahora (Blade): "{{ }} = seguro por defecto, {!! !!} = excepcion justificada"
# Si no puedes JUSTIFICAR las doce llaves, usa dos
Puntos clave
- {{ }} = htmlspecialchars ENT_QUOTES + double_encode: e() automático.
- e($x) del MVC → SIEMPRE {{ $x }}, jamás {!! $x !!}.
- {!! !!} solo con HTML generado por ti desde fuentes cerradas.
- ENUM de la BD + helper propio = fuentes cerradas legítimas.
- El default absoluto son las dos llaves; la excepción se justifica.
5 · Condicionales: @if y familia
Intermedio ~14 minAbre la Parte II con la familia completa de condicionales. Ya usaste @if en el cap 3 sin ceremonia; ahora dominamos las seis variantes y sus matices reales — porque @isset y @empty NO son sinónimos aunque parezcan.
- Migrar el detalle de pedido con cadena @if/@elseif completa.
- Distinguir @isset (¿existe?) de @empty (¿está vacío?).
- Invertir condiciones legiblemente con @unless.
- Conocer qué condicionales de Laravel quedan FUERA de este manual.
La cadena clásica: estado del pedido
<!-- vistas/pedidos/detalle.blade.php (recortado) -->
@if ($pedido['estado'] === 'PAGADO')
<p class="text-success">Pago confirmado el {{ $pedido['fecha_pago'] ?? '—' }}.</p>
@elseif ($pedido['estado'] === 'ANULADO')
<p class="text-danger">Pedido anulado. No admite acciones.</p>
@else
<a href="/pedidos/{{ $pedido['id'] }}/pagar" class="btn btn-success">Pagar</a>
@endifCadena idéntica a PHP if/elseif/else — solo cambia el verbo. Compila al mismo PHP que escribías a mano.
@isset vs @empty: la distinción que importa
| Directiva | Equivale a | true cuando... |
|---|---|---|
| @isset ($x) | isset($x) | $x existe Y no es null |
| @empty ($x) | empty($x) | $x es "", 0, [], null o no existe |
El caso donde se nota: un pedido con total 0.00.
@isset($pedido['descuento']) ... {{-- false si descuento = null --}}
@empty($pedido['descuento']) ... {{-- true si descuento = 0.00 TAMBIEN --}}
{{-- si "cero" es dato valido, usa @isset; si "cero" significa "nada", @empty --}}@unless: la condición leída natural
{{-- en vez de leer una doble negacion: --}}
@if (! $usuario['activo']) ... @endif
{{-- se lee directo: "a MENOS QUE esté activo" --}}
@unless ($usuario['activo'])
<p>Tu cuenta esta suspendida.</p>
@endunlessPura azúcar sintáctica — compila a if(!...). Úsala cuando la frase suene natural; si te cuesta leerla, @if con ! es igual de correcto.
Lo que queda FUERA de este manual
Blade trae condicionales que DEPENDEN del framework completo: @auth/@guest (sistema de auth), @can (policies), @production/@env (entornos Laravel). En nuestro standalone esas decisiones viven en el controlador:
<?php
// el controlador decide, la vista solo pinta:
MotorVistas::render('pedidos/detalle', [
'puedePagar' => session('usuario_id') !== null
&& $pedido['estado'] === 'REGISTRADO',
]);Puntos clave
- @if/@elseif/@else/@endif: la cadena, igual que PHP.
- @isset = isset(); @empty = empty() — cero es «vacío», cuidado.
- @unless invierte legiblemente; azúcar opcional.
- @auth/@can/@env: dependen de Laravel — aquí deciden controladores.
- Compilan al mismo PHP artesanal: rendimiento idéntico.
6 · Bucles: @for, @while y $loop->parent
Intermedio ~15 minEl cap 3 te dio @foreach y las propiedades básicas de $loop. Hoy completamos la familia: los otros dos bucles, la tabla COMPLETA de $loop (incluidas remaining y depth) y la joya para tablas anidadas: $loop->parent.
- Usar @for y @while donde corresponden (y dónde NO).
- Completar el mapa de $loop con remaining y depth.
- Anidar bucles accediendo al loop EXTERIOR con $loop->parent.
- Mantener el presupuesto de queries dentro de cualquier bucle.
@for y @while: casos honestos
{{-- paginador numerico simple del MVC, ahora en Blade: --}}
@for ($i = 1; $i <= $totalPaginas; $i++)
<a href="?page={{ $i }}" {{ $i === $pagina ? 'class="active"' : '' }}>{{ $i }}</a>
@endfor
{{-- @while es raro en vistas: solo para consumir un cursor/cola --}}
@while ($tarea = array_shift($pendientes))
<li>{{ $tarea }}</li>
@endwhileRegla práctica: 95% de los bucles de vista son @foreach sobre colecciones que el controlador ya preparó. @for para secuencias numéricas; @while casi nunca — si lo necesitas, sospecha que esa lógica pertenece al controlador.
El mapa completo de $loop
| Propiedad | Valor | Vista en |
|---|---|---|
| $loop->index | 0-based | cap 3 |
| $loop->iteration | 1-based | cap 3 |
| $loop->remaining | faltantes después del actual | nuevo |
| $loop->count | total ítems | cap 3 |
| $loop->first / ->last | booleanos de borde | cap 3 |
| $loop->even / ->odd | alternancia | cap 3 |
| $loop->depth | 1 = exterior, 2 = anidado… | nuevo |
| $loop->parent | el $loop del nivel superior | nuevo — hoy |
$loop->parent: pedidos con detalles anidados
<table>
@foreach ($pedidos as $pedido)
<tbody>
<tr class="{{ $loop->first ? '' : 'tabla-separador' }}">
<th colspan="3">
Pedido #{{ $pedido['id'] }}
{{-- acceso al loop EXTERIOR desde el anidado: --}}
(fila global {{ $loop->parent ? $loop->parent->iteration : '-' }})
</th>
</tr>
@foreach ($pedido['detalles'] as $d)
<tr>
<td>{{ $d['producto'] }}</td>
<td>x{{ $d['cantidad'] }}</td>
<td>{{ moneda($d['precio_unitario']) }}</td>
</tr>
@endforeach
</tbody>
@endforeach
</table>Dentro del foreach anidado, $loop apunta AL INTERIOR y $loop->parent al exterior. depth te dice en qué nivel estás sin contar llaves. En el artesanal esto exigía guardar el contador externo en una variable auxiliar antes del segundo foreach — aquí el motor mantiene ambos por ti.
La regla que no cambia dentro de ningún bucle
<?php
// MAL — una query POR pedido (N+1):
@foreach ($pedidos as $pedido)
@php $detalles = $this->detalles($pedido['id']); @endphp
...
// BIEN — el controlador trae TODO antes de renderizar:
MotorVistas::render('pedidos/con-detalles', [
'pedidos' => $this->pedidosConDetalles(), // 2 queries totales
]);Puntos clave
- @for para secuencias; @while casi nunca en vistas.
- $loop completo: index/iteration/remaining/count/first/last/even/odd/depth/parent.
- $loop->parent resuelve bucles anidados sin variables auxiliares.
- Ninguna query dentro de bucles: N+1 no se disfraza de @foreach.
- Todo compila a PHP plano: cero penalización vs el artesanal.
7 · @forelse, @continue y @break
Intermedio ~13 minPayoff inmediato: el patrón @if(empty(...)) + @foreach + @else que escribimos en el cap 3 tiene su directiva propia — @forelse. Y el control fino de bucles (@continue/@break) llega con una sorpresa: aceptan CONDICIONES integradas.
- Refactorizar lista.blade.php con @forelse/@empty/@endforelse.
- Saltar ítems con @continue(condición) — no con if adentro.
- Cortar bucles con @break(condición).
- Aprender la forma CORRECTA verificada: condición, no número.
@forelse: la refactorización prometida
Esto era lo nuestro del cap 3:
@if (empty($pedidos))
<p>Sin pedidos todavia.</p>
@else
<table>...@foreach ($pedidos as $p)...</table>
@endif@forelse fusiona los tres bloques en uno:
<table>
@forelse ($pedidos as $p)
<tr>
<td>#{{ $p['id'] }}</td>
<td>{{ $p['cliente'] }}</td>
<td>{{ moneda($p['total']) }}</td>
</tr>
@empty
<tr><td colspan="3">Sin pedidos todavia.</td></tr>
@endforelse
</table>El cuerpo corre por cada ítem; @empty corre UNA vez si el arreglo vino vacío. Menos anidación, imposible olvidar el else del vacío — que era exactamente el bug más común de nuestras vistas MVC.
@continue y @break con condición integrada
Verificado contra la doc oficial: aceptan una expresión booleana entre paréntesis (NO un número de iteraciones):
<?php
// saltar los anulados sin ensuciar con @if adentro:
@foreach ($pedidos as $p)
@continue($p['estado'] === 'ANULADO')
<tr>...fila normal...</tr>
@endforeach
// mostrar hasta encontrar el primer PAGADO:
@foreach ($pedidos as $p)
<li>{{ $p['cliente'] }}</li>
@break($p['estado'] === 'PAGADO')
@endforeach| Forma | Hace | Eco artesanal |
|---|---|---|
| @continue | salta esta iteración | continue; |
| @continue(cond) | la salta SOLO si cond es true | if (cond) continue; |
| @break | corta el bucle | break; |
| @break(cond) | corta SOLO si cond es true | if (cond) break; |
¿Cuándo NO usarlas?
La condición dentro de @continue/@break es lógica de presentación legítima ("saltar anulados", "top 5"). Pero cuidado con la pendiente resbaladiza:
- Bien: filtrar/formatar para MOSTRAR ("saltar ANULADO en este listado público").
- Mal: decidir negocio ("si hay más de 100 pedidos cobrar recargo") — eso jamás se decide en una plantilla.
- Filtrado pesado (subconjuntos reales) → el controlador entrega la colección ya correcta; la vista solo pinta lo recibido.
Lista final refactorizada
<tbody>
@forelse ($pedidos as $p)
@continue($p['estado'] === 'ANULADO')
<tr class="{{ $loop->even ? 'fila-par' : '' }}">
<td>#{{ $loop->iteration }}</td>
<td>{{ $p['cliente'] }}</td>
<td>{{ moneda($p['total']) }}</td>
</tr>
@empty
<tr><td colspan="3">Nada por aqui.</td></tr>
@endforelse
</tbody>Nótese que $loop->iteration sigue contando TODOS los pedidos aunque saltemos anulados — si necesitas numeración solo de los mostrados, usa un contador propio o deja que el controlador filtre antes.
Puntos clave
- @forelse/@empty/@endforelse = foreach + else-vacío atómicos.
- @continue/@break reciben CONDICIÓN booleana, no números.
- $loop sigue contando aunque uses @continue.
- Presentación sí, negocio no: la línea se cruza rápido.
- Filtros reales → controlador; la vista pinta lo recibido.
8 · @switch y el @php que casi nunca
Intermedio ~14 minCierre de la Parte II con dos herramientas de uso restringido: @switch para variantes de presentación y @php para... casi nada, y esa es justamente la lección. Saber CUÁNDO NO usarlas es dominarlas.
- Escribir @switch con @break explícito por caso (obligatorio).
- Comparar switch-inline vs helper vs componente futuro.
- Reconocer el anti-patrón del @php en vistas.
- Conocer @use() para imports cuando sí hace falta.
@switch: variantes de texto por estado
@switch ($pedido['estado'])
@case('REGISTRADO')
<span class="text-warning">Esperando pago</span>
@break
@case('PAGADO')
<span class="text-success">Cobrado</span>
@break
@default
<span class="text-danger">Anulado</span>
@endswitchEl @break es OBLIGATORIO dentro de cada @case — Blade compila a un switch PHP real, sin fall-through automático. Olvidarlo ejecuta también el siguiente caso. Y @default va sin paréntesis ni dos puntos.
¿Switch inline o helper? La guía de decisión
| Situación | Herramienta | Por qué |
|---|---|---|
| Texto simple en UNA vista | @switch inline | localizado y legible |
| Mismo badge/color en 2+ vistas | helper {!! badgeEstado() !!} (cap 4) | DRY — una sola fuente de verdad visual |
| HTML complejo + props variables | componente x- (Parte IV) | encapsulación total |
La escala natural: directiva local → helper → componente. Sube de nivel SOLO cuando el segundo uso aparezca.
@php: el anti-patrón en vivo
{{-- MAL — negocio y queries disfrazados de vista: --}}
@php
$ingresos = model(\App\Models\PedidoModelo::class)
->where('estado', 'PAGADO')->sum('total');
$puedeEditar = session('es_admin') || $pedido['cliente_id'] === session('usuario_id');
@endphp<?php
// BIEN — el controlador decide, la vista recibe decisiones:
MotorVistas::render('pedidos/detalle', [
'ingresos' => $this->pedidos->ingresosTotales(), // query fuera
'puedeEditar' => $this->puedeEditar($pedido), // regla fuera
]);La doc no prohíbe @php — pero nuestro contrato con php_01 sí: la vista pinta, no piensa. Los casos legítimos son raros y triviales (un contador cosmético, una inicialización de una línea). Si tu @php ocupa más de una línea o toca la BD o sesiones, está en el lugar equivocado.
@use(): importar clases donde toque
@use(\App\Nucleo\Html)
<p>{!! \App\Nucleo\Html::badgeEstado($pedido['estado']) !!}</p>@use genera el use PHP al inicio del archivo compilado — útil cuando un helper vive namespaced. Con funciones globales cargadas por composer files (nuestro moneda()) no necesitas nada: ya están disponibles.
Puntos clave
- @switch compila a switch real: @break obligatorio por caso.
- Escala: directiva local → helper → componente (solo al segundo uso).
- @php >1 línea o con BD/sesiones = código en el lugar equivocado.
- @use() importa clases namespaced en la plantilla.
- Vocabulario de flujo completo: listo para estructura.
9 · Herencia: @extends, @yield y @parent
Intermedio ~15 minAquí muere el include() doble. En vez de «cabecera arriba, pie abajo», Blade propone herencia real: UN layout declara los HUECOS (@yield) y cada vista hija los RELLENA (@extends + @section). Es la inversión de perspectiva más importante del manual.
- Escribir un layout con @yield y valores por defecto.
- Distinguir la forma corta y la forma bloque de @section.
- Entender @endsection vs @show (definir vs definir-y-mostrar).
- Agregar contenido con @parent en vez de sobreescribirlo.
El esqueleto mínimo
<!-- vistas/layouts/base.blade.php -->
<!DOCTYPE html>
<html lang="es">
<head>
<title>@yield('titulo', 'Pedidos')</title>
</head>
<body>
<main>
@yield('contenido')
</main>
</body>
</html>@yield marca el hueco; el segundo argumento es el valor si ninguna hija lo llena. La hija invierte el control:
<!-- vistas/pedidos/lista.blade.php -->
@extends('layouts.base')
@section('titulo', 'Listado de pedidos') {{-- forma CORTA: solo texto --}}
@section('contenido') {{-- forma BLOQUE: HTML libre --}}
<table>...</table>
@endsection@endsection vs @show: la pareja mal entendida
| Directiva | Hace | Dónde vive |
|---|---|---|
| @endsection | solo DEFINE la sección | vistas HIJAS |
| @show | define Y muestra al instante | el LAYOUT (secciones por defecto) |
<!-- layout: barra lateral con contenido POR DEFECTO -->
@section('lateral')
<p>Menu generico para cualquier pagina.</p>
@show@show imprime ese contenido ahí mismo SI la vista actual no definió la sección; si la hija la define, gana la hija. Con @yield('x', default) logras lo mismo para texto plano; @show permite HTML por defecto completo.
@parent: agregar sin borrar
Cuando una hija redefine una sección que el layout ya mostraba con @show, puede CONSERVAR el original insertando @parent donde quiera que aparezca:
{{-- vistas/admin/base-interna.blade.php --}}
@extends('layouts.base')
@section('lateral')
@parent
<a href="/admin/usuarios">Usuarios</a>
<a href="/admin/reportes">Reportes</a>
@endsectionResultado: menú genérico + enlaces admin debajo. Sin @parent, los enlaces genéricos habrían desaparecido. El ejemplo oficial de la doc usa exactamente este caso.
Múltiples secciones, un solo layout
<title>@yield('titulo', 'Pedidos')</title>
...
@yield('contenido')
...
@section('pie') © {{ date('Y') }} Tienda @showTodas las vistas del sitio comparten estructura y solo declaran SUS huecos — el fin de sincronizar includes a mano cuando cambia el HTML global.
Puntos clave
- @extends invierte el control: la hija llena los huecos del layout.
- @section corto para texto; bloque para HTML libre.
- @yield(clave, defecto) texto plano; @show para HTML por defecto.
- @endsection define; @show define-y-muestra (doc literal).
- @parent agrega al contenido heredado en vez de reemplazarlo.
10 · El layout maestro de Pedidos
Intermedio ~15 minHora del reemplazo definitivo: cabecera.php y pie.php pasan a mejor vida. Un solo layouts/base.blade.php concentra Bootstrap, navegación, avisos flash y los tres huecos que toda página del sitio necesita.
- Escribir el layout completo del sitio (nav, flash, huecos).
- Migrar lista y detalle a @extends con diff-verificado.
- Marcar el enlace activo del menú según la URI actual.
- Dejar el hueco de scripts listo para @push (Parte VII).
El layout completo
<!-- vistas/layouts/base.blade.php -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('titulo', 'Pedidos') · Tienda</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<nav class="navbar navbar-expand bg-white border-bottom mb-4">
<div class="container">
<a class="navbar-brand fw-bold" href="/">Tienda Pedidos</a>
<div class="navbar-nav">
<a class="nav-link {{ urlEs('pedidos') ? 'active' : '' }}"
href="/pedidos">Pedidos</a>
<a class="nav-link {{ urlEs('productos') ? 'active' : '' }}"
href="/productos">Productos</a>
</div>
</div>
</nav>
<main class="container pb-5">
@if ($aviso = flash())
<div class="alert alert-{{ $aviso['tipo'] }}">{{ $aviso['texto'] }}</div>
@endif
@yield('contenido')
</main>
<script src="/js/bootstrap.bundle.min.js"></script>
@stack('scripts') {{-- hueco para JS por pagina (cap 28) --}}
</body>
</html>urlEs() y flash() son helpers del manual MVC — dentro de las llaves siguen siendo PHP global disponible. El aviso flash lee la sesión exactamente como lo hacía cabecera.php, pero ahora vive en UN lugar.
Las hijas, antes y después
<!-- ANTES: lista.blade.php con include(cabecera)...include(pie) -->
<!-- DESPUES: -->
@extends('layouts.base')
@section('titulo', 'Listado')
@section('contenido')
<table class="table bg-white">
...
</table>
@endsectionLos dos includes desaparecen, la vista declara qué aporta. El ritual de siempre aplica: migrar → comparar HTML → borrar cabecera.php/pie.php cuando NINGUNA vista los incluya más.
urlEs(): el helper de enlace activo
<?php
// app/Nucleo/Html.php (el del cap 4, crece)
function urlEs(string $segmento): bool
{
$ruta = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
return str_starts_with(trim($ruta, '/'), trim($segmento, '/'));
}Mismo front controller, misma REQUEST_URI de siempre — Blade no cambia cómo sabemos dónde estamos, solo dónde pintamos el resultado.
¿Y una segunda familia de páginas?
La herencia permite layouts DERIVADOS: admin/base extiende base y agrega sidebar + @parent en el nav. Las vistas admin extienden admin/base sin tocar el layout público. Jerarquía de plantillas = jerarquía de secciones del sitio.
Puntos clave
- Un layout = Bootstrap + nav + flash + @yield titulo/contenido.
- @stack('scripts'): hueco reservado para JS por página (cap 28).
- Helpers globales (urlEs/flash/moneda) viven dentro de {{ }}.
- Layouts derivados (admin/base) para familias de páginas.
- Borrar includes viejos SOLO cuando ninguna vista los use.
11 · @include: parciales con datos
Intermedio ~14 minLos fragmentos reutilizables — la tarjeta de pedido, la alerta flash — encuentran su forma canónica en @include. Pero trae una característica que puede jugar en contra si no la conoces: las incluidas HEREDAN todas las variables del padre.
- Extraer parciales con datos explícitos (@include + arreglo).
- Entender la herencia de variables del scope padre.
- Aislar una parcial cuando el acoplamiento molesta.
- Nombrar parciales con convención de carpeta propia.
La tarjeta de pedido, reutilizable
<!-- vistas/parciales/tarjeta-pedido.blade.php -->
<div class="card mb-2">
<div class="card-body d-flex justify-content-between">
<span>#{{ $p['id'] }} · {{ $p['cliente'] }}</span>
<strong>{{ moneda($p['total']) }}</strong>
</div>
</div><!-- uso: dato EXPLICITO con nombre corto -->
@include('parciales.tarjeta-pedido', ['p' => $pedido])Doble beneficio del nombre corto: la parcial no depende de cómo se llame la variable afuera ($pedido, $fila, $ultimo...), y queda autodocumentada — p es «el pedido que pinto».
La heredada silenciosa
Doc literal: «todas las variables disponibles en la vista padre estarán disponibles en la vista incluida». Es decir, dentro de tarjeta-pedido también existen $pedidos, $titulo y cualquier otra cosa del listado. Dos caras:
| Cara | Ejemplo |
|---|---|
| Comodidad | la parcial usa moneda() o helpers sin configurar nada |
| Acoplamiento oculto | alguien usa $titulo adentro; un día renombras la variable padre y la parcial revienta lejos de su causa |
Regla de la casa: la parcial declara SUS datos con el arreglo explícito y solo toca esas variables. Si necesitas aislar del todo (parciales genéricas, widgets), existe @includeIsolated para NO heredar nada del padre.
La alerta flash, ahora parcial única
<!-- vistas/parciales/aviso.blade.php -->
@if (! empty($aviso))
<div class="alert alert-{{ $aviso['tipo'] }} alert-dismissible">
{{ $aviso['texto'] }}
<button class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
{{-- layout: --}}
@include('parciales.aviso', ['aviso' => flash()])Convención de carpeta y familia completa
| Directiva | Hace | Vista aquí |
|---|---|---|
| @include('x', [...]) | incluye siempre | cap actual |
| @includeIf('x', ...) | solo si la vista existe | Parte VI |
| @includeWhen($cond, 'x') | solo si la condición manda | Parte VI |
| @includeFirst(['a','b']) | primera que exista (temas/overrides) | Parte VI |
| @each('x', $items, 'var') | una render por ítem | cap 12 |
La convención: carpeta parciales/ para todo lo que no se renderiza solo — igual que decidimos en CI4 y Laravel. El nombre nunca empieza por layouts/ ni coincide con una página real.
Puntos clave
- @include('parciales.x', ['var' => dato]): datos explícitos primero.
- Heredan TODAS las variables del padre: comodidad y riesgo.
- @includeIsolated corta la herencia cuando acopla demasiado.
- Familia includeIf/When/First existe — Parte VI las detalla.
- Parcial = recibe pintable, pinta. Nada de consultas ni decisiones.
12 · @each: una render por ítem
Intermedio ~13 minCuando un bucle solo hace @include por cada fila, Blade lo comprime en UNA línea: @each. Con bonus incluido: la vista para colecciones vacías va integrada. Pero tiene una limitación documentada que cambia cuándo conviene.
- Renderizar filas con @each(vista, colección, variable).
- Manejar colecciones vacías con el cuarto argumento.
- Saber la limitación real: NO hereda variables del padre.
- Elegir entre @foreach+include y @each con criterio.
La parcial de fila
<!-- vistas/parciales/fila-pedido.blade.php -->
<tr>
<td>#{{ $p['id'] }}</td>
<td>{{ $p['cliente'] }}</td>
<td>{{ moneda($p['total']) }}</td>
</tr>El bucle comprimido
<table class="table">
<thead><tr><th>#</th><th>Cliente</th><th>Total</th></tr></thead>
<tbody>
@each('parciales.fila-pedido', $pedidos, 'p')
</tbody>
</table>Tres argumentos: la vista, la colección, y el nombre con el que CADA ítem entra a la vista ($p aquí). La key del arreglo también llega, como $key, por si alguna fila necesita su posición.
Vacío incluido: el cuarto argumento
<!-- vistas/parciales/pedidos-vacio.blade.php -->
<tr><td colspan="3" class="text-center text-secondary">
Sin pedidos todavia.
</td></tr>
{{-- uso: --}}
@each('parciales.fila-pedido', $pedidos, 'p',
'parciales.pedidos-vacio')@forelse sigue siendo perfecto cuando el vacío necesita estructura distinta (mensajes grandes, botones); @each brilla cuando el vacío es OTRA FILA del mismo formato — cero anidación extra.
La limitación que decide
Doc literal: «las vistas renderizadas vía @each NO heredan las variables de la vista padre». Dentro de fila-pedido existen: $p (y $key) y los helpers globales. NADA más:
| Dentro de la parcial @each | ¿Disponible? |
|---|---|
| $p / $key (el ítem) | sí — así se diseñó |
| moneda(), urlEs(), esc implícito… helpers globales | sí |
| $titulo, $usuario o cualquier otra variable del listado padre | NO existe |
| $loop (metadatos del bucle) | no aplica — no hay bucle visible |
Ironía útil: la «limitación» es exactamente el desacoplamiento que cap 11 te pidió como disciplina. @each te obliga a diseñar parciales autosuficientes.
¿@foreach+@include o @each?
| Necesitas... | Herramienta |
|---|---|
| Fila simple, datos propios del ítem, vacío simple | @each |
| $loop (zebra, bordes, numeración), @continue/@break | @foreach + @include |
| Vacío con estructura distinta a las filas | @forelse |
| Lógica condicional por ítem antes de pintar | @foreach + @includeWhen |
Puntos clave
- @each(vista, items, var[, vistaVacia]): bucle + include + vacío.
- $key llega gratis junto al ítem nombrado.
- NO hereda variables del padre — diseño autosuficiente forzado.
- ¿Necesitas $loop? Vuelve a @foreach + @include.
- Tres herramientas de lista: forelse, each, foreach+include.
13 · Tu primer componente anónimo
Intermedio ~13 minLas parciales del cap 11 funcionan, pero heredan TODO el contexto del padre en silencio. Los componentes invierten el contrato: solo existe lo que tú le pasas explícitamente. Es HTML reutilizable de verdad.
- Saber qué aporta un componente sobre una parcial @include.
- Crear vistas/components/alerta.blade.php y usarla como x-alerta.
- Recibir contenido con el slot por defecto ($slot).
- Pasar datos por atributos: cada atributo se vuelve una variable.
Del include al componente
@include('parciales.tarjeta-pedido', ['p' => $pedido]) pide $p pero TAMBIÉN ve $titulo, $usuario y cualquier otra variable de la vista que lo llama (cap 11: herencia silenciosa, dos caras). Un componente es una vista con puerta cerrada: dentro existen únicamente los datos que entran por atributos y por slots. Nada más. El contrato queda escrito EN la etiqueta de uso.
La convención de carpetas
Crea la carpeta components/ dentro de vistas/ y pon ahí un archivo alerta.blade.php. La correspondencia nombre ↔ etiqueta es directa: x-alerta busca la vista components/alerta:
vistas/
├── layouts/
├── parciales/
└── components/ <-- componentes x-
└── alerta.blade.php <-- x-alertaNo hay nada que registrar para componentes así: el compilador de etiquetas que trae illuminate/view (el mismo motor que empaqueta jenssegers/blade) resuelve cualquier tag x-nombre contra la carpeta components/ por defecto. Subcarpetas y prefijos propios llegan en el cap 17.
El componente: $slot y los atributos
<!-- vistas/components/alerta.blade.php -->
<div class="alert alert-{{ $tipo }} d-flex gap-2 py-2" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>{{ $slot }}</div>
</div>Dos variables mágicas aparecen solas dentro de todo componente:
- $slot: el CONTENIDO entre la etiqueta que abre y la que cierra. Se imprime como HTML porque ya fue renderizado.
- Cada atributo = una variable: tipo="warning" en la etiqueta crea $tipo dentro del componente. Nombres kebab-case (data-pedido-id) llegan camelCase si son props declaradas — cap 14.
El uso: HTML con contrato
<x-alerta tipo="warning">
Tu pedido quedó registrado pero sigue <strong>pendiente de pago</strong>.
</x-alerta>
<!-- HTML resultante -->
<div class="alert alert-warning d-flex gap-2 py-2" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>Tu pedido quedó registrado pero sigue <strong>pendiente de pago</strong>.</div>
</div>Léelo como una llamada a función: alerta(tipo: warning) { cuerpo }. Si mañana cambias el markup interno del componente, las veinte vistas que lo usan se actualizan sin tocar una línea.
@include vs componente
| Aspecto | @include (cap 11) | Componente x- |
|---|---|---|
| Variables visibles adentro | TODAS las del padre (heredadas) | solo atributos + slot + compartidas |
| Contrato de datos | implícito — hay que leer la parcial | explícito — se lee en la etiqueta |
| Contenido entre etiquetas | no aplica | $slot / slots con nombre (cap 15) |
| Atributos HTML dinámicos | manual | attribute bag con merge (cap 14) |
| Ideal para... | fragmentos internos de una vista | bloques reutilizados por TODO el sitio |
Regla práctica de Pedidos: la parcial que usa UNA vista sigue siendo @include; en cuanto un bloque aparece en DOS o más vistas, sube a componente.
Puntos clave
- components/alerta.blade.php ⇔ x-alerta: convención, cero registro.
- {{ $slot }} recibe el contenido entre etiquetas.
- Cada atributo de la etiqueta entra como variable.
- Contrato explícito vs herencia silenciosa del @include.
- Un bloque usado en 2+ vistas → componente, no parcial.
14 · @props y el attribute bag
Intermedio ~13 minCuando la etiqueta recibe tipo="danger" class="mb-3" id="aviso-pago", ¿qué es dato de negocio y qué es HTML que debe llegar al markup final? @props traza la frontera y $attributes maneja el resto.
- Declarar datos con @props y valores por defecto.
- Entender qué queda dentro del attribute bag $attributes.
- Fusionar clases con merge() y clases condicionales con class().
- Filtrar atributos: only(), except(), whereStartsWith().
@props separa datos de atributos
<!-- vistas/components/alerta.blade.php -->
@props(['tipo' => 'info'])
<div {{ $attributes->merge(['class' => 'alert alert-'.$tipo.' d-flex gap-2 py-2']) }}
role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>{{ $slot }}</div>
</div>@props(['tipo' => 'info']) declara que tipo es un DATO del componente, con 'info' como valor por defecto si nadie lo pasa. Doc literal: «todo los demás atributos del componente estarán disponibles vía el attribute bag». Es decir: lo declarado se vuelve variable; lo NO declarado se acumula en $attributes. Ese es todo el modelo mental.
$attributes: el resto viaja intacto
$attributes es un objeto (ComponentAttributeBag) con TODOS los atributos que no declaraste en @props. Imprímelo entre llaves dobles y se convierte en texto de etiqueta HTML:
<x-alerta tipo="warning" id="aviso-pago" data-test="flash">
Revisa tu medio de pago.
</x-alerta>
<!-- dentro del componente, {{ $attributes }} vale: -->
<!-- id="aviso-pago" data-test="flash" -->
<!-- nota: tipo NO aparece — quedó consumido por @props -->Por eso el patrón estándar termina siendo: props para los datos que cambian el COMPORTAMIENTO, attribute bag para lo que cambia el ENVOLTORIO (id, data-*, aria-*).
merge(): clases que se suman
El método estrella. Fusiona tus valores por defecto con lo que venga de fuera — y para la clave class CONCATENA en vez de sobreescribir:
<x-alerta tipo="warning" class="mb-3">...</x-alerta>
<!-- merge(['class' => 'alert alert-warning d-flex gap-2 py-2']) produce: -->
<div class="alert alert-warning d-flex gap-2 py-2 mb-3" role="alert">...Sin merge tendrías DOS atributos class o uno pisado por el otro — bug clásico de componentes mal diseñados.
Clases condicionales con class()
Variante para decidir clases DENTRO del componente según su estado:
@props(['tipo' => 'info', 'urgente' => false])
<div {{ $attributes->merge(['class' => 'alert alert-'.$tipo])->class([
'fw-bold' => $urgente,
'border-3' => $urgente,
]) }} role="alert">
...
</div>
<!-- uso: <x-alerta tipo="danger" :urgente="true" class="mb-3"> -->class() acepta un arreglo donde la CLAVE es la clase y el VALOR un booleano: solo entran las verdaderas. Se encadena después del merge sin problema.
Selección fina del bag
| Método | Qué devuelve | Uso típico |
|---|---|---|
| $attributes->merge([...]) | bag fusionado (class concatena) | envoltorio con defaults |
| $attributes->class([...]) | clases condicionales | estados visuales |
| ->only('id', 'data-*') | solo esas claves | reinyectar en un nodo interno |
| ->except('role') | todas menos esas | proteger atributos reservados |
| ->whereStartsWith('data-') | familia completa | pasar todos los data-* al hijo |
| ->has() / ->get() / ->first() | consulta puntual | condiciones sobre atributos |
Puntos clave
- @props = contrato de datos con defaults; el resto → $attributes.
- {{ $attributes }} imprime el bag como texto de etiqueta.
- merge() fusiona y CONCATENA la clave class — nunca la pisa.
- class([...clave => bool]) suma clases condicionales.
- Props: comportamiento · bag: envoltorio (id, data-*, aria-*).
15 · Slots con nombre y @aware
Intermedio ~14 minUn solo $slot se queda corto cuando el componente tiene VARIOS huecos: cabecera, cuerpo, pie. Los slots con nombre resuelven eso; @aware completa el kit dejando que un componente hijo herede datos del padre.
- Definir huecos múltiples con x-slot:title.
- Consumir cada slot como variable dentro del componente.
- Hacer slots opcionales con @isset.
- Propagar datos de un componente a otro con @aware (y su límite).
Un panel con tres huecos
El sitio Pedidos necesita tarjetas con título, cuerpo variable y pie opcional (resumen del pedido, formulario de pago). Componente:
<!-- vistas/components/panel.blade.php -->
@props(['titulo'])
<div {{ $attributes->merge(['class' => 'card mb-3']) }}>
<div class="card-header fw-bold">{{ $titulo }}</div>
<div class="card-body">{{ $slot }}</div>
@isset($pie)
<div class="card-footer text-secondary small">{{ $pie }}</div>
@endisset
</div>Rellenar los huecos
<x-panel titulo="Resumen del pedido" class="border-primary">
<p>3 artículos listos para despacho.</p>
<x-slot:pie>
Total: {{ moneda($total) }} · actualizado {{ date('H:i') }}
</x-slot:pie>
</x-panel>- El contenido directo es el slot por defecto ($slot → card-body).
- x-slot:pie captura SU contenido y lo inyecta como la variable $pie dentro del componente.
- @isset($pie) hace el hueco OPCIONAL: sin x-slot:pie no hay footer.
@aware: herencia entre componentes
Componentes anidados: un menú que contiene ítems, donde el color lo define el padre pero lo necesitan TODOS los hijos. Sin @aware tendrías que repetir color="success" en cada item:
<!-- vistas/components/menu.blade.php -->
@props(['color' => 'secondary'])
<nav {{ $attributes->merge(['class' => 'nav flex-column nav-pills']) }}>
{{ $slot }}
</nav>
<!-- vistas/components/menu/item.blade.php -->
@aware(['color' => 'secondary'])
@props(['href', 'activo' => false])
<a {{ $attributes->merge(['class' => 'nav-link '.($activo ? 'active' : '')]) }}
href="{{ $href }}">{{ $slot }}</a><x-menu color="primary">
<x-menu.item href="{{ urlEs('/pedidos') }}" :activo="true">Pedidos</x-menu.item>
<x-menu.item href="{{ urlEs('/clientes') }}">Clientes</x-menu.item>
</x-menu>@aware(['color' => 'secondary']) dice: busca 'color' en los atributos del componente PADRE; si no está, usa 'secondary'. Los dos items quedan azules sin repetir una palabra.
El límite documentado de @aware
Doc literal: «la directiva @aware NO puede acceder a datos del padre que no sean pasados explícitamente al padre vía atributos HTML». Traducción: solo fluye lo que entra por la ETIQUETA del padre — variables internas del padre, cálculos de su plantilla o datos compartidos NO bajan por @aware. Es un canal estrecho a propósito: el contrato sigue siendo visible en HTML.
| Dato del padre | ¿Llega al hijo con @aware? |
|---|---|
| Atributo de su etiqueta (color="primary") | sí — este es el caso de uso |
| Prop calculada dentro de la plantilla del padre | NO |
| Variable heredada de la vista contenedora | NO |
| Slot del padre (contenido) | NO |
Puntos clave
- x-slot:nombre crea huecos extra → variables $nombre.
- @isset($slotOpcional) = hueco que puede faltar.
- Doc 13.x documenta SOLO la forma tag x-slot:nombre.
- @aware hereda ATRIBUTOS del componente padre, nada más.
- Sin atributo explícito en el padre, @aware usa su default.
16 · Componentes de clase
Avanzado ~14 minCuando un componente necesita lógica de PRESENTACIÓN (calcular totales, mapear estados a colores), el archivo .blade.php se queda chico. La solución es un componente respaldado por una clase PHP — con un matiz importante fuera de Laravel.
- Crear una clase que extienda Illuminate\View\Component.
- Registrarla a mano: fuera del framework no hay auto-descubrimiento.
- Pasar props por el constructor y exponer cálculos como propiedades.
- Elegir entre componente anónimo y de clase con criterio.
El matiz standalone
En Laravel completo, artisan make:component genera la clase en app/View/ Components y el framework la AUTO-DESCUBRE. Nuestro paquete standalone no trae artisan ni convenciones de aplicación — así que la clase se escribe a mano y se REGISTRA explícitamente en el compilador. Dos líneas más, mismo resultado.
La clase
<?php
// app/View/Components/ResumenPedido.php
namespace App\View\Components;
use Illuminate\View\Component;
class ResumenPedido extends Component
{
public readonly int $cantidad;
public readonly float $subtotal;
public function __construct(
public readonly array $items,
public readonly float $iva = 0.21,
) {
$this->cantidad = count($items);
$this->subtotal = array_sum(array_column($items, 'total'));
}
public function render()
{
return 'components.resumen-pedido';
}
}Tres reglas del contrato (verificadas contra doc):
- Los datos entran por el constructor: cada atributo de la etiqueta cuyo nombre coincida con un parámetro se inyecta ahí; los demás van al attribute bag igual que en los anónimos.
- Toda propiedad pública llega a la vista automáticamente: «no es necesario pasar los datos desde render». Por eso $cantidad y $subtotal se calculan EN EL CONSTRUCTOR y quedan expuestos.
- render() devuelve el nombre de la vista del componente — una plantilla blade común y corriente.
La vista del componente
<!-- vistas/components/resumen-pedido.blade.php -->
<div {{ $attributes->merge(['class' => 'card border-success mb-3']) }}>
<div class="card-body">
<h3 class="h6 card-title">Resumen ({{ $cantidad }} artículos)</h3>
<p class="mb-1">Subtotal: {{ moneda($subtotal) }}</p>
<p class="mb-0 fw-bold">
Total con IVA: {{ moneda($subtotal * (1 + $iva)) }}
</p>
</div>
</div>Sin @props dentro: el constructor de la clase YA filtró qué es dato y qué es atributo. El bag sigue disponible para el envoltorio HTML.
Registro y uso
// app/Nucleo/MotorVistas.php — justo tras crear el motor
$motor->compiler()->component(\App\View\Components\ResumenPedido::class);
// alias autoderivado: App\View\Components\ResumenPedido → x-resumen-pedido
// alias explícito si prefieres otro nombre:
$motor->compiler()->component(
\App\View\Components\ResumenPedido::class, 'resumen'
);<!-- en cualquier vista -->
<x-resumen-pedido :items="$detalles" :iva="0.10" />Los dos puntos (:items) significan "evalúa esta EXPRESIÓN PHP"; sin ellos, items="$detalles" sería el texto literal. El motor despacha el render: instancia la clase con los atributos mapeados al constructor, ejecuta render() y rellena la vista con sus propiedades públicas.
¿Anónimo o de clase?
| Necesitas... | Herramienta |
|---|---|
| Markup reutilizable, datos directos | anónimo (.blade.php solo) |
| Cálculos previos al render (totales, conteos) | de clase |
| Mapear ENUM/estados a clases CSS | de clase (cap 18 lo demuestra) |
| Inyectar dependencias del dominio (lector de pedidos) | de clase + contenedor |
| Lógica de NEGOCIO (persistir, validar) | NINGUNA — va en controladores/servicios |
La frontera de siempre, ahora en tres capas: dominio → servicio/controlador → componente. El componente de clase es el techo de la lógica permitida en la capa de presentación: transformar datos ya calculados en markup.
Puntos clave
- Clase extiende Illuminate\View\Component + vista propia.
- Fuera de Laravel: registro manual vía compiler()->component().
- Atributos ↔ parámetros del constructor; resto → attribute bag.
- Props públicas = variables de la vista, automáticas.
- Cálculo en constructor; :expr pasa expresiones, no literales.
17 · x-dynamic-component y subcarpetas
Intermedio ~12 minCierre de organización: componentes en subcarpetas, componentes índice y la pieza flexible final — elegir QUÉ componente renderizar en tiempo de ejecución.
- Agrupar componentes en subcarpetas con notación punto.
- Conocer los componentes índice (.index).
- Renderizar un componente elegido EN EJECUCIÓN con x-dynamic-component.
- Registrar prefijos propios para carpetas especiales.
Subcarpetas: notación punto
Cuando la carpeta components/ crece, agrupa. Doc literal: el punto indica profundidad dentro de components/ — su ejemplo oficial es inputs/button renderizado como <x-inputs.button/>. En Pedidos:
vistas/components/
├── alerta.blade.php <-- x-alerta
├── panel.blade.php <-- x-panel
├── menu/
│ ├── item.blade.php <-- x-menu.item
│ └── grupo.blade.php <-- x-menu.grupo
└── pedidos/
└── fila.blade.php <-- x-pedidos.filaEl nombre de la etiqueta replica EXACTAMENTE la ruta relativa con puntos. Nada más que registrar.
Componentes índice
Carpeta pedidos/ con un archivo pedidos.blade.php dentro: ese archivo ES el componente x-pedidos — útil cuando la carpeta representa una familia y quieres una etiqueta raíz que la abra. Doc: «anonymous index components».
vistas/components/pedidos/pedidos.blade.php
<x-pedidos>
contenido...
</x-pedidos>El componente elegido en ejecución
Hasta hoy el nombre del componente estaba escrito fijo en la plantilla. Si la DECISIÓN depende de datos (tipo de pedido, estado, plan del cliente), <x-dynamic-component> recibe el nombre como expresión:
@php $tarjeta = match ($p['estado']) {
'PAGADO' => 'pedidos.fila-pagado',
'ANULADO' => 'pedidos.fila-anulado',
default => 'pedidos.fila',
} @endphp
<x-dynamic-component :component="$tarjeta" :pedido="$p" />- :component acepta CUALQUIER expresión PHP: variable, ternario, match.
- Los demás atributos (:pedido) llegan al componente elegido igual que siempre — props, bag y slots funcionan idénticos.
- Sintaxis verificada contra doc 13.x, ejemplo literal: <x-dynamic-component :component="$componentName" class="mt-4" />.
Prefijos propios (opcional)
| Necesidad | Herramienta del compilador |
|---|---|
| Carpeta de componentes FUERA de components/ | $motor->compiler()->anonymousComponentPath(ruta, prefijo) |
| Familia con namespace propio | x-admin::panel vía anonymousComponentNamespace(carpeta, prefijo) |
| Paquete de clases externo | compiler()->componentNamespace(namespace, prefijo) |
Pedidos no las necesita HOY — components/ ordenada con subcarpetas alcanza — pero saber que existen evita inventar hacks mañana.
Puntos clave
- x-carpeta.archivo: puntos = profundidad, cero registro.
- carpeta/carpeta.blade.php = componente índice.
- <x-dynamic-component :component="expr"> elige en runtime.
- Props/slots/bag funcionan igual detrás del dinámico.
- @if explícito > dinámico cuando los casos son fijos.
18 · Los badges de estado, ahora componentes
Intermedio ~13 minPAYOFF de la parte. El helper badgeEstado() del MVC artesanal era el único {!! !!} "legítimo" del sitio (cap 4). Hoy se jubila: todo su trabajo pasa a un componente de clase, y con él desaparece el último HTML crudo inyectado en las vistas.
- Migrar badgeEstado() a un componente de clase x-badge-estado.
- Reemplazar sus usos en lista y detalle sin tocar controladores.
- Ganar merge de clases y escape automático en el cambio.
- Cerrar Parte IV con el sitio libre de {!! !!}.
El antes: helper que devolvía HTML
// app/Nucleo/Html.php — versión MVC artesanal (cap 4)
function badgeEstado(string $estado): string
{
[$clase, $icono] = match ($estado) {
'REGISTRADO' => ['info', 'clock'],
'PAGADO' => ['success', 'check-circle'],
'ANULADO' => ['danger', 'x-circle'],
default => throw new InvalidArgumentException($estado),
};
return "<span class='badge text-bg-{$clase}'>
<i class='bi bi-{$icono}'></i> {$estado}</span>";
}
// uso OBLIGATORIO con raw echo — la excepción que cap 4 justificó:
<!-- {!! badgeEstado($p['estado']) !!} -->Funcionaba, pero: el ENUM cerrado vivía en una función global, el markup era un string concatenable, imposible fusionarle clases desde fuera, y cualquier descuido futuro abría puerta a HTML mal formado.
El después: componente de clase
<?php
// app/View/Components/BadgeEstado.php
namespace App\View\Components;
use Illuminate\View\Component;
class BadgeEstado extends Component
{
private const MAP = [
'REGISTRADO' => ['info', 'clock'],
'PAGADO' => ['success', 'check-circle'],
'ANULADO' => ['danger', 'x-circle'],
];
public readonly string $clase;
public readonly string $icono;
public function __construct(public readonly string $estado)
{
[$this->clase, $this->icono] =
self::MAP[$estado]
?? throw new InvalidArgumentException($estado);
}
public function render()
{
return 'components.badge-estado';
}
}<!-- vistas/components/badge-estado.blade.php -->
<span {{ $attributes->merge(['class' => 'badge text-bg-'.$clase]) }}>
<i class="bi bi-{{ $icono }} me-1"></i>{{ $estado }}
</span>Fíjate qué hereda gratis de los caps 14-16: el estado viaja como prop con validación en el constructor (ENUM → InvalidArgumentException), el texto sale escapado por {{ }} SIN construir HTML a mano, y el bag acepta clases extra.
<!-- registro en MotorVistas, junto a ResumenPedido -->
$motor->compiler()->component(\App\View\Components\BadgeEstado::class);
<!-- usos nuevos -->
<x-badge-estado :estado="$p['estado']" />
<!-- más flexible que el helper: contexto visual incluido -->
<x-badge-estado :estado="$p['estado']" class="fs-6" data-test="badge" />La migración, vista como diff
<!-- vistas/pedidos/lista.blade.php -->
- <td>{!! badgeEstado($p['estado']) !!}</td>
+ <td><x-badge-estado :estado="$p['estado']" /></td>
<!-- vistas/pedidos/detalle.blade.php -->
- <h1>Pedido #{{ $p['id'] }} {!! badgeEstado($p['estado']) !!}</h1>
+ <h1>Pedido #{{ $p['id'] }} <x-badge-estado :estado="$p['estado']" /></h1>Ritual de siempre (cap 3): renderiza antes y después, compara el HTML resultante byte a byte — debe ser IDÉNTICO salvo las clases extra que decidas añadir. Controladores intactos: siguen pasando $p tal cual.
Puntos clave
- Helper-de-HTML → componente de clase: mismo output, mejor contrato.
- ENUM validado en constructor; texto escapado por defecto.
- El bag añade lo que el helper nunca pudo: clase/contexto por uso.
- Diff de HTML resultante = prueba de migración fiel.
- Meta cumplida: cero {!! !!} en el sitio completo.
19 · Formularios I: @csrf y @method
Intermedio ~13 minParte V: formularios. Blade trae directivas para las dos piezas que todo form POST necesita — el token anti-CSRF y los verbos HTTP que HTML no soporta. Con una lección de honestidad incluida sobre qué vive en Blade y qué vive en el framework.
- Proteger formularios con @csrf.
- Simular PUT/DELETE con @method (spoofing).
- Entender QUÉ compilan esas directivas realmente.
- Definir los helpers que el paquete standalone no trae.
@csrf
<form method="POST" action="{{ urlEs('/pedidos') }}">
@csrf
<!-- campos... -->
</form>Un atacante puede hacer que EL NAVEGADOR de tu usuario envíe un POST a /pedidos sin su consentimiento (etiqueta form oculta en otra web). El token CSRF — valor aleatorio por sesión que se regenera y se compara en cada POST — lo bloquea: el atacante no puede leerlo, solo inyectar campos visibles. Tu MVC artesanal ya generaba un token parecido; @csrf es la versión estandarizada.
Qué compila de verdad (y lo que falta)
Verificado contra la fuente de illuminate/view 11.x:
// CompilesHelpers.php — la compilación literal:
protected function compileCsrf()
{
return '<?php echo csrf_field(); ?>';
}
protected function compileMethod($method)
{
return "<?php echo method_field{$method}; ?>";
}@csrf NO genera HTML él mismo: llama a csrf_field(), un helper DEL FRAMEWORK Laravel completo. El paquete standalone no lo trae — así que lo definimos nosotros, igual de simples:
<?php
// app/Nucleo/Html.php — helpers de formulario propios
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="_token" value="'.esc(csrf_token()).'">';
}
function method_field(string $metodo): string
{
return '<input type="hidden" name="_method" value="'.esc($metodo).'">';
}Y el front controller valida antes de despachar POST:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['_token'] ?? '';
if (! hash_equals(csrf_token(), $token)) {
http_response_code(419);
exit('Token CSRF inválido');
}
}Misma sintaxis que Laravel, misma responsabilidad repartida: Blade compila la llamada; TU aplicación decide cómo existe el token. En Laravel real, csrf_field() vive en el núcleo del framework — ahora sabes qué hace por ti.
@method: verbos que HTML no tiene
HTML solo soporta GET y POST en forms. Para respetar una API RESTful (PUT/PATCH/DELETE) Laravel popularizó el SPOOFING: un campo _method que el framework lee y usa como verbo efectivo:
<form method="POST"
action="{{ urlEs('/pedidos/'.$p['id']) }}">
@csrf
@method('PUT')
<!-- el navegador envía POST; tu router ve PUT -->
</form>// front controller artesanal — honrar el spoofing:
$metodo = $_SERVER['REQUEST_METHOD'];
if ($metodo === 'POST' && isset($_POST['_method'])) {
$metodo = strtoupper($_POST['_method']); // PUT, PATCH, DELETE
}
// ...y enrutar según $metodoSolo POST se disfraza; GET jamás debe mutar datos, y DELETE disfrazado exige el mismo @csrf — el spoofing no anula la protección.
Puntos clave
- @csrf dentro de TODO form POST, siempre.
- Compilan a llamadas a helpers: csrf_field()/method_field().
- Standalone: esos helpers los defines tú (5 líneas cada uno).
- _method = spoofing de verbos; validar el token igual.
- hash_equals para comparar tokens — timing-safe.
20 · Formularios II: errores y repoblado
Intermedio ~14 minLa segunda mitad de la experiencia de formularios: marcar el campo con error, mostrar el mensaje y devolver al usuario lo que YA había escrito. Todo con @error, old() y las directivas de atributos.
- Marcar errores por campo con @error/@enderror y $message.
- Compartir el bag $errors standalone (MessageBag).
- Repoblar con old() — y por qué NO existe directiva @old.
- @checked / @selected / @disabled combinados con old().
@error: el marcador por campo
<input type="text" name="cliente"
class="form-control @error('cliente') is-invalid @enderror"
value="{{ old('cliente') }}">
@error('cliente')
<div class="invalid-feedback">{{ $message }}</div>
@enderrorDos usos distintos: como clase condicional inline (@error ... @enderror SIN contenido imprime solo el texto "is-invalid" si hay error) y como bloque que muestra el mensaje. Dentro del bloque, la variable $message trae el primer error del campo — doc literal: «you may echo the $message variable».
De dónde sale $errors (standalone)
En Laravel completo, un middleware comparte $errors con TODAS las vistas automáticamente (doc validation: ShareErrorsFromSession). Fuera del framework, ese middleware no existe — lo emulamos con share() del cap 2:
<?php
// controlador Pedidos::crear — validación falló:
$_SESSION['flash_errores'] = $errores; // ['cliente' => '...', ...]
$_SESSION['flash_old'] = $_POST;
redirigir(urlEs('/pedidos/nueva'));
// Pedidos::nueva — GET que re-dibuja el formulario:
if (isset($_SESSION['flash_errores'])) {
$bag = new \Illuminate\Support\MessageBag($_SESSION['flash_errores']);
$motor->share('errors', $bag);
unset($_SESSION['flash_errores'], $_SESSION['flash_old']);
}
return $motor->render('pedidos.formulario');MessageBag viene DENTRO del paquete (illuminate/support) — tiene has(), first(), isNotEmpty(), all(). La compilación de @error verifica exactamente eso: $errors->has($campo), y $message = $errors->first($campo). Cero magia.
old(): repoblar sin directiva
<?php
// app/Nucleo/Html.php
function old(string $campo, mixed $default = null): mixed
{
return $_SESSION['flash_old'][$campo] ?? $default;
}Directivas de atributos + old()
Los ejemplos LITERALES de la doc 13.x muestran la combinación canónica:
<input type="checkbox" name="urgente" value="1"
@checked(old('urgente')) />
<select name="medio">
@foreach (['tarjeta', 'transferencia', 'efectivo'] as $m)
<option value="{{ $m }}" @selected(old('medio') == $m)>
{{ $m }}
</option>
@endforeach
</select>
<button type="submit" @disabled($errors->isNotEmpty())>Guardar</button>Cada una compila (fuente verificada) al mismo patrón mínimo: if(condición): echo 'checked'; endif — idéntico patrón para selected, disabled, required y readonly. Sin expresión, sin atributo: cero ruido en el HTML cuando no aplican.
El flujo completo del formulario de pedido
| Paso | Actor |
|---|---|
| 1 · GET /pedidos/nueva dibuja el form (@csrf + campos) | vista pedidos/formulario |
| 2 · POST /pedidos valida DOBLE (cliente y servidor) | controlador |
| 3 · Falla → flash errores+old, redirect | controlador |
| 4 · GET re-dibuja: @error marca, old() repuebla | vista + MessageBag compartido |
| 5 · Pasa → persiste, flash éxito, redirect (POST-redirect-GET) | controlador + dominio |
Nada nuevo conceptualmente: tu MVC ya hacía validación doble y PRG. Blade solo cambia QUIÉN escribe el markup repetitivo: él.
Puntos clave
- @error inline para is-invalid; bloque + $message para el texto.
- $errors = MessageBag compartido con share() — sin middleware.
- old(): helper, no directiva — default en el 2° argumento.
- @checked/@selected/@disabled(old(...)): la tríada canónica.
- Compilan a if(cond): echo 'attr'; endif — nada más.
21 · El formulario completo del pedido
Intermedio ~14 minSíntesis de la parte: el formulario ALTA PEDIDO completo — token, campos repoblados, errores por campo y botón inteligente — más una refactor final que convierte el campo repetido en componente.
- Escribir el formulario entero con las directivas de caps 19-20.
- Extraer el patrón campo+etiqueta+error a un componente x-forms.campo.
- Ver el flujo PRG completo lado controlador.
- Checklist final de formulario sano.
El patrón repetido que merece componente
Cliente, dirección, notas… cada campo repite label + input + old() + @error. Eso ES un componente anónimo (regla cap 13: usado en 2+ sitios):
<!-- vistas/components/form/campo.blade.php -->
@props(['name', 'label', 'tipo' => 'text'])
<div {{ $attributes->merge(['class' => 'mb-3']) }}>
<label for="{{ $name }}" class="form-label">{{ $label }}</label>
<input type="{{ $tipo }}" id="{{ $name }}" name="{{ $name }}"
value="{{ old($name) }}"
class="form-control @error($name) is-invalid @enderror">
@error($name)
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>Dos detalles finos: @error($name) acepta EXPRESIÓN — compila a $errors->has($name) con la variable evaluada en runtime — y el bag fusiona lo que venga de fuera (class="col-md-6", data-*).
El formulario armado
<!-- vistas/pedidos/formulario.blade.php -->
@extends('layouts.base')
@section('contenido')
<h1>Nuevo pedido</h1>
<form method="POST" action="{{ urlEs('/pedidos') }}" class="row g-3">
@csrf
<x-forms.campo name="cliente" label="Cliente" class="col-md-6" />
<x-forms.campo name="direccion" label="Dirección" class="col-md-6" />
<div class="col-md-4">
<label class="form-label" for="medio">Medio de pago</label>
<select id="medio" name="medio"
class="form-select @error('medio') is-invalid @enderror">
@foreach (['tarjeta', 'transferencia', 'efectivo'] as $m)
<option value="{{ $m }}" @selected(old('medio') == $m)>
{{ ucfirst($m) }}
</option>
@endforeach
</select>
</div>
<div class="col-md-8 d-flex align-items-end">
<div class="form-check">
<input type="checkbox" id="urgente" name="urgente" value="1"
class="form-check-input" @checked(old('urgente'))>
<label class="form-check-label" for="urgente">Envío urgente</label>
</div>
</div>
<div class="col-12 d-flex justify-content-end gap-2">
<a href="{{ urlEs('/pedidos') }}" class="btn btn-outline-secondary">
Cancelar</a>
<button class="btn btn-primary" @disabled($errors->isNotEmpty())>
Registrar pedido
</button>
</div>
</form>
@endsectionMismo formulario que el MVC artesanal, con UNA diferencia estructural: ninguna línea de PHP de presentación vive ya suelto en la vista.
El flujo completo, lado controlador
// PedidosControlador::guardar — POST /pedidos
public function guardar(): void
{
$datos = $_POST;
$errores = ValidadorPedido::validar($datos); // doble validación intacta
if ($errores) {
$_SESSION['flash_errores'] = $errores;
$_SESSION['flash_old'] = $datos;
redirigir(urlEs('/pedidos/nueva')); // PRG: nunca re-POST
}
$pedido = RegistrarPedido::ejecutar($datos); // dominio, como siempre
$_SESSION['flash_exito'] = "Pedido #{$pedido->id} registrado";
redirigir(urlEs("/pedidos/{$pedido->id}"));
}Blade NO tocó el controlador: valida, persiste y redirige igual que en el tutorial de MVC. La capa de presentación absorbió todo el cambio — esa es la señal de que la frontera está bien puesta.
Checklist del formulario sano
| Requisito | Herramienta usada |
|---|---|
| Token anti-CSRF presente | @csrf + hash_equals (cap 19) |
| Nunca confiar en el cliente | Validación doble (MVC, intacta) |
| Errores junto al campo | @error + $message (cap 20) |
| Trabajo del usuario conservado | old() en todos los campos |
| Estado coherente tras POST | redirect, nunca render directo |
| Cero HTML crudo inyectado | solo {{ }} y componentes |
Puntos clave
- Campo repetido = componente: name/label props, resto al bag.
- @error($variable) es válido: compila a expresión runtime.
- PRG + flash errores/old: el controlador no cambió.
- Select/checkbox manuales: @selected/@checked con old().
- @disabled($errors->isNotEmpty()): botón consciente del estado.
22 · @class y @style: presentación condicional
Intermedio ~12 minLas directivas que cierran Parte V: clases y estilos condicionales en CUALQUIER etiqueta — no solo componentes — más el mapa honesto de lo que queda por fuera de nuestro paquete.
- Condicionar clases con @class([...]) en cualquier elemento.
- Lo mismo para estilos inline con @style([...]).
- Distinguir @class de $attributes->class().
- Saber qué directivas del ecosistema NO funcionan standalone.
@class fuera de componentes
El cap 14 usó $attributes->class() — que vive DENTRO de un componente. Para decidir clases en una etiqueta común de cualquier vista, la directiva es @class:
<tr @class([
'table-warning' => $p['estado'] === 'REGISTRADO',
'table-success' => $p['estado'] === 'PAGADO',
])>
...
</tr>
<!-- con una clase fija además, clave numérica = siempre presente -->
<tr @class(['pedido-fila', 'fw-bold' => $p['total'] > 1000])>Semántica idéntica a $attributes->class(): claves numéricas entran SIEMPRE; pares clase => condición entran solo si la condición es verdadera. Compilación verificada en fuente:
// CompilesClasses.php — literal:
protected function compileClass($expression)
{
return "class=\"<?php echo \Illuminate\Support\Arr::toCssClasses{$expression}; ?>\"";
}@style para estilos inline
<div class="progress-bar"
@style(['width' => $porcentaje . '%', 'background' => 'var(--wc-blade)'])>
</div>Mismo arreglo condicional, salida como CSS. Útil para valores calculados (barras, posiciones); para todo lo demás, clases + Bootstrap siguen siendo la opción más sana.
@class vs $attributes->class()
| @class([...]) | $attributes->class([...]) | |
|---|---|---|
| Dónde vive | cualquier vista | dentro de un componente |
| Con qué trabaja | el arreglo que le pasas | además FUSIONA el bag recibido |
| Motor | Arr::toCssClasses | método del attribute bag |
| Úsalo cuando... | pintas una etiqueta suelta | tu componente acepta clases de fuera |
Lo que NO viene en el paquete
Inventario honesto de directivas vistas hasta aquí que dependen del framework completo — verificadas en fuente, todas compilan a helpers de Laravel:
| Directiva | Necesita | Nuestro sustituto |
|---|---|---|
| @csrf / @method (los helpers) | csrf_field()/method_field() | definidos por nosotros (cap 19) |
| @error | $errors compartido | share('errors', MessageBag) (cap 20) |
| @session('status') | helper session() | nuestro flash() de siempre |
| @auth / @guest / @can | guards/policies | fuera de alcance desde cap 5 |
| @checked/@selected/@disabled… | nada — puro PHP | funcionan tal cual |
| @class / @style | nada — illuminate/support | funcionan tal cual |
Criterio permanente: si la compilación llama un helper del framework, standalone toca definirlo o sustituirlo; si compila a PHP puro o a illuminate/support, funciona gratis. Con esta tabla memorizada, ninguna directiva te tomará por sorpresa.
Puntos clave
- @class([...clave => bool]): condicional universal, no solo bag.
- Claves numéricas = clases incondicionales.
- @style igual para estilos inline calculados.
- @session necesita session(): seguimos con flash().
- Test rápido: ¿compila a helper del framework? → revisar.
23 · Colecciones y paginación
Intermedio ~14 minArranca Parte VI (datos): renderizar el paginador del MVC con Blade. La regla de oro se estrena en la capa de presentación: la vista pinta, el controlador CALCULA — hasta la ventana de páginas.
- Mantener el presupuesto de queries al paginar con Blade.
- Calcular la ventana de páginas EN EL CONTROLADOR.
- Pintar el paginador como componente x-paginacion reutilizable.
- Marcar la página activa con @class.
El reparto que no cambia
Tu paginador artesanal ya hace el trabajo duro: LIMIT/OFFSET en UNA query y COUNT en otra. Blade no toca SQL — recibe datos listos. Lo único nuevo: también la GEOMETRÍA del paginador (qué números mostrar, dónde van puntos suspensivos) se calcula fuera de la vista:
// app/Nucleo/Paginador.php — ventana tipo Google
function ventanaPaginas(int $actual, int $total, int $radio = 2): array
{
if ($total <= $radio * 2 + 3) {
return range(1, $total); // pocas páginas: todas
}
$inicio = max(1, $actual - $radio);
$fin = min($total, $actual + $radio);
$rango = range($inicio, $fin);
// null = hueco a dibujar como «…»
if ($inicio > 1) array_unshift($rango, null, 1);
if ($fin < $total) array_push($rango, null, $total);
return $rango;
}// PedidosControlador::indice
$paginacion = PaginarPedidos::ejecutar(
pagina: (int) ($_GET['pagina'] ?? 1),
porPagina: 15,
);
$motor->share('usuario', $_SESSION['usuario'] ?? null);
return $motor->render('pedidos.lista', [
'pedidos' => $paginacion->filas,
'pagina' => $paginacion->actual,
'ventana' => ventanaPaginas($paginacion->actual, $paginacion->paginas),
'base' => urlEs('/pedidos'),
]);El componente paginador
<!-- vistas/components/paginacion.blade.php -->
@props(['pagina', 'ventana', 'base'])
@if (count(array_filter($ventana)) > 1)
<nav aria-label="Paginación">
<ul class="pagination justify-content-center">
<li @class(['page-item', 'disabled' => $pagina <= 1])>
<a class="page-link" href="{{ $base }}?pagina={{ max(1, $pagina - 1) }}">
←</a>
</li>
@foreach ($ventana as $n)
@if ($n === null)
<li class="page-item disabled">
<span class="page-link">…</span></li>
@else
<li @class(['page-item', 'active' => $n === $pagina])>
<a class="page-link" href="{{ $base }}?pagina={{ $n }}">
{{ $n }}
</a>
</li>
@endif
@endforeach
<li @class(['page-item', 'disabled' => $pagina >= count($ventana)])>
<a class="page-link" href="{{ $base }}?pagina={{ $pagina + 1 }}">
→</a>
</li>
</ul>
</nav>
@endifY en lista.blade.php, una línea donde antes había HTML a mano:
<x-paginacion :pagina="$pagina" :ventana="$ventana" :base="$base" />Presupuesto de queries intacto
| Tarea | Quién | Queries |
|---|---|---|
| Total de filas para el COUNT | dominio | 1 |
| Filas LIMIT/OFFSET de esta página | dominio | 1 |
| Ventana de números | aritmética pura | 0 |
| Markup del paginador | x-paginacion | 0 |
Puntos clave
- Blade no pagina: recibe página actual, ventana y base listos.
- Ventana con null = hueco → «…» dibujado en el bucle.
- @class(['active' => $n === $pagina]): activo sin ternarios.
- Dos queries totales, cero en la vista — presupuesto intacto.
- El paginador es un componente más: reutilizable en clientes.
24 · La familia @include condicional
Intermedio ~13 min@include tiene hermanos que deciden ANTES de incluir: si la condición manda (@includeWhen/@includeUnless), si el archivo existe (@includeIf) o cuál de varias vistas usar (@includeFirst). Verificados en T3, ahora con casos reales de Pedidos.
- Incluir bajo condición con @includeWhen y @includeUnless.
- Evitar errores con @includeIf cuando el archivo es opcional.
- Elegir entre candidatas con @includeFirst.
- Recordar qué heredan (y qué no) respecto a @each.
La familia completa
| Directiva | Incluye cuando... |
|---|---|
| @include('v', [...]) | siempre — como cap 11 |
| @includeIf('v') | la vista EXISTE (evita excepción) |
| @includeWhen($cond, 'v') | $cond es verdadera |
| @includeUnless($cond, 'v') | $cond es falsa |
| @includeFirst(['a', 'b']) | la PRIMERA existente de la lista |
| @includeIsolated('v') | siempre, SIN heredar variables (T3) |
Todas heredan las variables del padre igual que @include — salvo @includeIsolated. Y todas aceptan datos extra como segundo argumento, que se suman a lo heredado.
@includeWhen: el banner opcional
<!-- vistas/pedidos/lista.blade.php -->
@includeWhen($usuario['plan'] === 'premium',
'parciales.banner-promo',
['descuento' => 15])
<!-- equivalente artesanal que sustituye: -->
@if ($usuario['plan'] === 'premium')
@include('parciales.banner-promo', ['descuento' => 15])
@endifMisma salida, UNA línea menos de anidación. Regla de gusto: para una sola condición corta gana @includeWhen; si dentro del if hay MÁS cosas además del include, el @if explícito sigue siendo más legible.
@includeUnless: el caso negado natural
<!-- aviso solo para quien NO confirmó su email -->
@includeUnless($usuario['email_confirmado'],
'parciales.aviso-confirmar-email')Pureza semántica: «incluye SALVO QUE». El mismo ejemplo con @includeWhen necesitaría !$usuario['email_confirmado'] — doble negación que se lee peor.
@includeFirst: temas y overrides
<!-- si existe cabecera del tema admin, úsala; si no, la general -->
@includeFirst(['temas.admin.cabecera', 'layouts.cabecera'],
['titulo' => $titulo])Patrón override: lista ordenada de candidatas, gana la primera que exista. Es también cómo Laravel resuelve vistas por defecto vs publicadas por el usuario — el concepto te servirá intacto en el tutorial de framework.
¿Y cuál uso? Mapa de decisión
| Situación | Herramienta |
|---|---|
| Bloque fijo en un lugar | @include |
| Bloque según condición simple | @includeWhen / @includeUnless |
| Vista OPCIONAL que puede no existir | @includeIf |
| Candidatas con prioridad (override) | @includeFirst |
| Fila de bucle, autosuficiente | @each (cap 12) |
| Bloque con contrato HTML propio | componente x- (Parte IV) |
Puntos clave
- @includeWhen/Unless: condición en una línea, sin anidar.
- @includeIf: vista opcional sin try/catch ni exists().
- @includeFirst: patrón override por prioridad de rutas.
- Todos heredan variables del padre; Isolated, no.
- Más de una cosa dentro del if → @if explícito mejor.
25 · Autorización en vistas
Intermedio ~13 minÚltima pieza de datos: mostrar u ocultar acciones según QUIÉN mira. Sin policies ni guards — eso es framework — pero con la misma frontera limpia de siempre: el controlador decide, la vista consulta.
- Compartir el usuario autenticado con TODAS las vistas vía share().
- Consultar permisos con helpers de dominio dentro de {{ }} o @if.
- Saber por qué @can queda fuera de alcance (y qué lo sustituye).
- Entender qué protege de verdad: servidor, no markup.
El usuario, disponible en todas partes
Cada vista que necesite $usuario no debe recibirlo a mano. share() (cap 2) lo pone en todas las renders del request — una línea en el front controller:
// public/index.php — antes de enrutar:
$motor->share('usuario', $_SESSION['usuario'] ?? null);Los helpers que responden «¿puede?»
La REGLA vive en dominio/helpers, no en la vista. Funciones puras, consultables desde cualquier plantilla:
<?php
// app/Nucleo/Autorizacion.php
function puedePagar(array $usuario, array $pedido): bool
{
return $pedido['estado'] === 'REGISTRADO'
&& in_array($usuario['rol'] ?? null, ['operador', 'admin'], true);
}
function puedeAnular(array $usuario, array $pedido): bool
{
return $pedido['estado'] !== 'ANULADO'
&& ($usuario['rol'] ?? null) === 'admin';
}La vista SOLO consulta
<!-- vistas/pedidos/detalle.blade.php -->
@if (puedePagar($usuario, $p))
<form method="POST" action="{{ urlEs('/pedidos/'.$p['id'].'/pagar') }}"
class="d-inline">
@csrf
<button class="btn btn-success btn-sm">Marcar pagado</button>
</form>
@endif
@if (puedeAnular($usuario, $p))
<form method="POST" action="{{ urlEs('/pedidos/'.$p['id'].'/anular') }}"
class="d-inline">
@csrf
@method('PUT')
<button class="btn btn-outline-danger btn-sm">Anular</button>
</form>
@endif
<!-- saludo contextual gratis gracias a share(): -->
<small class="text-secondary">{{ ucfirst($usuario['nombre'] ?? 'invitado') }}</small>Y la otra mitad, SIN la cual todo esto es decoración:
// PedidosControlador::pagar — POST /pedidos/{id}/pagar
$usuario = $_SESSION['usuario'] ?? null;
if (! $usuario || ! puedePagar($usuario, $p)) {
http_response_code(403);
exit('Prohibido');
}
// recién aquí, el dominio...Qué protege cada capa
| Capa | Qué hace | Sin ella... |
|---|---|---|
| Controlador (403) | BLOQUEA la acción | cualquiera paga/anula por POST |
| Vista (@if helper) | OCULTA el botón | solo estética — UX, no seguridad |
| Dominio (regla pura) | define QUIÉN puede | reglas dispersas e inconsistentes |
¿Y @can?
@can es la directiva Blade de Laravel para policies — clases que centralizan estas preguntas por modelo. Queda fuera de alcance (decisión D7, cap 5): ni el paquete standalone la trae operativa ni la necesitamos. Nuestro equivalente funcional: helper puro + controlador que aplica. Cuando llegues a Laravel real (laravel_01), reconocerás @can al instante: es ESTO, empaquetado.
Puntos clave
- share('usuario'): uno para todas las vistas del request.
- Helpers puros puedenX(usuario, recurso): regla en un solo sitio.
- Vista consulta (UX); controlador aplica (seguridad).
- Ocultar botón ≠ autorizar acción — siempre 403 detrás.
- @can/policies = este patrón empaquetado por Laravel.
26 · Render parcial para fetch/AJAX
Intermedio ~13 minCierre de Parte VI: actualizar UNA fila de la tabla sin recargar la página. El truco — que Laravel también usa con sus «htmx-style» endpoints — es servir la MISMA parcial renderizada como respuesta de una ruta.
- Crear un endpoint que devuelve SOLO el fragmento HTML.
- Reutilizar la parcial existente (cero duplicación).
- Conectarlo con fetch() desde el navegador.
- Saber cuándo fragmento y cuándo página completa.
La clave: parciales no extienden layouts
parciales/fila-pedido (cap 12) no tiene @extends — es un TR. Eso significa que $motor->render('parciales.fila-pedido', [...]) devuelve exactamente ese fragmento, sin cabeceras ni scripts: el HTML perfecto para inyectar.
// front controller — ruta nueva:
if ($metodo === 'GET' && preg_match('#^/pedidos/(\d+)/fila$#', $ruta, $m)) {
$p = BuscarPedido::ejecutar((int) $m[1]);
header('Content-Type: text/html; charset=UTF-8');
// SIN layout: la parcial ES la respuesta completa
echo $motor->render('parciales.fila-pedido', ['p' => $p]);
exit;
}El cliente: fetch + swap
<script>
async function refrescarFila(id) {
const r = await fetch(`/pedidos/${id}/fila`);
if (!r.ok) return;
const html = await r.text();
document.getElementById(`fila-${id}`).outerHTML = html;
}
</script>Y la fila necesita su id para ser encontrable — un ajuste a la parcial:
<!-- vistas/parciales/fila-pedido.blade.php -->
<tr id="fila-{{ $p['id'] }}">
<td>#{{ $p['id'] }}</td>
<td>{{ $p['cliente'] }}</td>
<td><x-badge-estado :estado="$p['estado']" /></td>
<td>{{ moneda($p['total']) }}</td>
</tr>El flujo completo: acción (pagar) → fetch POST al endpoint real → fetch GET del fragmento → swap. La parcial sirve para la carga inicial, para el @each del listado Y para el AJAX: tres usos, cero duplicación.
Detalles que separan demo de producción
| Detalle | Por qué importa |
|---|---|
| Content-Type text/html; charset=UTF-8 | el navegador interpreta bien tildes |
| Verificar r.ok antes del swap | 404/500 no deben borrar tu fila |
| Mutación solo por outerHTML completo | innerHTML acumula y rompe listeners |
| POST reales llevan _token manual | @csrf es para FORMS — en fetch, header propio o campo FormData |
| Escape intacto en la parcial | {{ }} sigue protegiendo en fragments |
Puntos clave
- Ruta dedicada que renderiza la PARCIAL como respuesta entera.
- Parcial sin @extends = fragmento listo para inyectar.
- id en el elemento raíz → outerHTML swap limpio.
- @csrf no cubre fetch: token manual en POST por JS.
- Tres usos de una misma parcial: inicial, @each, AJAX.
27 · Compilación y caché: el motor por dentro
Avanzado ~14 minArranca Parte VII. El cap 1 prometió demostrarlo a fondo: Blade NO interpreta plantillas — las COMPILA una vez a PHP plano y luego ejecuta ese PHP mil veces. Hoy abrimos cache/vistas y leemos el resultado.
- Seguir el pipeline completo .blade.php → PHP compilado.
- Entender la regla de expiración (mtime vs mtime).
- Depurar «edité la plantilla y sigue mostrando lo viejo».
- Decidir cuándo limpiar la caché a mano.
El pipeline, paso a paso
| # | Paso | Coste |
|---|---|---|
| 1 | Ruta de la vista → hash xxh128 → nombre del archivo compilado | trivial |
| 2 | ¿Existe compilado Y es más nuevo que la fuente? → usarlo | un stat() |
| 3 | Sino: leer fuente, compilar directivas a PHP, escribir en cache/vistas/ | caro — UNA vez |
| 4 | include del compilado con los datos → HTML | barato — SIEMPRE |
Verificado en fuente (Compiler.php): el nombre compilado es hash('xxh128', 'v2'.ruta).php dentro del directorio de caché que le pasaste al constructor en el cap 2 — por eso cache/vistas/ existe y va en .gitignore.
Leer un archivo compilado
Toma una fila real de cache/vistas/ tras renderizar la lista:
<!-- FUENTE: vistas/parciales/fila-pedido.blade.php -->
<tr id="fila-{{ $p['id'] }}">
<td>{{ $p['cliente'] }}</td>
<!-- COMPILADO: cache/vistas/a3f9...php (nombre-hash) -->
<tr id="fila-<?php echo e($p['id']); ?>">
<td><?php echo e($p['cliente']); ?></td>Ahí está todo el curso en dos líneas: {{ }} se convirtió en echo e(...) — la función de escape que estudiamos en el cap 4, con ENT_QUOTES y UTF-8. Las directivas (@foreach, @if) se convierten en su PHP equivalente. El compilado es PHP LEGIBLE: puedes abrirlo cuando algo no cuadra.
La regla de expiración exacta
// Compiler.php — isExpired(), simplificado:
if (! $this->files->exists($compiled)) {
return true; // no hay compilado: recompilar
}
return $this->files->lastModified($path) // fuente
>= $this->files->lastModified($compiled); // compiladoRecompila si la fuente es IGUAL O MÁS NUEVA que el compilado. Ese >= explica el aviso del cap 2: si editas la plantilla DENTRO del mismo segundo de una render previa (o el reloj del sistema se atrasa), el mtime engaña al motor y sigues viendo la versión vieja aunque el archivo cambió.
El ritual de depuración
| Síntoma | Causa probable | Remedio |
|---|---|---|
| Edité la vista y no cambia nada | compilado más nuevo que la fuente | guardar de nuevo o rm cache/vistas/* |
| Error fatal en un .php raro (hash) | compilado corrupto a medio escribir | borrar ese archivo y recargar |
| «Please provide a valid cache path» | falta el dir de caché / permisos | crear cache/vistas/ escribible |
| Cambios vistos por tu compañero, no por ti | cachés en distintos servidores | deploy: limpiar caché siempre |
Borrar cache/vistas/ entero es SEGURO en cualquier momento: son artefactos regenerables, nunca datos. La próxima request recompila lo que necesite.
Puntos clave
- Blade compila UNA vez a PHP plano; luego solo ejecuta.
- {{ $x }} → <?php echo e($x); ?> — escape incluido en compilación.
- Nombre compilado = hash de la ruta; contenido regenerable.
- Expira si mtime(fuente) >= mtime(compilado) — el >= importa.
- rm -r cache/vistas es seguro: nunca contiene datos.
28 · @once, @push y @stack: assets por página
Avanzado ~14 minEl layout carga SUS scripts; pero una página necesita el suyo, un componente necesita registrar JS solo la primera vez que aparece... Las stacks resuelven el tráfico de assets hijo → layout sin ensuciar secciones.
- Declarar puntos de inyección con @stack en el layout.
- Inyectar desde vistas hijas y componentes con @push.
- Controlar orden con @prepend.
- Evitar duplicados con @once y @pushOnce.
El problema que resuelven
Dos dolores conocidos: (1) la vista detalle quiere un script propio — ¿lo metes en el layout y lo pagas en TODAS las páginas? (2) la parcial fila-pedido incluye un mini-script, y @each la repite 15 veces → 15 copias idénticas. Ambos mueren con las stacks.
@stack: el enchufe en el layout
<!-- vistas/layouts/base.blade.php -->
<script src="/js/app.js"></script>
@stack('scripts') <-- aquí aterriza lo empujado
</body>
</html><!-- vistas/pedidos/detalle.blade.php -->
@extends('layouts.base')
@section('contenido')
...
@endsection
@push('scripts')
<script src="/js/firma-digital.js"></script>
@endpushLa hija NO toca el layout: empuja al stack nombrado y el layout los imprime donde declaró el enchufe. Verificado en fuente: @stack compila a $__env->yieldPushContent(...) y @push a startPush/stopPush — estado del request actual, administrado por la Factory.
Orden y duplicados
@prepend('scripts') empuja AL PRINCIPIO de la pila (para dependencias); @pushOnce('scripts', 'id-firma') combina push + once para no repetir. Y @once a secas protege cualquier bloque dentro de parciales repetidas:
<!-- dentro de parciales/fila-pedido.blade.php (renderizada x15) -->
@once
<script>
document.addEventListener('click', e => {
if (e.target.matches('.btn-pagar')) pagarFila(e.target.dataset.id);
});
</script>
@endonce
<!-- HTML final: UNA sola copia, aunque 15 filas lo pidieron -->@once compila (fuente verificada) a un hasRenderedOnce/markAsRenderedOnce con id único por bloque — memoria DENTRO del request: la siguiente render de la misma parcial ya no imprime el bloque. No persiste entre requests ni usuarios: eso lo hace la caché de assets del navegador.
| Herramienta | Qué garantiza | Úsala para... |
|---|---|---|
| @stack('nombre') | punto de impresión en el layout | el enchufe (uno por zona) |
| @push / @endpush | agregar contenido a la pila | script/estilo de ESTA página |
| @prepend | mismo push, al inicio | dependencias antes que consumidores |
| @once / @endonce | bloque único POR REQUEST | JS inline en parciales repetidas |
| @pushOnce('pila','id') | push + once combinados | librería que N componentes piden |
Puntos clave
- @stack = enchufe con nombre; @push = enchufar desde la hija.
- @prepend ordena; @pushOnce evita librerías duplicadas.
- @once: único por request — ideal en parciales de bucle.
- Nada cruza requests: stacks viven solo durante el render.
- Doc recomienda stacks para scripts (no secciones).
29 · Directivas personalizadas
Avanzado ~14 minEl motor te deja enseñarle palabras nuevas: @moneda, @fechaLarga... La sintaxis que tu proyecto merece. Con una responsabilidad nueva clara: la directiva escribe PHP, y quien la define responde por su escape.
- Registrar directivas con compiler()->directive().
- Entender el contrato: expresión cruda entra, código PHP sale.
- Crear @moneda y usarla en las vistas de Pedidos.
- Saber cuándo una directiva NO es la herramienta (vs componente/helper).
El contrato exacto
Verificado en fuente (BladeCompiler): al compilar, tu handler recibe la EXPRESIÓN como string crudo — sin evaluar — y lo que DEVUELVA se inyecta literalmente en el PHP compilado. No devuelve un valor: devuelve código.
// app/Nucleo/MotorVistas.php — registro en bootstrap, ANTES del primer render
$motor->directive('moneda', function ($expresion) {
return "<?php echo moneda{$expresion}; ?>";
});<!-- en cualquier vista -->
<td>@moneda($p['total'])</td>
<!-- compila a EXACTAMENTE: -->
<td><?php echo moneda($p['total']); ?></td>Tres detalles del contrato (todos verificados en fuente):
- Los paréntesis balanceados exteriores se recortan antes de entregarte la expresión; el resto llega tal cual escrito en la plantilla.
- Nombres válidos: letras, dígitos y guion bajo — regex ^\w+$ del compilador (por eso no existe @mi-moneda).
- La directiva se resuelve en COMPILACIÓN: si registras tarde o cambias el handler, borra cache/vistas para forzar recompilación (cap 27).
Segunda directiva: formato de fecha
$motor->directive('fechaLarga', function ($expresion) {
return "<?php echo esc(date('d \d\e m \d\e Y', strtotime{$expresion})); ?>";
});
<!-- uso -->
<small>Creado el @fechaLarga($p['creado'])</small>El escape es RESPONSABILIDAD TUYA
Nada escapa automáticamente lo que imprime tu directiva — tú generaste el código PHP final. Si los datos pueden contener input de usuario, envuelve con esc()/e() DENTRO del handler, como arriba. Regla de oro (cap 4) aplicada al nivel del motor.
| Herramienta | Cuándo |
|---|---|
| Helper en {{ }} | formateo puntual, una vista o dos |
| @directiva | sintaxis repetida en MUCHAS vistas; azúcar legible |
| Componente x- | bloque HTML estructurado con props/slots |
| @if personalizado (cap 30) | condiciones booleanas con nombre |
Puntos clave
- directivo(expresión-cruda) → devuelve CÓDIGO PHP, no valores.
- @moneda(x) compila a echo moneda(x); — azúcar sobre helpers.
- Registro en bootstrap; cambios exigen recompilar (cap 27).
- El handler es responsable del escape de su salida.
- Nombres ^\w+$: sin guiones, sin espacios.
30 · Custom if statements
Avanzado ~13 minLas directivas del cap 29 inyectan código; los custom ifs generan una FAMILIA completa de condicionales con nombre — @puedePagar, @unlessPuedePagar, @endPuedePagar — a partir de UNA función booleana.
- Registrar condiciones con compiler()->if().
- Saber qué directivas genera exactamente (fuente verificada).
- Confirmar que funciona standalone: la cadena Blade::check.
- Migrar la autorización de Pedidos a condicionales legibles.
El registro
// app/Nucleo/MotorVistas.php — junto a las directivas del cap 29
$motor->compiler()->if('puedePagar', function (array $usuario, array $pedido) {
return puedePagar($usuario, $pedido); // el helper puro de cap 25
});
$motor->compiler()->if('puedeAnular', function (array $usuario, array $pedido) {
return puedeAnular($usuario, $pedido);
});Una llamada genera CUATRO directivas. Verificado en fuente (BladeCompiler::if): @nombre, @unlessnombre, @elsenombre y @endnombre — concatenados tal cual, sin guiones:
| Generada | Equivale a |
|---|---|
| @puedePagar(...) | @if (condición verdadera) |
| @unlesspuedePagar(...) | @if (! condición) |
| @elsepuedePagar(...) | @elseif de la MISMA condición |
| @endpuedePagar | @endif |
¿Y funciona sin Laravel? Sí — la cadena probada
Duda legítima: los custom ifs compilan a una llamada al FACADE \Illuminate\Support\Facades\Blade::check(...) — ¿existe ese facade standalone? Verificado en fuente, la cadena cierra:
| Eslabón | Quién lo provee |
|---|---|
| Facade::setFacadeApplication($container) | jenssegers, en su constructor |
| singleton 'blade.compiler' en el contenedor | ViewServiceProvider que jenssegers ejecuta |
| Blade::check() → make('blade.compiler')->check() | el facade resuelve NUESTRO compilador |
| check() llama tu callable con los argumentos | método check() del propio compilador |
El detalle migrado
<!-- vistas/pedidos/detalle.blade.php — antes (cap 25) -->
@if (puedePagar($usuario, $p))
<form ...>...</form>
@endif
<!-- después -->
@puedePagar($usuario, $p)
<form method="POST" action="{{ urlEs('/pedidos/'.$p['id'].'/pagar') }}">
@csrf
<button class="btn btn-success btn-sm">Marcar pagado</button>
</form>
@endpuedePagar
@unlesspuedeAnular($usuario, $p)
<small class="text-secondary">Este pedido ya no admite cambios.</small>
@endunlesspuedeAnularLa vista ahora LEE como requisitos de negocio. Y la fuente de verdad sigue siendo única: los helpers puros de app/Nucleo/Autorizacion.php — el controlador los sigue aplicando con 403 exactamente igual (cap 25).
Puntos clave
- compiler()->if(nombre, fn): 4 directivas por registro.
- Nombres concatenados: @unlesspuedePagar (sin guión).
- Standalone OK: facade resuelve 'blade.compiler' local.
- Ideal para reglas de negocio booleanas reutilizadas.
- La verdad sigue en helpers puros + 403 en controlador.
31 · Composers y el mapa completo del motor
Avanzado ~13 minCierre de Parte VII: las dos piezas que faltaban del paquete (composer y creator) y el mapa definitivo de TODO lo que el motor ofrece — con el capítulo donde se aprendió cada cosa.
- Inyectar datos comunes con composer() sin tocar controladores.
- Distinguir creator (al crear) de composer (al renderizar).
- Consolidar la API completa del paquete en una tabla.
- Cerrar Parte VII con el motor «sin cajas negras».
composer(): datos que viajan solos
El footer necesita el año; varias vistas, el contador de carrito. Pasarlo por render() desde cada controlador es repetición. composer() registra un callback que corre ANTES de renderizar vistas específicas:
// bootstrap del motor
$motor->composer('layouts.base', function ($vista) {
$vista->with('anio', date('Y'));
});
$motor->composer(['pedidos.lista', 'pedidos.detalle'], function ($vista) {
$vista->with('carrito', ContadorCarrito::actual());
});<!-- layouts/base.blade.php -->
<footer>© {{ $anio }} Pedidos</footer>El orden exacto (verificado en fuente Factory/View): creator al CREAR la instancia de vista (make), composer justo antes de RENDERIZAR su contenido. Por eso un composer puede sobreescribir lo que llegó por render() — úsalo para COMPLETAR, nunca para pisar datos del controlador.
| Herramienta | Cuándo corre | Para qué |
|---|---|---|
| render(...datos) | — | datos ESPECÍFICOS del request |
| share(clave, valor) | global, todo el proceso | usuario autenticado, errors (cap 20/25) |
| creator(views, fn) | al crear la instancia | inicialización temprana |
| composer(views, fn) | justo antes de renderizar | datos comunes a vistas concretas |
El mapa completo del paquete
| API (MotorVistas) | Capítulo | Una línea |
|---|---|---|
| make / render | 2 | renderizar una vista con datos |
| exists / file | 2 | ¿existe? / render por ruta absoluta |
| share | 25 | dato global para todas las vistas |
| composer / creator | 31 | callbacks de inyección por vista |
| addNamespace | 17 | rutas de vistas extra (temas) |
| compiler()->component | 16 | registrar componente de clase |
| compiler()->directive | 29 | enseñar @palabra nueva |
| compiler()->if | 30 | familia de @condicionales |
| cache/vistas/ | 27 | compilados regenerables |
Puntos clave
- composer(): datos comunes sin tocar cada controlador.
- creator corre en make(); composer antes de renderizar.
- Composer completa; jamás pisa los datos del request.
- Nine métodos cubren TODO el paquete standalone.
- Parte VII cerrada: motor transparente de punta a punta.
32 · XSS y el doble escape: dónde Blade protege
Avanzado ~14 minArranca Parte VIII (seguridad y producción). El cap 4 prometió volver a este tema con el motor completo en la mano: hoy, la firma EXACTA de e(), los contextos donde el escape NO alcanza y las capas que faltan.
- Verificar qué hace exactamente {{ }} con la firma real de e().
- Mapear los 4 contextos HTML y cuáles escapa Blade.
- Embedir datos en JavaScript con @json — nunca a mano.
- Auditar el sitio Pedidos contra la checklist.
La firma exacta de e() (fuente verificada)
// Illuminate/Support/helpers.php — literal:
function e($value, $doubleEncode = true)
{
...
return htmlspecialchars(
$value ?? '',
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8',
$doubleEncode
);
}Tres garantías concretas que esto da:
- ENT_QUOTES: escapa comillas simples Y dobles → un valor dentro de atributos entrecomillados no puede salirse.
- ENT_SUBSTITUTE: bytes UTF-8 inválidos se reemplazan — no hay trucos de encoding.
- doubleEncode = true: si el usuario escribió <script>, se muestra COMO TEXTO (&lt;...) — jamás se «descodifica» a markup vivo.
Los cuatro contextos de salida
| Contexto | ¿{{ }} protege? | Herramienta correcta |
|---|---|---|
| Texto del body / celda | sí | {{ $dato }} |
| Valor de atributo ENTRECOMILLADO | sí (ENT_QUOTES) | <a href="{{ $url }}"> |
| URL completa (esquema javascript:) | NO neutraliza | validar esquema antes (allowlist http/https) |
| Código JavaScript inline | NO — contexto distinto | @json($dato) para datos; JS aparte para código |
@json: el caso que más engaña
<script>
// MAL: escape HTML no protege en contexto JS
// var cliente = '{{ $cliente }}';
// BIEN: @json compila a json_encode con flags HEX por defecto
var pedido = @json($pedido);
</script>Verificado en fuente (CompilesJson): @json compila a json_encode(...) cuyo encoding por defecto hex-escapa <, >, &, comillas — un </script> malicioso dentro del dato no puede cerrar tu etiqueta script. Es el mismo mecanismo que Laravel usa internamente para pasar data a sus componentes.
Auditoría del sitio Pedidos
| Superficie | Estado | Por qué |
|---|---|---|
| Echos de datos de pedido/cliente | protegido | todo por {{ }} desde cap 4 |
| {!! !!} en el sitio | cero | jubilado en cap 18 |
| Atributos dinámicos de componentes | protegido | sanitizeComponentAttribute aplica e() (fuente, cap 16) |
| URLs construidas con input | revisar | solo urlEs() interna hoy; allowlist si entra input |
| Datos hacia JS inline | revisar | @json obligatorio si aparece |
Puntos clave
- e(): ENT_QUOTES + ENT_SUBSTITUTE + doubleEncode=true.
- Protege texto y atributos entrecomillados — nada más.
- javascript: en href sobrevive al escape: validar esquema.
- Datos en <script>: SIEMPRE @json, jamás interpolación.
- Componentes heredan e() vía sanitizeComponentAttribute.
33 · Rendimiento y caché en producción
Avanzado ~13 minEl cap 27 explicó CÓMO funciona la caché; hoy, cómo se comporta en un deploy real: qué limpiar, qué capas existen y cómo precalentar para que el primer usuario no pague la fiesta.
- Distinguir las cuatro capas de caché que afectan una página.
- Limpiar caché de vistas en deploy (y por qué).
- Precalentar vistas tras publicar.
- Saber qué NO hay que optimizar.
Las cuatro capas
| Capa | Qué guarda | Quién la gestiona |
|---|---|---|
| Navegador / CDN | CSS, JS, imágenes | headers de cacheo |
| opcache | PHP YA compilado a bytecode | PHP mismo |
| cache/vistas/ (Blade) | plantillas traducidas a PHP | el motor (cap 27) |
| Tus datos | pedidos, sesiones | dominio + BD |
No compiten: se apilan. La plantilla compilada de cache/vistas/ es un .php común — opcache lo bytecodea como cualquier otro archivo. El coste total de renderizar una vista madura tiende al de incluir un PHP cualquiera.
El deploy: limpiar SIEMPRE
Escenario clásico: dos servidores, deploy desplegado a destiempo — uno sirve la plantilla nueva compilada, el otro conserva la vieja. Resultado: el mismo request devuelve HTML distinto según a qué máquina toque. Regla:
# standalone nuestro:
rm -rf cache/vistas/*
# Laravel real hace lo mismo con un comando propio (verificado en fuente,
# ViewClearCommand):
php artisan view:clearBorrar es SEGURO (artefactos regenerables, cap 27) y garantiza que todos los servidores recompilen desde la MISMA versión de las fuentes.
Precalentado opcional
Sin precalentado, los primeros usuarios de cada página pagan su compilación (milisegundos extra, una vez). Un script CLI al terminar el deploy lo reparte entre nadie:
<?php
// scripts/precalentar.php — php scripts/precalentar.php
require __DIR__.'/../public/bootstrap.php';
foreach (['layouts.base', 'pedidos.lista', 'pedidos.detalle',
'pedidos.formulario', 'parciales.fila-pedido'] as $vista) {
$motor->render($vista, ['usuario' => null]);
echo "compilada: {$vista}\n";
}Qué NO optimizar
Puntos clave
- Blade-caché y opcache son capas distintas y complementarias.
- Deploy → limpiar cache/vistas siempre (view:clear en Laravel).
- Precalentar mueve el coste del usuario al deploy.
- Las queries mandan: optimiza ahí antes que en plantillas.
- Borrar compilados jamás borra datos.
34 · Organización de proyectos grandes
Avanzado ~13 minPedidos creció capítulo a capítulo sin que se notara — porque la organización se fue decidiendo EN CADA capítulo. Hoy, la foto completa del árbol y las dos herramientas que faltaban para escalar: namespaces y temas.
- Auditar la estructura final del proyecto.
- Separar familias de vistas con addNamespace (hint ::).
- Implementar temas con override por prioridad.
- Fijar las convenciones que evitan el caos.
El árbol final
proyecto-pedidos/
├── public/ front controller + bootstrap
├── app/
│ ├── Nucleo/ MotorVistas, Html, Autorizacion, Paginador
│ ├── View/
│ │ └── Components/ ResumenPedido, BadgeEstado (clase)
│ ├── Controladores/
│ └── Dominio/ RegistrarPedido, BuscarPedido...
├── vistas/
│ ├── layouts/ base.blade.php
│ ├── pedidos/ lista, detalle, formulario (.blade.php)
│ ├── parciales/ fila-pedido, aviso...
│ └── components/
│ ├── alerta, panel, paginacion (anónimos raíz)
│ ├── form/ campo (x-forms.campo)
│ ├── menu/ item (x-menu.item)
│ └── pedidos/ fila (x-pedidos.fila)
└── cache/vistas/ compilados (gitignore)Namespaces: familias separadas
Cuando entra un panel admin con VISTAS PROPIAS, mezclarlo con el sitio público ensucia. addNamespace crea una familia con hint:
// bootstrap:
$motor->addNamespace('admin', __DIR__.'/../vistas-admin');<!-- cualquier vista: -->
@extends('admin::base')
@include('admin::pedidos.aprobacion')El doble dos puntos es el MISMO sintaxis de hints que Laravel usa para paquetes (mail::, notifications::) — ya lo hablas.
Temas: override por prioridad
¿Un cliente quiere SU cabecera sin bifurcar el código? @includeFirst (cap 24) como patrón de tema:
@includeFirst(['temas.cliente-acme.cabecera', 'layouts.cabecera'])Si existe la vista del tema, gana; si no, la general. El resto del sitio no sabe que hay un tema. Con namespaces puedes combinar ambos: tema-acme::cabecera.
Las convenciones que sostienen todo
| Convención | Regla |
|---|---|
| Parcial vs componente | un solo uso → parcial; 2+ → componente |
| Contrato visible | componentes: props en @props/constructor; parciales: datos explícitos en el include |
| Vocabulario custom | todas las directivas/ifs registradas SOLO en MotorVistas |
| Lógica | dominio calcula; controlador decide; vista pinta |
| Nombres | kebab-case archivos ↔ x-kebab-case etiquetas; subcarpetas = dominio |
Puntos clave
- Estructura por responsabilidad: Nucleo/View/Dominio/vistas.
- admin::base: namespaces para familias completas de vistas.
- includeFirst = temas sin bifurcar código.
- Toda palabra nueva del motor vive en MotorVistas.
- 2+ usos → componente: la regla que evita el caos.
35 · Probar plantillas
Avanzado ~14 minLa última frontera: las vistas también rompen. Un estado ENUM nuevo, un @isset mal cerrado, un cambio de clase CSS que tira un test de E2E... Renderizar en un test y afirmar sobre el HTML cuesta poco y atrapa mucho.
- Renderizar vistas desde un test con el motor real.
- Afirmar SEMÁNTICA del HTML, no bytes exactos.
- Cubrir todos los estados de un ENUM (badge).
- Automatizar el diff ritual de los caps 3/18.
El harness mínimo
El motor standalone es una ventaja para testing: no necesito HTTP ni framework — instancio MotorVistas contra carpetas de prueba y renderizo:
<?php
// tests/VistasPedidosTest.php (PHPUnit)
use App\Nucleo\MotorVistas;
beforeEach(function () {
$this->motor = new MotorVistas(
__DIR__.'/../vistas',
__DIR__.'/../cache/tests-vistas'
);
});
function renderLista(MotorVistas $m, array $pedidos): string
{
return $m->render('pedidos.lista', [
'pedidos' => $pedidos,
'usuario' => ['rol' => 'admin', 'nombre' => 'test'],
]);
}Afirmar semántica, no píxeles
public function lista_muestra_badge_del_estado(): void
{
$html = renderLista($this->motor, [[
'id' => 7, 'cliente' => '<script>x</script>',
'estado' => 'PAGADO', 'total' => 1500,
]]);
// el badge correcto está presente
$this->assertStringContainsString('text-bg-success', $html);
// el cliente malicioso salió ESCAPADO — seguridad visible en el test
$this->assertStringNotContainsString('<script>', $html);
$this->assertStringContainsString('<script>', $html);
}Dos lecciones dentro del test: se afirman MARCAS significativas (la clase del badge, la presencia del escape) — no el HTML completo byte a byte, que volvería el suite frágil ante cualquier retoque estético.
Cubrir el ENUM completo
public function badge_cubre_todos_los_estados(): void
{
foreach (['REGISTRADO', 'PAGADO', 'ANULADO'] as $estado) {
$html = $this->motor->render('components.badge-estado',
['estado' => $estado]);
$this->assertStringContainsString($estado, $html);
}
// y el estado IMPOSIBLE explota con excepción clara (cap 18)
$this->expectException(\InvalidArgumentException::class);
$this->motor->render('components.badge-estado', ['estado' => 'ENVIADO']);
}El diff ritual, automatizado
| Nivel | Herramienta | Cuándo |
|---|---|---|
| Fragmento clave | assertStringContains(String) | siempre — barato y estable |
| ENUM completo | bucle de estados + expectException | componentes con match |
| Migración grande | golden file: HTML esperado vs diff | refactors tipo cap 18 |
| Interacción real | E2E (Playwright) — otro curso | solo flujos críticos |
Puntos clave
- Motor standalone = render en tests sin servidor ni HTTP.
- Afirma marcas semánticas; nunca el HTML completo.
- Un caso por estado del ENUM + el estado imposible.
- El test de escape documenta la seguridad (cap 32).
- Golden files solo para migraciones grandes.
36 · Graduación: Pedidos 100% Blade
Meta ~15 minÚltima parada. Mapa completo del recorrido, la tabla de traducción que resume TODO el vocabulario, el examen de graduación y el puente hacia Eloquent — tu siguiente tutorial.
- Repasar las 8 partes como un solo sistema.
- Domina la tabla artesanal → Blade (vocabulario Laravel).
- Aprobar el examen: sitio Pedidos 100% en Blade.
- Entender qué resuelve Eloquent en la capa de datos.
El mapa final
| Parte | Logro |
|---|---|
| I · Instalación (1-4) | motor standalone sobre TU front controller; {{ }} escapa siempre |
| II · Control (5-8) | condicionales, $loop completa, @forelse/@switch; @php casi nunca |
| III · Layouts (9-12) | herencia real, layout maestro, @include con contrato, @each |
| IV · Componentes (13-18) | x-alerta → clase BadgeEstado; cero {!! !!} en el sitio |
| V · Formularios (19-22) | @csrf/@method, @error/old(), @class condicional, PRG intacto |
| VI · Datos (23-26) | paginador componente, familia @include*, autorización limpia, fragments AJAX |
| VII · Motor (27-31) | compilación+caché, stacks, directivas e ifs propios, composers, mapa API |
| VIII · Producción (32-35) | XSS por contextos, deploy y caché, organización, tests |
La tabla de traducción (adelanto Laravel)
Cada fila es lo que hacías a mano y lo que ahora expresa Blade. En Laravel real estas piezas existen idénticas — ya sabes leerlas todas:
| MVC artesanal | Blade | Visto en |
|---|---|---|
| include 'v.php' + extract() | @include / @each / componentes x- | 11-13 |
| esc() manual en cada echo | {{ }} automático (e()) | 4, 32 |
| cabecera.php + pie.php incluidos | @extends/@yield + layouts | 9-10 |
| helpers que devuelven HTML ({!! !!}) | componentes / directivas propias | 16-18, 29 |
| token CSRF a mano | @csrf + helpers del framework | 19 |
| paginador HTML hardcodeado | componente paginador (+ links() en Laravel) | 23 |
| if/else para permisos sueltos | @puedePagar custom if (@can en Laravel) | 25, 30 |
| scripts repetidos por vista | @push/@stack/@once | 28 |
EXAMEN DE GRADUACIÓN
El sitio Pedidos debe cumplir TODO. Marca sin piedad:
- [ ] Todas las vistas son .blade.php — cero include() artesanal.
- [ ] Un único layout base con @stack('scripts'); hijas solo @section.
- [ ] Cero {!! !!} en todo el proyecto.
- [ ] Formularios: @csrf, @error+$message, old(), @selected/@checked.
- [ ] Mínimo 5 componentes (anónimos y de clase) con props declaradas.
- [ ] Al menos 2 directivas propias y 2 custom ifs centralizadas.
- [ ] Paginación y fragments AJAX vía componentes/parciales reutilizadas.
- [ ] Tests de vista cubren los 3 estados del ENUM + escape malicioso.
- [ ] Deploy documentado: limpiar caché + precalentar.
El puente hacia Eloquent
Quedó UNA deuda consciente desde el cap 1: los pedidos viajan como ARREGLOS ($pedido['cliente']). El motor de vistas ya no importa cómo llegan los datos — pero arreglos no tienen identidad, relaciones ni protección contra el problema N+1 que viste en el cap 6. Eso es exactamente Eloquent: modelos con métodos, relaciones lazy/eager y query builder. Cuando lo domines, tus vistas pasarán de $pedido['cliente'] a $pedido->cliente sin tocar otra línea — porque la frontera quedó bien puesta desde el primer día.
Puntos clave finales
- 8 partes, un sistema: contratos explícitos + fronteras limpias.
- La tabla artesanal→Blade ES el vocabulario de Laravel.
- Examen: 9 entregables verificables en tu repositorio.
- Eloquent ataca la única deuda restante: los datos como arreglos.
- Serie PHP: HTML5 > CSS > JS > PHP > MVC > BLADE ✓ > Eloquent…