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.

36 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil jenssegers/blade v2 · PHP 8.1+
36
Capítulos
150+
Ejemplos de código
3
Niveles: básico a experto
2
Prerrequisitos: PHP · MVC
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada directiva se enseña migrando una vista real del manual MVC: mismo HTML resultante, cero llamadas a e(), herencia real de layouts y componentes que reutilizan HTML como nunca pudimos.

1 · Por qué un motor de plantillas

Básico ~13 min

Nuestro 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

PiezaVersió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
Buclesforeach con llaves mezcladas al HTML@foreach + variable $loop gratis
Layout comúninclude('cabecera.php') arriba y abajo@extends/@section — herencia real
Fragmentosinclude con variables previamente extraídas@include y componentes x-
Errores de sintaxisPHP roto visible al usuarioplantilla 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
@endsection

Mismo 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.

Alcance de este manual: instalamos el motor Blade como paquete composer SOBRE nuestro front controller artesanal. Sin frameworks completos: nuestras rutas, nuestros controladores, nuestro dominio Pedidos. Blade entra donde vivían las vistas — y nada más.

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 min

La 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

composer require jenssegers/blade

# 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

mkdir cache/vistas      # aqui compila Blade (escribible)
touch cache/vistas/.gitignore && echo "*" > cache/vistas/.gitignore
CarpetaPapelRegla
vistas/tus plantillas .blade.phpla de siempre — conviven con los .php viejos
cache/vistas/el PHP compiladoescribible, 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>
# verifica y mira el interior compilado:
# 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.

Si editas una plantilla y no ves cambios: borra el contenido de cache/vistas/ — Blade solo recompila cuando detecta que la plantilla cambió, y en servidores con timestamps raros puede quedarse con la versión vieja. Es el «credenciales fantasma» del mundo plantillas.

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 min

Del 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:

PropiedadValorUso típico
$loop->index0, 1, 2…numerar filas
$loop->iteration1, 2, 3…números para humanos
$loop->first / ->lasttrue en el bordeestilos especiales de borde
$loop->even / ->oddalternanciazebra sin CSS nth-child
$loop->counttotal de ítems"mostrando N registros"
<tr class="{{ $loop->even ? 'fila-par' : '' }}">

Verificación honesta: diff de HTML

# guarda ambas salidas y compara:
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
El ritual de migración de este manual: crear .blade.php → cambiar UNA línea en el controlador → comparar HTML → borrar el .php viejo SOLO cuando el diff esté limpio. Paso a paso, sin big-bang, siempre con salida verificable.

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 min

El 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 ON

Tres 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:
     &lt;script&gt;alert("XSS")&lt;/script&gt; -->

<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 propiotú controlas cada carácter que entra
Contenido de un editor WYSIWYG de admin confiablesolo con sanitización previa (purificador)"confiable" hoy puede estar comprometido mañana
Cualquier input del usuario (nombre, comentario...)NUNCAvector de ataque directo
Datos de la BD escritos por usuariosNUNCAla 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

# Antes (php_01): "echo = peligro, SIEMPRE e()"
# Ahora (Blade): "{{ }} = seguro por defecto, {!! !!} = excepcion justificada"
# Si no puedes JUSTIFICAR las doce llaves, usa dos
El error de migración más común: convertir e($x) en {!! $x !!} «porque así estaba». La traducción correcta de e($x) es SIEMPRE {{ $x }}. Las dobles llaves quedan reservadas para los casos del protocolo — y en Pedidos, prácticamente solo badgeEstado() las merecerá.

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 min

Abre 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>
@endif

Cadena 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

DirectivaEquivale atrue 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>
@endunless

Pura 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',
]);
Regla de la casa reforzada: la vista pregunta por datos ya decididos (puedePagar), nunca decide permisos ella misma. Cuando llegues a Laravel verás @can haciendo este papel — pero la disciplina de «el controlador decide» sigue siendo tuya.

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 min

El 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>
@endwhile

Regla 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

PropiedadValorVista en
$loop->index0-basedcap 3
$loop->iteration1-basedcap 3
$loop->remainingfaltantes después del actualnuevo
$loop->counttotal ítemscap 3
$loop->first / ->lastbooleanos de bordecap 3
$loop->even / ->oddalternanciacap 3
$loop->depth1 = exterior, 2 = anidado…nuevo
$loop->parentel $loop del nivel superiornuevo — 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
]);
Presupuesto de queries intacto: un bucle Blade ejecuta exactamente lo mismo que un foreach PHP — si tu vista consulta adentro, tienes un N+1 disfrazado de directiva bonita. Datos preparados arriba, vista tonta abajo.

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 min

Payoff 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
FormaHaceEco artesanal
@continuesalta esta iteracióncontinue;
@continue(cond)la salta SOLO si cond es trueif (cond) continue;
@breakcorta el buclebreak;
@break(cond)corta SOLO si cond es trueif (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.

El patrón MVC muere oficialmente aquí: «if empty → mensaje / else → foreach» era nuestra firma en cada listado desde php_01. @forelse lo hace atómico. Menos líneas, menos bugs, misma salida.

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 min

Cierre 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>
@endswitch

El @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ónHerramientaPor qué
Texto simple en UNA vista@switch inlinelocalizado y legible
Mismo badge/color en 2+ vistashelper {!! badgeEstado() !!} (cap 4)DRY — una sola fuente de verdad visual
HTML complejo + props variablescomponente 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.

Cierre de Parte II: ya tienes TODO el vocabulario de flujo que el 90% de las plantillas comerciales usa: condicionales, bucles con $loop, forelse, switch. Las partes III-V construyen ESTRUCTURA sobre este vocabulario — layouts, componentes, formularios.

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 min

Aquí 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

DirectivaHaceDónde vive
@endsectionsolo DEFINE la secciónvistas HIJAS
@showdefine Y muestra al instanteel 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>
@endsection

Resultado: 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 @show

Todas las vistas del sitio comparten estructura y solo declaran SUS huecos — el fin de sincronizar includes a mano cuando cambia el HTML global.

El cambio mental: en el artesanal la vista ARMABA la página (includes arriba y abajo). En Blade la página ES el layout y las vistas donan fragmentos. Quien extiende siempre es la hija; el layout jamás sabe quiénes son.

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 min

Hora 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>
@endsection

Los 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.

Balance de deuda pagada: tres archivos menos (cabecera, pie y su sincronización manual), un punto único de cambio visual para TODO el sitio, y avisos flash que ya nadie olvida renderizar. Esto era el objetivo real del motor de plantillas.

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 min

Los 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:

CaraEjemplo
Comodidadla parcial usa moneda() o helpers sin configurar nada
Acoplamiento ocultoalguien 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

DirectivaHaceVista aquí
@include('x', [...])incluye siemprecap actual
@includeIf('x', ...)solo si la vista existeParte VI
@includeWhen($cond, 'x')solo si la condición mandaParte VI
@includeFirst(['a','b'])primera que exista (temas/overrides)Parte VI
@each('x', $items, 'var')una render por ítemcap 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.

Límite de responsabilidad: una parcial no consulta, no decide permisos, no formatea lógica de negocio. Recibe datos pintables y pinta. Si tu parcial necesita "saber cosas", esos datos deben venir en el arreglo — o directamente es un componente (Parte IV).

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 min

Cuando 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
$titulo, $usuario o cualquier otra variable del listado padreNO 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
Cierre de Parte III: layouts con herencia real, parciales con contrato explícito y bucles comprimidos. El sitio Pedidos ya no tiene ni un include() manual. Parte IV sube el nivel: componentes con props, slots y atributos — HTML reutilizable de verdad.

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 min

Las 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-alerta

No 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 adentroTODAS las del padre (heredadas)solo atributos + slot + compartidas
Contrato de datosimplícito — hay que leer la parcialexplícito — se lee en la etiqueta
Contenido entre etiquetasno aplica$slot / slots con nombre (cap 15)
Atributos HTML dinámicosmanualattribute bag con merge (cap 14)
Ideal para...fragmentos internos de una vistabloques 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.

Regla de la casa intacta: un componente nunca consulta la base de datos ni la sesión. Recibe datos ya preparados por el controlador vía atributos/slots y devuelve markup. La lógica pesada sigue fuera de las vistas — ahora con una frontera física que el motor hace cumplir.

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 min

Cuando 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étodoQué devuelveUso típico
$attributes->merge([...])bag fusionado (class concatena)envoltorio con defaults
$attributes->class([...])clases condicionalesestados visuales
->only('id', 'data-*')solo esas clavesreinyectar en un nodo interno
->except('role')todas menos esasproteger atributos reservados
->whereStartsWith('data-')familia completapasar todos los data-* al hijo
->has() / ->get() / ->first()consulta puntualcondiciones sobre atributos
Precisión doc: la documentación no dice que @props «remueve» atributos del bag — dice qué queda en él («all other attributes»). El efecto práctico es ese: una prop declarada jamás aparece duplicada ni en $attributes ni en el HTML resultante.

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 min

Un 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.
Precisión doc (13.x): la forma documentada es la etiqueta <x-slot:nombre>. El compilador todavía acepta la forma legada <x-slot name="pie">, pero ya no aparece en la doc oficial — escríbela siempre con dos puntos.

@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 padreNO
Variable heredada de la vista contenedoraNO
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 min

Cuando 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.
Cuidado: a la vista llegan las PROPIEDADES públicas — no métodos sueltos para llamar desde la plantilla. Si necesitas un valor calculado, calcúlalo en el constructor y guárdalo en una propiedad pública. Mantener la plantilla tonta es exactamente lo que buscamos.

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 directosanónimo (.blade.php solo)
Cálculos previos al render (totales, conteos)de clase
Mapear ENUM/estados a clases CSSde 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 min

Cierre 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.fila

El 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" />.
Moderación: el dinámico oculta qué componente corre tras cada línea — el lector de la plantilla pierde el mapa. Úsalo solo cuando la elección sea REALMENTE un dato en runtime; si son dos casos fijos, un @if con dos etiquetas explícitas documenta mejor.

Prefijos propios (opcional)

NecesidadHerramienta del compilador
Carpeta de componentes FUERA de components/$motor->compiler()->anonymousComponentPath(ruta, prefijo)
Familia con namespace propiox-admin::panel vía anonymousComponentNamespace(carpeta, prefijo)
Paquete de clases externocompiler()->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 min

PAYOFF 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.

Cierre de Parte IV: borra badgeEstado() de Html.php. El sitio Pedidos ya no tiene NI UN {!! !!} — cada carácter de markup sale de una plantilla dueña de su estructura. Esa es exactamente la sensación de trabajar en Laravel real, y la tienes sobre tu front controller propio.

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 min

Parte 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 $metodo

Solo POST se disfraza; GET jamás debe mutar datos, y DELETE disfrazado exige el mismo @csrf — el spoofing no anula la protección.

Precisión doc 13.x: la doc muestra la colocación dentro del form y la sintaxis @method('PUT'), pero NO muestra el HTML generado en esa página — por eso lo verificamos en fuente. La forma manual equivalente sí está documentada en routing: <input type="hidden" name="_token" value="{{ csrf_token() }}">.

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 min

La 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>
@enderror

Dos 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

Precisión doc 13.x: NO existe una directiva @old en Blade — el repoblado documentado es el helper global old('campo'): devuelve el valor enviado anteriormente o null. Como con csrf_field(), lo definimos nosotros leyendo $_SESSION['flash_old']. El default va de segundo argumento: old('iva', 0.21).
<?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

PasoActor
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, redirectcontrolador
4 · GET re-dibuja: @error marca, old() repueblavista + 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 min

Sí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>
@endsection

Mismo 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

RequisitoHerramienta usada
Token anti-CSRF presente@csrf + hash_equals (cap 19)
Nunca confiar en el clienteValidación doble (MVC, intacta)
Errores junto al campo@error + $message (cap 20)
Trabajo del usuario conservadoold() en todos los campos
Estado coherente tras POSTredirect, nunca render directo
Cero HTML crudo inyectadosolo {{ }} 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 min

Las 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}; ?>\"";
}
Buenas noticias standalone: Arr::toCssClasses vive en illuminate/support, que SÍ trae nuestro paquete. @class y @style funcionan sin definir nada — al revés que csrf_field() y old() (cap 19-20).

@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 vivecualquier vistadentro de un componente
Con qué trabajael arreglo que le pasasademás FUSIONA el bag recibido
MotorArr::toCssClassesmétodo del attribute bag
Úsalo cuando...pintas una etiqueta sueltatu 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:

DirectivaNecesitaNuestro sustituto
@csrf / @method (los helpers)csrf_field()/method_field()definidos por nosotros (cap 19)
@error$errors compartidoshare('errors', MessageBag) (cap 20)
@session('status')helper session()nuestro flash() de siempre
@auth / @guest / @canguards/policiesfuera de alcance desde cap 5
@checked/@selected/@disabled…nada — puro PHPfuncionan tal cual
@class / @stylenada — illuminate/supportfuncionan 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 min

Arranca 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) }}">
                    &larr;</a>
            </li>

            @foreach ($ventana as $n)
                @if ($n === null)
                    <li class="page-item disabled">
                        <span class="page-link">&hellip;</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 }}">
                    &rarr;</a>
            </li>
        </ul>
    </nav>
@endif

Y 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

TareaQuiénQueries
Total de filas para el COUNTdominio1
Filas LIMIT/OFFSET de esta páginadominio1
Ventana de númerosaritmética pura0
Markup del paginadorx-paginacion0
Referencia: Laravel trae su propio paginador con vistas incluidas y un método ->links() (doc pagination). Cuando llegues a Eloquent lo usarás; aquí construimos el nuestro sobre componentes porque el dominio es artesanal — y así ves QUÉ hace ese links() por dentro: exactamente este componente, más bonito.

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

DirectivaIncluye 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])
@endif

Misma 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ónHerramienta
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 propiocomponente x- (Parte IV)
Frontera honesta: la familia @include hereda TODO el contexto del padre — útil pero silencioso (cap 11). Cuando el bloque necesite un CONTRATO visible o atributos HTML dinámicos, ya sabes: componente, no include.

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

CapaQué haceSin ella...
Controlador (403)BLOQUEA la accióncualquiera paga/anula por POST
Vista (@if helper)OCULTA el botónsolo estética — UX, no seguridad
Dominio (regla pura)define QUIÉN puedereglas dispersas e inconsistentes
Regla de oro: ocultar el botón NO protege nada — un curl casero lo salta. La vista consulta el helper para la experiencia; el controlador lo APLICA antes de tocar el dominio. Misma función, dos usos.

¿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 min

Cierre 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

DetallePor qué importa
Content-Type text/html; charset=UTF-8el navegador interpreta bien tildes
Verificar r.ok antes del swap404/500 no deben borrar tu fila
Mutación solo por outerHTML completoinnerHTML 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
Fragmento vs página: si la actualización cambia la URL, el título o la navegación, devuelve la PÁGINA completa y recarga — los fragments brillan en micro-actualizaciones (badge cambia, contador sube), no en navegación real. Y si mañana adoptas htmx/Livewire, ya entiendes su modelo mental completo: este capítulo.

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 min

Arranca 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

#PasoCoste
1Ruta de la vista → hash xxh128 → nombre del archivo compiladotrivial
2¿Existe compilado Y es más nuevo que la fuente? → usarloun stat()
3Sino: leer fuente, compilar directivas a PHP, escribir en cache/vistas/caro — UNA vez
4include del compilado con los datos → HTMLbarato — 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); // compilado

Recompila 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íntomaCausa probableRemedio
Edité la vista y no cambia nadacompilado más nuevo que la fuenteguardar de nuevo o rm cache/vistas/*
Error fatal en un .php raro (hash)compilado corrupto a medio escribirborrar ese archivo y recargar
«Please provide a valid cache path»falta el dir de caché / permisoscrear cache/vistas/ escribible
Cambios vistos por tu compañero, no por ticachés en distintos servidoresdeploy: 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.

Por qué importa: sin esta arquitectura, cada request re-parsearía todas las plantillas. Con ella, el coste de Blade en producción tiende al de PHP puro: el precio se paga UNA vez por versión de cada plantilla. Es el mismo contrato mental que opcache — y ahora sabes explicarlo.

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 min

El 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>
@endpush

La 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.

HerramientaQué garantizaÚsala para...
@stack('nombre')punto de impresión en el layoutel enchufe (uno por zona)
@push / @endpushagregar contenido a la pilascript/estilo de ESTA página
@prependmismo push, al iniciodependencias antes que consumidores
@once / @endoncebloque único POR REQUESTJS inline en parciales repetidas
@pushOnce('pila','id')push + once combinadoslibrería que N componentes piden
Pago de deuda (cap 10): el layout maestro dejó el placeholder «teaser cap28» justo aquí. Migración: quita los scripts hardcodeados de las hijas, decláralos @push('scripts'), deja UN @stack('scripts') en base. Dif del HTML final: mismas etiquetas, mismo orden relativo — cero sorpresas.

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 min

El 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.

HerramientaCuándo
Helper en {{ }}formateo puntual, una vista o dos
@directivasintaxis repetida en MUCHAS vistas; azúcar legible
Componente x-bloque HTML estructurado con props/slots
@if personalizado (cap 30)condiciones booleanas con nombre
Moderación: cada directiva custom es vocabulario que solo existe en TU proyecto. Laravel pide mesura por la misma razón; nosotros, además, porque mañana ese vocabulario viaja a Eloquent/Laravel tal cual — documéntalo en un solo lugar (el registro del motor).

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 min

Las 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:

GeneradaEquivale 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ónQuién lo provee
Facade::setFacadeApplication($container)jenssegers, en su constructor
singleton 'blade.compiler' en el contenedorViewServiceProvider que jenssegers ejecuta
Blade::check() → make('blade.compiler')->check()el facade resuelve NUESTRO compilador
check() llama tu callable con los argumentosmé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>
@endunlesspuedeAnular

La 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).

Directiva vs custom if: usa compiler()->if() cuando el resultado es SIEMPRE un booleano de decisión (genera toda la familia gratis); usa directive() cuando quieres transformar/FORMATEAR valores (@moneda). Son dos verbos distintos: decidir vs formatear.

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 min

Cierre 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>&copy; {{ $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.

HerramientaCuándo correPara qué
render(...datos)datos ESPECÍFICOS del request
share(clave, valor)global, todo el procesousuario autenticado, errors (cap 20/25)
creator(views, fn)al crear la instanciainicialización temprana
composer(views, fn)justo antes de renderizardatos comunes a vistas concretas

El mapa completo del paquete

API (MotorVistas)CapítuloUna línea
make / render2renderizar una vista con datos
exists / file2¿existe? / render por ruta absoluta
share25dato global para todas las vistas
composer / creator31callbacks de inyección por vista
addNamespace17rutas de vistas extra (temas)
compiler()->component16registrar componente de clase
compiler()->directive29enseñar @palabra nueva
compiler()->if30familia de @condicionales
cache/vistas/27compilados regenerables
Cierre de Parte VII: ya no queda ninguna caja negra. Cada llamada que haces tiene dueño identificado: o es PHP puro compilado (cap 27), o estado de la Factory (caps 15/28), o tu propio registro (29-31). El motor manda sobre TU front controller, pero ahora sabes CÓMO obedece.

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 min

Arranca 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ó &lt;script&gt;, se muestra COMO TEXTO (&amp;lt;...) — jamás se «descodifica» a markup vivo.

Los cuatro contextos de salida

Contexto¿{{ }} protege?Herramienta correcta
Texto del body / celda{{ $dato }}
Valor de atributo ENTRECOMILLADOsí (ENT_QUOTES)<a href="{{ $url }}">
URL completa (esquema javascript:)NO neutralizavalidar esquema antes (allowlist http/https)
Código JavaScript inlineNO — 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

SuperficieEstadoPor qué
Echos de datos de pedido/clienteprotegidotodo por {{ }} desde cap 4
{!! !!} en el sitiocerojubilado en cap 18
Atributos dinámicos de componentesprotegidosanitizeComponentAttribute aplica e() (fuente, cap 16)
URLs construidas con inputrevisarsolo urlEs() interna hoy; allowlist si entra input
Datos hacia JS inlinerevisar@json obligatorio si aparece
Panorama honesto: el escape es UNA capa. Un sitio madre suma: cookies con HttpOnly/SameSite, cabecera CSP, validación de entrada (ya la tienes: validación doble) y actualizaciones del motor. Blade resuelve su capa excepcionalmente bien — pero no sustituye al resto.

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 min

El 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

CapaQué guardaQuién la gestiona
Navegador / CDNCSS, JS, imágenesheaders de cacheo
opcachePHP YA compilado a bytecodePHP mismo
cache/vistas/ (Blade)plantillas traducidas a PHPel motor (cap 27)
Tus datospedidos, sesionesdominio + 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:clear

Borrar 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

Anti-prematurez: micro-gestionar {{ }} vs {!! !!} por rendimiento no tiene sentido (la diferencia es ruido); tampoco evitar componentes «porque anidan». Los cuellos de botella reales viven en las QUERIES — tu presupuesto de queries (caps 6 y 23) sigue siendo la única métrica que importa primero. Mide después: TTFB y tiempo por request.

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 min

Pedidos 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ónRegla
Parcial vs componenteun solo uso → parcial; 2+ → componente
Contrato visiblecomponentes: props en @props/constructor; parciales: datos explícitos en el include
Vocabulario customtodas las directivas/ifs registradas SOLO en MotorVistas
Lógicadominio calcula; controlador decide; vista pinta
Nombreskebab-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 min

La ú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('&lt;script&gt;', $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

NivelHerramientaCuándo
Fragmento claveassertStringContains(String)siempre — barato y estable
ENUM completobucle de estados + expectExceptioncomponentes con match
Migración grandegolden file: HTML esperado vs diffrefactors tipo cap 18
Interacción realE2E (Playwright) — otro cursosolo flujos críticos
El pago: cuando el examen del próximo capítulo te pida migrar otra vista, tus tests dirán «sigue igual» en segundos. La confianza de refactorizar sin miedo — esa es la verdadera función de las pruebas de vista.

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

ParteLogro
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 artesanalBladeVisto 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 + layouts9-10
helpers que devuelven HTML ({!! !!})componentes / directivas propias16-18, 29
token CSRF a mano@csrf + helpers del framework19
paginador HTML hardcodeadocomponente paginador (+ links() en Laravel)23
if/else para permisos sueltos@puedePagar custom if (@can en Laravel)25, 30
scripts repetidos por vista@push/@stack/@once28

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.

Felicidades: dominas el motor de plantillas que mueve a Laravel, sobre tu propio front controller, con reglas de seguridad y testing de nivel producción. Siguiente estación de la serie: php_03_eloquent.html. Nos vemos ahí.

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…